Framework Independent
Use with React, Angular, Svelte, Vue, Solid,
or without any UI framework.
Smooth
We pay attention to how the library components look and how they react to data changes. Styles are customizable via CSS variables.
Built with Typescript
Unovis is built with TypeScript, with its options documented in its types for your editor and your AI agent. Import individual components to keep your bundle small.
- React
- Angular
- Svelte
- Vue
- Solid
- TypeScript
- Data
import React, { useCallback } from 'react'
import { VisXYContainer, VisAxis, VisArea, VisXYLabels, VisCrosshair, VisTooltip, VisBulletLegend } from '@unovis/react'
import { data, formats, DataRecord, getLabels, gradients, legendItems, svgDefs, tooltipTemplate } from './data'
const labels = getLabels(data)
const x = (d: DataRecord): number => d.year
const y = formats.map(f => (d: DataRecord): number => d[f])
const color = (_: DataRecord[], i: number): string => gradients[i]
export default function StackedArea (): JSX.Element {
return (
<>
<VisBulletLegend items={legendItems}/>
<VisXYContainer data={data} height={'50vh'} svgDefs={svgDefs}>
<VisArea x={x} y={y} color={color}/>
<VisAxis type='x' label='Year' numTicks={10} gridLine={false} domainLine={false}/>
<VisAxis type='y' label='Revenue (USD, billions)' numTicks={10}/>
<VisXYLabels
x={useCallback((d: DataRecord) => labels[d.year] ? d.year : undefined, [])}
y={useCallback((d: DataRecord) => labels[d.year]?.value, [])}
label={useCallback((d: DataRecord) => labels[d.year]?.label, [])}
backgroundColor={useCallback((d: DataRecord) => labels[d.year]?.color ?? 'none', [])}
clusterBackgroundColor="none"
clusterLabel={() => ''}
/>
<VisCrosshair x={x} yStacked={y} template={tooltipTemplate}/>
<VisTooltip/>
</VisXYContainer>
</>
)
}
from-vinyl-to-streaming.html
<vis-bullet-legend [items]="legendItems"></vis-bullet-legend>
<vis-xy-container [data]="data" [height]="500" [svgDefs]="svgDefs">
<vis-area [x]="x" [y]="y" [color]="color"></vis-area>
<vis-axis type="x" label="Year" [numTicks]="10" [domainLine]="false" [gridLine]="false"></vis-axis>
<vis-axis type="y" label="Revenue (USD, billions)" [numTicks]="10"></vis-axis>
<vis-xy-labels
[x]="x"
[y]="labelY"
[label]="labelText"
[backgroundColor]="labelColor"
[clusterLabel]="noLabel"
clusterBackgroundColor="none"
></vis-xy-labels>
<vis-crosshair [x]="x" [yStacked]="y" [template]="tooltipTemplate"></vis-crosshair>
<vis-tooltip></vis-tooltip>
</vis-xy-container>
from-vinyl-to-streaming.component.ts
import { Component } from '@angular/core'
import { data, formats, DataRecord, getLabels, gradients, legendItems, svgDefs, tooltipTemplate } from './data'
@Component({
selector: 'stacked-area-chart',
templateUrl: './stacked-area-chart.component.html',
standalone: false,
})
export class StackedAreaComponent {
data = data
legendItems = legendItems
svgDefs = svgDefs
x = (d: DataRecord): number => d.year
y = formats.map(f => (d: DataRecord) => d[f])
color = (_: DataRecord[], i: number): string => gradients[i]
tooltipTemplate = tooltipTemplate
labelItems = getLabels(this.data)
labelY = (d: DataRecord): number => this.labelItems[d.year]?.value ?? 0
labelText = (d: DataRecord): string => this.labelItems[d.year]?.label ?? ''
labelColor = (d: DataRecord): string => this.labelItems[d.year]?.color ?? 'none'
noLabel = (): string => ''
}
from-vinyl-to-streaming.module.ts
import { NgModule } from '@angular/core'
import {
VisXYContainerModule,
VisAreaModule,
VisXYLabelsModule,
VisAxisModule,
VisCrosshairModule,
VisTooltipModule,
VisBulletLegendModule,
} from '@unovis/angular'
import { StackedAreaComponent } from './stacked-area-chart.component'
@NgModule({
imports: [VisXYContainerModule, VisAreaModule, VisXYLabelsModule, VisAxisModule, VisCrosshairModule, VisTooltipModule, VisBulletLegendModule],
declarations: [StackedAreaComponent],
exports: [StackedAreaComponent],
})
export class StackedAreaModule { }
<script lang="ts">
import { VisXYContainer, VisAxis, VisArea, VisXYLabels, VisCrosshair, VisTooltip, VisBulletLegend } from '@unovis/svelte'
import type { DataRecord } from './data'
import { data, formats, getLabels, gradients, legendItems, svgDefs, tooltipTemplate } from './data'
const labels = getLabels(data)
const x = (d: DataRecord) => d.year
const y = formats.map(g => (d: DataRecord) => d[g])
const color = (_: DataRecord[], i: number) => gradients[i]
const labelProps = {
x: (d: DataRecord) => labels[d.year] ? d.year : undefined,
y: (d: DataRecord) => labels[d.year]?.value,
label: (d: DataRecord) => labels[d.year]?.label,
backgroundColor: (d: DataRecord) => labels[d.year]?.color ?? 'none',
}
</script>
<VisBulletLegend items={legendItems}/>
<VisXYContainer {data} height={500} {svgDefs}>
<VisXYLabels {...labelProps} clusterBackgroundColor="none" clusterLabel={() => ''}/>
<VisArea {x} {y} {color}/>
<VisAxis type="x" label="Year" numTicks={10} gridLine={false} domainLine={false}/>
<VisAxis type="y" label="Revenue (USD, billions)" numTicks={10}/>
<VisCrosshair x={x} yStacked={y} template={tooltipTemplate}/>
<VisTooltip/>
</VisXYContainer>
<script setup lang="ts">
import { VisXYContainer, VisAxis, VisArea, VisXYLabels, VisCrosshair, VisTooltip, VisBulletLegend } from '@unovis/vue'
import { data, formats, DataRecord, getLabels, gradients, legendItems, svgDefs, tooltipTemplate } from './data'
const labels = getLabels(data)
const x = (d: DataRecord) => d.year
const y = formats.map(g => (d: DataRecord) => d[g])
const color = (_: DataRecord[], i: number) => gradients[i]
const labelProps = {
x: (d: DataRecord) => labels[d.year] ? d.year : undefined,
y: (d: DataRecord) => labels[d.year]?.value,
label: (d: DataRecord) => labels[d.year]?.label,
backgroundColor: (d: DataRecord) => labels[d.year]?.color ?? 'none',
}
</script>
<template>
<VisBulletLegend :items="legendItems" />
<VisXYContainer :data :height="500" :svgDefs>
<VisXYLabels v-bind="labelProps" clusterBackgroundColor="none" :clusterLabel="() => ''" />
<VisArea :x :y :color />
<VisAxis type="x" label="Year" :numTicks="10" :gridLine="false" :domainLine="false" />
<VisAxis type="y" label="Revenue (USD, billions)" :numTicks="10" />
<VisCrosshair :x :yStacked="y" :template="tooltipTemplate" />
<VisTooltip />
</VisXYContainer>
</template>
import { JSX } from 'solid-js'
import { VisArea, VisAxis, VisBulletLegend, VisCrosshair, VisTooltip, VisXYContainer, VisXYLabels } from '@unovis/solid'
import { data, formats, DataRecord, getLabels, gradients, legendItems, svgDefs, tooltipTemplate } from './data'
const StackedAreaChart = (): JSX.Element => {
const labels = getLabels(data)
const x = (d: DataRecord) => d.year
const y = formats.map((g) => (d: DataRecord) => d[g])
const color = (_: DataRecord[], i: number) => gradients[i]
const labelProps = {
x: (d: DataRecord) => (labels[d.year] ? d.year : undefined),
y: (d: DataRecord) => labels[d.year]?.value,
label: (d: DataRecord) => labels[d.year]?.label,
backgroundColor: (d: DataRecord) => labels[d.year]?.color ?? 'none',
}
return (
<>
<VisBulletLegend items={legendItems} />
<VisXYContainer data={data} height='50dvh' svgDefs={svgDefs}>
<VisArea x={x} y={y} color={color} />
<VisAxis
type='x'
label='Year'
numTicks={10}
gridLine={false}
domainLine={false}
/>
<VisAxis type='y' label='Revenue (USD, billions)' numTicks={10} />
<VisXYLabels
{...labelProps}
clusterBackgroundColor='none'
clusterLabel={() => ''}
/>
<VisCrosshair x={x} yStacked={y} template={tooltipTemplate} />
<VisTooltip />
</VisXYContainer>
</>
)
}
export default StackedAreaChart
import { Area, Axis, BulletLegend, Crosshair, Tooltip, XYContainer, XYLabels } from '@unovis/ts'
import { data, formats, DataRecord, getLabels, gradients, legendItems, svgDefs, tooltipTemplate } from './data'
const labels = getLabels(data)
const container = document.getElementById('vis-container')
const legendContainer = document.createElement('div')
const chartContainer = document.createElement('div')
container.append(legendContainer, chartContainer)
const legend = new BulletLegend(legendContainer, { items: legendItems })
const chart = new XYContainer(chartContainer, {
height: 500,
svgDefs,
components: [
// area chart
new Area<DataRecord>({
x: (d: DataRecord) => d.year,
y: formats.map(f => (d: DataRecord) => d[f]),
color: (_: DataRecord[], i: number) => gradients[i],
}),
// labels
new XYLabels({
x: (d: DataRecord) => labels[d.year] ? d.year : undefined,
y: (d: DataRecord) => labels[d.year]?.value,
label: (d: DataRecord) => labels[d.year]?.label ?? '',
backgroundColor: (d: DataRecord) => labels[d.year]?.color,
clusterBackgroundColor: 'none',
clusterLabel: () => '',
}),
],
crosshair: new Crosshair({
x: (d: DataRecord) => d.year,
yStacked: formats.map(f => (d: DataRecord) => d[f]),
template: tooltipTemplate,
}),
tooltip: new Tooltip(),
xAxis: new Axis({ label: 'Year', numTicks: 10, domainLine: false, gridLine: false }),
yAxis: new Axis({ label: 'Revenue (USD, billions)', numTicks: 10 }),
}, data)
enum Format {
Vinyl = 'vinyl',
Cassette = 'cassette',
Cd = 'cd',
Download = 'download',
Streaming = 'streaming',
}
export const formats: Format[] = [Format.Vinyl, Format.Cassette, Format.Cd, Format.Download, Format.Streaming]
export type DataRecord = Record<Format, number> & { year: number };
export type Label = {
label: string;
value: number;
color: string;
}
export const formatNames: Record<Format, string> = {
[Format.Vinyl]: 'Vinyl',
[Format.Cassette]: 'Cassette',
[Format.Cd]: 'CD',
[Format.Download]: 'Download',
[Format.Streaming]: 'Streaming',
}
export const colors = formats.map((_, i) => `var(--vis-color${i})`)
export const legendItems = formats.map((f, i) => ({ name: formatNames[f], color: colors[i] }))
// One gradient per format, added to the chart through the container's `svgDefs`
export const svgDefs = formats.map((f, i) => `
<linearGradient id="gradient-${f}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" style="stop-color: ${colors[i]}; stop-opacity: 0.9"/>
<stop offset="1" style="stop-color: ${colors[i]}; stop-opacity: 0.6"/>
</linearGradient>
`).join('')
export const gradients = formats.map(f => `url(#gradient-${f})`)
export function tooltipTemplate (d: DataRecord | undefined): string {
if (!d) return ''
const currency = Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 1 }).format
const rows = [...formats].reverse()
.filter(f => d[f] >= 0.05)
.map(f => `<span style="display: flex; align-items: center; gap: 10px">
<span style="width: 9px; height: 9px; border-radius: 50%; background: ${colors[formats.indexOf(f)]}"></span>${formatNames[f]}
</span><span style="text-align: right">${currency(d[f])}B</span>`)
return `<div style="display: grid; grid-template-columns: auto auto; column-gap: 24px; row-gap: 6px; font-size: 12px; line-height: 1.2">
<span style="grid-column: 1 / -1; font-size: 13px; margin-bottom: 2px">${d.year}</span>${rows.join('')}
</div>`
}
export function getMaxItems<T extends Record<string, number>> (
array: T[],
keys: (keyof T)[]
): { [key in keyof T]?: T } {
const maxIndex = (k: keyof T): number => array.reduce((max, curr, i) => curr[k] > array[max][k] ? i : max, 0)
const entries = keys.map(key => [
key,
array[maxIndex(key)],
])
return Object.fromEntries(entries)
}
export function getLabels (data: DataRecord[]): Record<number, Label> {
// map formats to their maximum data records
const peakItems = getMaxItems(data.slice(0, data.length - 3), formats)
// place labels at [x,y] where x = peak year and y = area midpoint
return formats.reduce((obj, format, i) => {
const offset = Array(i).fill(0).reduce((sum, _, j) => sum + peakItems[format][formats[j]], 0)
const [x, y] = [peakItems[format].year, offset + peakItems[format][format] / 2]
obj[x] = {
label: formatNames[format],
value: y,
color: 'none',
}
return obj
}, {})
}
export const data: DataRecord[] = [
{
year: 1973,
vinyl: 1.436,
cd: 0,
streaming: 0,
cassette: 0.5806,
download: 0,
},
{
year: 1974,
vinyl: 1.55,
cd: 0,
streaming: 0,
cassette: 0.6497,
download: 0,
},
{
year: 1975,
vinyl: 1.6965,
cd: 0,
streaming: 0,
cassette: 0.692,
download: 0,
},
{
year: 1976,
vinyl: 1.9081,
cd: 0,
streaming: 0,
cassette: 0.829,
download: 0,
},
{
year: 1977,
vinyl: 2.4402,
cd: 0,
streaming: 0,
cassette: 1.0606,
download: 0,
},
{
year: 1978,
vinyl: 2.7336,
cd: 0,
streaming: 0,
cassette: 1.3978,
download: 0,
},
{
year: 1979,
vinyl: 2.4106,
cd: 0,
streaming: 0,
cassette: 1.2649,
download: 0,
},
{
year: 1980,
vinyl: 2.45,
cd: 0,
streaming: 0,
cassette: 1.232,
download: 0,
},
{
year: 1981,
vinyl: 2.5981,
cd: 0,
streaming: 0,
cassette: 1.3758,
download: 0,
},
{
year: 1982,
vinyl: 2.2081,
cd: 0,
streaming: 0,
cassette: 1.4205,
download: 0,
},
{
year: 1983,
vinyl: 1.9583,
cd: 0.0172,
streaming: 0,
cassette: 1.8109,
download: 0,
},
{
year: 1984,
vinyl: 1.8475,
cd: 0.1033,
streaming: 0,
cassette: 2.3839,
download: 0,
},
{
year: 1985,
vinyl: 1.5615,
cd: 0.3895,
streaming: 0,
cassette: 2.4115,
download: 0,
},
{
year: 1986,
vinyl: 1.2111,
cd: 0.9301,
streaming: 0,
cassette: 2.4995,
download: 0,
},
{
year: 1987,
vinyl: 0.9964,
cd: 1.5936,
streaming: 0,
cassette: 2.974,
download: 0,
},
{
year: 1988,
vinyl: 0.7126,
cd: 2.0997,
streaming: 0,
cassette: 3.4424,
download: 0,
},
{
year: 1989,
vinyl: 0.3367,
cd: 2.7024,
streaming: 0,
cassette: 3.5404,
download: 0,
},
{
year: 1990,
vinyl: 0.1809,
cd: 3.6299,
streaming: 0,
cassette: 3.7303,
download: 0,
},
{
year: 1991,
vinyl: 0.0933,
cd: 4.4909,
streaming: 0,
cassette: 3.25,
download: 0,
},
{
year: 1992,
vinyl: 0.0799,
cd: 5.529,
streaming: 0,
cassette: 3.4151,
download: 0,
},
{
year: 1993,
vinyl: 0.0618,
cd: 6.7705,
streaming: 0,
cassette: 3.2143,
download: 0,
},
{
year: 1994,
vinyl: 0.065,
cd: 8.7517,
streaming: 0,
cassette: 3.2513,
download: 0,
},
{
year: 1995,
vinyl: 0.0718,
cd: 9.7086,
streaming: 0,
cassette: 2.5399,
download: 0,
},
{
year: 1996,
vinyl: 0.0843,
cd: 10.3549,
streaming: 0,
cassette: 2.0946,
download: 0,
},
{
year: 1997,
vinyl: 0.0689,
cd: 10.5117,
streaming: 0,
cassette: 1.6562,
download: 0,
},
{
year: 1998,
vinyl: 0.0597,
cd: 12.1372,
streaming: 0,
cassette: 1.5143,
download: 0,
},
{
year: 1999,
vinyl: 0.0597,
cd: 13.4154,
streaming: 0,
cassette: 1.1096,
download: 0,
},
{
year: 2000,
vinyl: 0.054,
cd: 13.6391,
streaming: 0,
cassette: 0.6306,
download: 0,
},
{
year: 2001,
vinyl: 0.0588,
cd: 13.324,
streaming: 0,
cassette: 0.3581,
download: 0,
},
{
year: 2002,
vinyl: 0.0454,
cd: 12.3606,
streaming: 0,
cassette: 0.2082,
download: 0,
},
{
year: 2003,
vinyl: 0.0432,
cd: 11.7031,
streaming: 0,
cassette: 0.1081,
download: 0,
},
{
year: 2004,
vinyl: 0.0392,
cd: 12.0918,
streaming: 0.0069,
cassette: 0.0237,
download: 0.1835,
},
{
year: 2005,
vinyl: 0.0274,
cd: 11.1545,
streaming: 0.1709,
cassette: 0.0131,
download: 0.9243,
},
{
year: 2006,
vinyl: 0.0256,
cd: 9.8393,
streaming: 0.2407,
cassette: 0.0037,
download: 1.65,
},
{
year: 2007,
vinyl: 0.0269,
cd: 7.9558,
streaming: 0.272,
cassette: 0.003,
download: 2.3924,
},
{
year: 2008,
vinyl: 0.0596,
cd: 5.7064,
streaming: 0.323,
cassette: 0.0009,
download: 2.6859,
},
{
year: 2009,
vinyl: 0.0663,
cd: 4.5355,
streaming: 0.3629,
cassette: 0.0,
download: 2.66,
},
{
year: 2010,
vinyl: 0.0912,
cd: 3.5725,
streaming: 0.4631,
cassette: 0.0,
download: 2.6934,
},
{
year: 2011,
vinyl: 0.124,
cd: 3.257,
streaming: 0.6554,
cassette: 0.0,
download: 2.9018,
},
{
year: 2012,
vinyl: 0.1655,
cd: 2.607,
streaming: 1.0362,
cassette: 0,
download: 3.0162,
},
{
year: 2013,
vinyl: 0.2137,
cd: 2.2501,
streaming: 1.4608,
cassette: 0,
download: 2.9203,
},
{
year: 2014,
vinyl: 0.2493,
cd: 1.8725,
streaming: 1.8352,
cassette: 0,
download: 2.5531,
},
{
year: 2015,
vinyl: 0.3391,
cd: 1.5231,
streaming: 2.3421,
cassette: 0,
download: 2.3107,
},
{
year: 2016,
vinyl: 0.3603,
cd: 1.192,
streaming: 4.0019,
cassette: 0,
download: 1.8294,
},
{
year: 2017,
vinyl: 0.3946,
cd: 1.1009,
streaming: 5.7167,
cassette: 0,
download: 1.3853,
},
{
year: 2018,
vinyl: 0.4245,
cd: 0.7303,
streaming: 7.4368,
cassette: 0,
download: 1.0173,
},
{
year: 2019,
vinyl: 0.5044,
cd: 0.6439,
streaming: 8.9132,
cassette: 0,
download: 0.8326,
},
]
Bundle size
The same chart in every library: a React line chart with smooth lines, a legend, a tooltip, a crosshair and animation, minified and gzipped. Unovis takes 52.4 KB, the smallest of the 9 libraries we measured.