@pyreon/charts is Pyreon's own chart engine. You write <Chart> with your rows and put marks inside it as children. Channels are field names, typed against the row, so a typo is a compile error when the chart and its marks know the row type (<Chart<Row>>, and const RowBar = Bar<Row> declared once for the marks). Axes, palette, tooltip, an accessible data table and a spoken description come without configuration.
It has zero runtime dependencies: geometry is computed in pure TypeScript into a flat draw list, which a small canvas host paints in the browser, a pure string builder serializes on the server, and the Swift and Kotlin runtimes draw natively (the geometry is generated into them). Every mark and chart family is an imported binding, so an unused family (pie, radar, candlestick, heatmap, …) tree-shakes out of your bundle like any unused function. The family marks follow the same rule: <Arc>, <Stage>, <Cell> and<Candle> each carry their own renderer, so a <Chart> with only cartesian marks never includes the pie, funnel, heatmap or candlestick code. A CI import budget locks it.
The package has three entries:
| Entry | For |
|---|---|
@pyreon/charts | <Chart>, its marks, the family components, formatters, theme and linking. The stable surface. |
@pyreon/charts/svg | chartToSvg and every *ToSvg: charts as SVG strings with no DOM, for SSR and export. Imported on a server, it also makes every <Chart> and every family chart (<GaugeChart>, <TreemapChart>, <SankeyChart>, …) ship its first frame as SVG in the SSR / SSG HTML. |
@pyreon/charts/engine | Every layout, hit test and draw-list builder, and the array form <PlotChart marks>. Not covered by the stability promise. |
Quick start
// @check
import { Axis, Bar, Legend, Line, Chart, Tooltip, currency } from '@pyreon/charts'
type Row = { month: string; revenue: number; target: number }
const rows: Row[] = [
{ month: 'Jan', revenue: 3200, target: 3000 },
{ month: 'Feb', revenue: 4100, target: 3400 },
{ month: 'Mar', revenue: 3800, target: 3800 },
]
export const Revenue = () => (
<Chart<Row> data={rows} x="month" title="Monthly revenue vs target" showTitle>
<Bar y="revenue" label="Revenue" />
<Line y="target" label="Target" />
<Axis y format={currency('$')} />
<Tooltip />
<Legend />
</Chart>
)That is the whole grammar: channels are field names (y="revenue" is typed against Row; an accessor y={(d) => d.revenue} works too), marks are children (they draw in order, so an <Area> under a <Line> under <Dot>is three lines of JSX), and the tooltip, legend, axes, zoom and reference lines are declared as data beside the marks they apply to. A <Show>around a mark adds and removes its series like any other Pyreon child.
| Child | Declares |
|---|---|
<Bar y stack? group? /> <Line y /> <Area y /> <Dot y r? /> | The marks. stack / group combine bars; r turns dots into area-mapped bubbles. |
<Rule y label? /> <Rule from to /> <Rule x /> | A reference line (horizontal, or vertical at a continuous x) or band. |
<Label text at? series? /> | A datum-anchored label: at the series' max / min, or at an index. |
<Axis y format domain ticks /> <Axis x time hidden ticks /> <Axis y2 … /> | Axis formatting and domains. Tick density follows the chart's size — about one value tick per 40px of height, one x tick per 80px of width — and the domain is rounded to the same step, so the axis always ends on a label. ticks={n} pins the target. |
<Tooltip crosshair? format? /> | The pointer tooltip. |
<Legend toggle? maxRows? direct? /> | The legend (click toggles series). direct labels each line at its last point instead, in the series colour, nudging labels apart when they collide. |
<Zoom inside? navigator? presets? link? brush? window? lock? /> | Pinch/wheel zoom and drag pan, the slider strip, preset buttons, cross-chart linking, the range brush. window={{ start, end }} (fractions) is the opening view; lock pins its span. |
<Toolbox saveAsImage? restore? magicType? dataZoom? dataView? brush? /> | The tool strip: save as SVG or PNG, restore, switch line and bar or stacked and tiled, a box-select zoom, a data table view, the area brushes. |
The interaction children carry their own code: a chart without <Zoom> does not bundle the navigator, the presets or the range brush, and one without<Toolbox> does not bundle the tool strip or the SVG serializer, and one without an indicator mark does not bundle the indicator arithmetic. CI import budgets lock the size of a <Chart> with one <Line> and of a pie through<Arc>; see Benchmarks for current measurements.
<Chart by="id"> gives rows an identity for update animation, like <For by>, with D3's data join: a surviving row slides from its old slot to its new one, a new row grows in from the zero line in its slot, and a removed row shrinks out in its old slot. That holds for bars, lines, stacked and grouped bars, in a vertical or horizontal frame, on a log scale and on either axis of a dual-axis chart, and every frame of the morph is drawn with the same geometry and styling the finished chart uses. Areas, bands, waterfalls, scatter and numeric-x charts tween each row from its own previous value, as does an update that changes a series' kind or the orientation. Without by rows are matched by position, so a sliding window (drop the oldest, append the newest) animates every bar toward its neighbour's value. On iOS and Android by lowers to the spec's rowKeys: the engine tags each bar with its row key and the edge it grows from, and the native canvas matches those commands by key, so bars (plain, stacked, grouped, horizontal) slide, grow in and shrink out exactly as on the web, and a line whose points are its rows moves its points by key.
<Chart color="region"> switches to long format: every y mark becomes one series per distinct region, categories come from x, a missing (category, series) pair is a gap, and bars group side by side unless stack.
The family marks make the same grammar cover the row-array families — one family per plot, and <Chart> renders that family's host instead of the cartesian plot:
| Family mark | Renders | Channels |
|---|---|---|
<Arc value label color? innerRadius? /> | A pie (innerRadius={0}) or donut. | one slice per row |
<Stage value label color? sort? gap? /> | A funnel, descending by default. | one stage per row |
<Cell x y value colors? gap? visualMap? /> | A heatmap; duplicate (x, y) cells sum. visualMap adds a continuous legend over the data's extent in the theme ramp; visualMap({ domain }) pins it. | one observation per row |
<Candle open high low close upColor? downColor? /> | A candlestick; the plot's x labels each period. | one period per row |
// @check
import { Arc, Legend, Chart, Tooltip } from '@pyreon/charts'
type Share = { browser: string; pct: number }
const share: Share[] = [
{ browser: 'Chrome', pct: 65 },
{ browser: 'Safari', pct: 19 },
{ browser: 'Firefox', pct: 8 },
]
export const BrowserShare = () => (
<Chart<Share> data={share} title="Browser share" showTitle>
<Arc value="pct" label="browser" innerRadius={0.6} />
<Tooltip />
<Legend />
</Chart>
)<Tooltip>, <Legend> and <Axis y format> apply to a family host too, and<Zoom window lock> opens a <Candle> chart's navigator on that window. Any other cartesian mark or <Zoom> prop beside a family mark is reported and ignored.
The grammar and the array form below are ONE spec: <Chart> resolves its children into the marks={[bars(…)]} props <PlotChart> takes (resolveGrammaris exported and tested to produce identical series), and on native the compiler desugars <Chart> to that element before lowering — the two emit byte-identical Swift and Kotlin.
The array form
// @check
import { signal } from '@pyreon/reactivity'
import { currency } from '@pyreon/charts'
import { PlotChart, bars, line } from '@pyreon/charts/engine'
type Row = { month: string; revenue: number; target: number }
const rows = signal<Row[]>([
{ month: 'Jan', revenue: 3200, target: 3000 },
{ month: 'Feb', revenue: 4100, target: 3400 },
{ month: 'Mar', revenue: 3800, target: 3800 },
])
export const Revenue = () => (
<PlotChart
data={() => rows()}
x={(d: Row) => d.month}
marks={[
bars((d: Row) => d.revenue, { label: 'Revenue', color: '#6366f1' }),
line((d: Row) => d.target, { label: 'Target', color: '#f59e0b' }),
]}
format={currency('$')}
title="Monthly revenue vs target"
showLegend
tooltip
/>
)data accepts a plain array (static) or an accessor (reactive) — signal reads inside the accessor are tracked, so a rows.set(...) repaints the chart with no other wiring.
That demo is the real engine, mounted live: marks compose in paint order (area under line under points), data is an accessor so Regenerate repaints without re-mounting anything, and one format feeds the axis, the tooltip and the accessible description together.
Marks
A mark is a child of <Chart>, and marks draw in the order they are written. Each takes its value channel as a field name (y="revenue", typed against the row) or an accessor.
| Mark | Draws |
|---|---|
<Bar y /> | Vertical bars from the zero line. |
<Bar y stack /> / <Bar y group /> | One stack segment per mark, or side-by-side bars per category; <Scale normalize> draws a stack as shares (the 100% stack). |
<Bar y waterfall /> | Floating steps from running total to running total (the bridge chart); negativeColor fills the falls, dashed connectors carry the level across. |
<Line y /> | A polyline through the values; dash={[4, 2]} draws it dashed (a target or a forecast). |
<Area y /> | A filled area under the line. |
<StackedArea y /> | Shares over time: one filled area per mark, each between the running total below it and its own top, so the topmost outline is the total. |
<Dot y /> / <Dot y r /> | Discrete points; an r channel makes area-mapped bubbles. |
<Band low high /> | A filled REGION between two channels — a confidence interval or a min/max range. Its floor is the data rather than the axis an <Area> closes to. showValues labels the HIGH edge; the tooltip and the accessible table carry both. |
<Histogram x bins /> | Bins the x channel (nice-step edges) and draws one bar per bin. It replaces the rows with bins, so it is the whole chart. |
<Rule y /> / <Label text at /> | A reference line or band, and a datum-anchored label. |
The technical indicators are marks too, derived from their y series rather than read off each datum:
| Mark | Draws |
|---|---|
<Sma y window /> | A simple moving average over window points. The first window - 1 points are gaps, not zeros. |
<Ema y window /> | An exponential moving average. |
<Trend y /> | The least-squares line through the series. |
<Bollinger y window k /> | A filled envelope k standard deviations wide (2 by default) plus its middle line: two marks, a band and a line. |
<Chart data={candles} x="day">
<Line y="close" label="Close" />
<Sma y="close" window={20} label="SMA 20" />
<Bollinger y="close" window={20} />
</Chart>Under a color pivot each series gets its own indicator over its own column. They lower to iOS and Android as long as window and k are numeric literals; the array-form factories (sma, ema, trend, bollinger in@pyreon/charts/engine) are the same marks.
That second demo covers the shapes the table above describes and the first demo does not draw: an interval, a rolling envelope, shares over time, a running total, and a raw sample binned.
<Bar>, <Line>, <Area> and <Dot> also take errorLow / errorHighchannels: a capped whisker from the low bound to the high one through each datum, both bounds joining the domain so a whisker never leaves the axis.
Every mark takes label (legend/tooltip/a11y name), color, width (stroke),radius (points) and showValues (a value label per datum — on every kind, not only bars: a line, area or point labels the placed point, a bar labels its rect, and a <Band> labels its HIGH edge). curve takes an imported interpolator rather than a string, so an unused curve tree-shakes:
// @check
import { Area, Chart, smooth, step } from '@pyreon/charts'
const readings = [3, 7, 4, 9, 6]
export const Curves = () => (
<Chart<number> data={readings}>
<Area<number> y={(d) => d} curve={smooth} label="Smoothed" />
<Area<number> y={(d) => d} curve={step} label="Stepped" />
</Chart>
)Axes
Categorical x — pass
x={(d) => d.label}; points are spaced evenly.Continuous x — pass
xValue={(d) => d.timestamp}; spacing follows the values, which is a correctness feature: readings on Jan 1, Jan 2 and Mar 1 drawn evenly spaced would claim the first gap equals the second.Time axis — add
xTimewhenxValuereturns epoch milliseconds to get calendar tick labels; override withxFormat.date(pattern)builds that formatter in UTC, identically on the web, iOS and Android —<Axis x time format={date('MMM YYYY')} />, tokensYYYYYYMMMMMMMMMMDDDHHHmmss,[text]printed as written.Horizontal bars —
horizontalflips the frame (categories on Y, bars growing rightward). Bar-family marks only; the left gutter sizes itself from the widest category label, which is the reason horizontal bars exist.Pinned domains —
yDomain/y2Domain(or<Axis y domain>/<Axis y2 domain>); derived from the data when absent.Gaps — a
null,undefined,NaNor infinite accessor result is a gap, not a zero: the bar is skipped, the line breaks, no dot is drawn, the value stays out of the domain, the tooltip row and the table cell are empty. A measurement nobody took must not read as0;d.v ?? 0says otherwise where that is the truth.Log y scale —
yScale="log"(or<Scale y="log">/<Axis y scale="log">) draws every left-axis mark in the log view: decades on the axis (with the 2× and 5× minors under two decades), non-positive values as gaps, bars growing from the axis floor. Tooltip, table and value labels keep the real values — the view is geometry, not a data edit. A right axis stays linear.Time y axis —
yTime(or<Scale y="time">), the twin ofxTime.Axis titles —
xTitle/yTitle/y2Title(or<Axis x title>), each in a line of its own outside the tick labels, the y titles running along their axes.Labels that do not fit —
xLabels:auto(default) slants overflowing category labels 45° and thins numeric ones to every k-th;rotateandthinforce one;alldraws every label upright and lets them collide. The horizontal frame thins its category rows the same way.Locale —
locale="de-DE"formats numbers (and, under a time axis, the dates) throughIntlon every surface:1.234,5,12. Mär.. An explicitformat/xFormatwins;registerLocalerefines what Intl produces.
One format formatter applies to the y axis, the tooltip and the accessible description — an axis that says $3.2K beside a tooltip that says 3204.55reads as a bug. plain, fixed, percent, currency and compact ship in the same subpath; any (v: number) => string works.
Scales, shares and small multiples
// @check
import { Axis, Bar, Histogram, Chart, Scale } from '@pyreon/charts'
const sales = [
{ month: 'Jan', region: 'eu', units: 120, lo: 100, hi: 140 },
{ month: 'Jan', region: 'us', units: 80, lo: 70, hi: 95 },
{ month: 'Feb', region: 'eu', units: 150, lo: 130, hi: 170 },
{ month: 'Feb', region: 'us', units: 90, lo: 75, hi: 100 },
]
export const Shares = () => (
<Chart data={sales} x="month" color="region">
<Scale normalize />
<Axis y title="Share of units" />
<Bar y="units" stack />
</Chart>
)
export const Facets = () => (
<Chart data={sales} x="month" facet="region" facetColumns={2}>
<Bar y="units" errorLow="lo" errorHigh="hi" />
<Axis x title="Month" labels="rotate" />
</Chart>
)
export const Ages = () => (
<Chart data={sales}>
<Histogram x="units" bins={5} label="Months" />
</Chart>
)<Scale y="log" | "time" x="time" normalize>states the scales together;<Axis>carries the same switches per axis, plustitleand (on x)labels.<Bar waterfall negativeColor>is the waterfall;<Bar y errorLow errorHigh>(and<Line>,<Area>,<Dot>) draw error bars from two channels.<Histogram x bins label color format>bins thexchannel and draws one bar per bin; it replaces the rows the way the long-format pivot does, so it is the whole plot.facet="region"renders small multiples: one titled panel per distinct value in afacetColumns-wide grid, every panel sharing the y domain (a facet on its own scale cannot be compared to its neighbour). The marks, channels and switches apply to every panel; a new value adds a panel, and a value that persists keeps its panel across data changes.
Pies, funnels, heatmaps and candlesticks
These are marks in the same <Chart>: <Arc> is a pie or donut, <Stage> a funnel, <Cell> a heatmap and <Candle> a candlestick. One family mark per chart; <Tooltip>, <Legend> and <Axis y format> apply to it as they do to a line.
// @check
import { Arc, Candle, Cell, Chart, GaugeChart, Legend, Stage, Tooltip } from '@pyreon/charts'
const share = [
{ browser: 'Chrome', pct: 65 },
{ browser: 'Safari', pct: 19 },
{ browser: 'Firefox', pct: 8 },
]
const steps = [
{ step: 'Visit', users: 1200 },
{ step: 'Sign up', users: 420 },
{ step: 'Buy', users: 96 },
]
const ohlc = [
{ day: 'Mon', o: 102, h: 108, l: 99, c: 106 },
{ day: 'Tue', o: 106, h: 111, l: 104, c: 105 },
]
const commits = [
{ dow: 'Mon', hour: '09', n: 4 },
{ dow: 'Mon', hour: '10', n: 7 },
{ dow: 'Tue', hour: '09', n: 2 },
]
export const Families = () => (
<>
<Chart data={share} title="Browser share" showTitle height={240}>
<Arc value="pct" label="browser" innerRadius={0.6} />
<Legend />
</Chart>
<Chart data={steps} height={220}>
<Stage value="users" label="step" />
<Tooltip />
</Chart>
<Chart data={ohlc} x="day" height={240}>
<Candle open="o" high="h" low="l" close="c" />
</Chart>
<Chart data={commits} height={200}>
<Cell x="hour" y="dow" value="n" />
<Tooltip />
</Chart>
<GaugeChart value={0.72} max={1} showValue title="Capacity" />
</>
)innerRadius={0} is a pie; anything up to 1 is a donut. Duplicate (x, y)heatmap observations sum, so raw event rows go straight in without pre-aggregating. A gauge is one value against a range, not a row per datum, so it is its own component, like the relation and hierarchy charts below.
Annotations and animation
annotations draws reference rules and bands (the target line, the healthy range) between the grid and the series. animate (on by default) plays an entrance — bars rise, lines draw left-to-right — and turns itself off underprefers-reduced-motion. A data update of the same shape tweens from the previous frame to the new one (updateAnimation, on by default, overtheme.updateMs or updateDuration); a shape change — a row added, a series removed — snaps, because there is no path between two different shapes that means anything. <PlotChart> tweens its VALUES; every family host tweens its DRAW LIST (two frames of the same shape interpolate command by command), so a treemap cell glides to its new rect and a slice sweeps to its new angle without the host knowing what a value is.
Animation lives in the engine as a parameter, not in the hosts as an effect:renderChart at progress: 0.4 is a pure function returning the 40%-grown frame, which makes every frame testable and means a native executor animates by tweening one number.
Accessibility
A canvas is a single opaque node to a screen reader, so every component renders an offscreen data table by default (accessibleTable={false} to opt out) and derives an aria-label from the actual data via describeChart. Pass title to name the chart — without it the description falls back to a bare "Chart".
Natively the same sentence is the canvas's accessibility label: a11y.tscrosses with the engine, so VoiceOver and TalkBack read describeChart over the series the canvas painted. The precedence matches the web — an explicitaccessibilityLabel, else the data description, else title, else the family word — so a chart is never an unnamed rectangle on any target.
The table's DATA crosses too, in each platform's own form, from the same input the description reads:
iOS: the canvas carries an
AXChartDescriptor(.accessibilityChartDescriptor), so VoiceOver offers the Audio Graph and its per-point data explorer.Android: one TalkBack node per visible category sits over its column (its row, for a horizontal chart), labelled with the table's row — "art, Score 91". A swipe walks the data in order and explore-by-touch finds the bar under the finger. The nodes take no pointer input, so taps still reach the chart. A zoomed chart labels its visible rows from the full data; a decimated or continuous-x chart, whose columns are not evenly spaced, keeps the description alone.
Server-side SVG
chartToSvg builds the same chart as a pure SVG string — no DOM, no canvas — so it runs in SSR, SSG, an API route, or an email pipeline. A server-rendered<Chart> (or family chart — pie, treemap, sankey, …) always ships its accessible data table in the HTML; import@pyreon/charts/svg in the server entry (import '@pyreon/charts/svg') and it also ships the chart's first frame as SVG, so the page shows the chart before any script runs. Hydration adopts that SVG and the first canvas paint replaces it. It is a server import on purpose: the serializer is about 2 KB gzipped that no browser bundle needs. A chart with no width draws its server frame at 600px and scales it to the container.
The same serializer, called directly:
// @check
import { currency } from '@pyreon/charts'
import { chartToSvg } from '@pyreon/charts/svg'
import { bars } from '@pyreon/charts/engine'
const svg = chartToSvg({
data: [3200, 4100, 3800],
marks: [bars((d: number) => d, { label: 'Revenue' })],
format: currency('$'),
title: 'Monthly revenue',
})Text measurement defaults to a server-safe approximation; in a browser passmeasure: canvasMeasure(ctx, font) when label widths must be exact. Given atitle and no description, a long description is derived from the data— a chart whose only accessible text is its title tells a screen-reader user that a graphic exists and nothing about what it shows.
Big data
lttb (largest-triangle-three-buckets) and minMaxBuckets from@pyreon/charts/engine decimate large series before charting — downsampling that preserves the visual shape (peaks and troughs) rather than averaging them away.
Hierarchy and relations
Every hierarchy family reads one TreeNode shape — { name, value?, children?, color? } — so the same data drives a treemap, a sunburst and a node-link tree:
import { TreemapChart, SunburstChart, TreeChart } from '@pyreon/charts'
import type { TreeNode } from '@pyreon/charts'
const repo: TreeNode[] = [
{ name: 'src', children: [{ name: 'core', value: 50 }, { name: 'ui', value: 20 }] },
{ name: 'docs', value: 30 },
]
<TreemapChart data={repo} height={260} onSelect={(cell) => cell && console.log(cell.path)} />
<SunburstChart data={repo} innerRatio={0.25} height={320} />
<TreeChart data={repo} tree={{ orient: 'LR' }} height={260} />Relations use node/link lists: <SankeyChart nodes links> lays flows out by longest path with relaxed bands and stacked ribbons, and <GraphChart nodes links graph={{ layout: 'force' }}> runs a seeded, deterministic force layout (or circular / none for given coordinates). Every family ships layoutX / renderX / hitX as pure functions and an xToSvg for the server, plus <BoxplotChart> (five-number summaries from raw samples via fiveNumber).
Coordinates
Beyond the cartesian grid the engine ships further coordinate systems as families of their own: <CalendarChart start end values> (the contribution-graph grid, strict ISO dates), <ParallelChart axes rows> (parallel coordinates with category or value axes and highlighted rows), <PolarChart axes series> (radial or concentric bars and polar lines), <RiverChart series> (a silhouette streamgraph), a single axis (layoutSingleAxis), and <MapChart map values> over GeoJSON registered with registerMap, passed inline, or projected first with geoShapes(json) into a GeoShape[] (the form that crosses to iOS and Android) — regions filled by value through the same colour ramp the heatmap uses, with renderGeoPoints / renderGeoPaths for scatter and flight paths on top.
Themes
One token map draws every chart. ChartTheme is palette (series colours in draw order), background, surface (tooltip and pager cards), text, label(ticks and legend entries), axis, grid, the three semantic tokenspositive / negative / muted, the value ramp, fontFamily, fontSize,titleSize, radius (the bar corner marks fall back to) and the two animation lengths enterMs / updateMs. Every host, family, legend, title, tooltip and accessible description reads from it, so a chart with no props already looks right on both grounds:
The mode is the app's. A chart draws
chartThemes.lightorchartThemes.darkfor the framework-wide colour mode in scope — the one<PyreonUI mode>sets, or<ColorModeProvider mode>from@pyreon/coreoutside the UI system. With neither, it follows the page's declaredcolor-scheme, elseprefers-color-scheme, live. There is nothing to wire: a chart below<PyreonUI mode="dark">is dark.<ChartThemeProvider>styles every chart below it:theme={{ … }}merges token overrides, andlight={{ … }}/dark={{ … }}override only in that mode (a brand whose ground or palette differs by mode). The mode is applied where each chart sits, so a mode set below a provider still picks its override.The
themeprop on any host merges over whatever is in scope.
The last four tokens exist because a colour that is not a series colour still has to change with the ground. positive and negative are semantic — an up or down candle, today's rule on a gantt, a highlighted parallel line — and are never palette entries. muted fills a cell or region with no data; its job is to recede into background, so it cannot be one value for both modes.ramp is the low-to-high value ramp behind <Cell>, <CalendarChart>and <MapChart>, and it must gain contrast against its own background as the value rises — a ramp that runs light-to-dark reads correctly on a white page and backwards on a dark one, where the highest value becomes the faintest mark. A per-family option (emptyColor, upColor, stops, …) still wins over the token.
// @check
import { ColorModeProvider } from '@pyreon/core'
import { Bar, Chart, ChartThemeProvider, palettes } from '@pyreon/charts'
interface Row { q: string; v: number }
const rows: Row[] = [{ q: 'Q1', v: 3 }, { q: 'Q2', v: 5 }]
// In an app on the UI system, <PyreonUI mode> already provides the mode.
export const Themed = () => (
<ColorModeProvider mode="system">
<ChartThemeProvider theme={{ palette: palettes.okabeIto, radius: 4 }} dark={{ background: '#0b1020' }}>
<Chart<Row> data={rows} x="q">
<Bar y="v" />
</Chart>
</ChartThemeProvider>
</ColorModeProvider>
)The default palette is chosen by measurement, not taste: every colour clears 3:1 against the background (WCAG 1.4.11, light and dark), the first four series stay distinguishable under deuteranopia and protanopia, and a series keeps its hue when the mode flips. A test asserts all three.
palettes exports the named sets as data — pyreon (the default),pyreonDark, echarts6, echarts5, echartsDark, observable10,tableau10, okabeIto (colour-vision safe) and tailwind — so a palette is a reference, not a hex list you maintain. A mark with its own color keeps it; the rest cycle through theme.palette.
On native the theme is a struct: theme={chartThemes.dark} andtheme={{ palette: palettes.okabeIto }} resolve at compile time, a literal merges over the defaults, and a literal <ColorModeProvider mode> or<PyreonUI mode> plus <ChartThemeProvider theme light dark> are compile-time scopes the chart children inherit — mode, then each provider'stheme, then its light or dark, then the chart's own theme, the web's layers in the web's order. With no mode pinned, a bare chart follows the platform's scheme at runtime. A reactive mode (an app's own signal) cannot be read at compile time: the charts below follow the platform scheme, and the compiler says so by name.
Every host, one surface
All the family components — <GaugeChart>, <RadarChart>, <BoxplotChart>,<TreemapChart>, <SunburstChart>, <TreeChart>, <SankeyChart>,<GraphChart>, <RiverChart>, <PolarChart>, <GanttChart>,<CalendarChart>, <ParallelChart>, <MapChart>, <ChordChart>, <SingleAxisChart>, and the four hosts behind<Arc>, <Stage>, <Candle> and <Cell> — share one canvas host, so they share one prop vocabulary:
| Prop | Does |
|---|---|
title / subtitle / showTitle | Names the chart for assistive tech and the hidden table; showTitle draws the block above the chart. |
showLegend / legendPosition | A legend for families with named entries (series, slices, stages, top-level nodes) — top by default, or bottom, left, right. |
tooltip | A tooltip with the family's own lines (a slice's share, a candle's OHLC, a cell's value, a node's name), following the pointer or the last touch. Off by default — a static report has no pointer. |
animate / updateAnimation / updateDuration | The entrance tween on first paint (theme.enterMs) and the update tween on a same-shape data change (theme.updateMs); both off under prefers-reduced-motion. |
keyboard | On by default: the canvas is focusable, Arrow / Home / End walk the items the accessible table lists, each is announced in a live region and ringed where the family can place a ring, Enter / Space select through the family's callbacks, Escape clears. |
toolbox / onSaveImage | { saveAsImage: true } draws a download button; the family hosts save the canvas as a PNG (the vector form is the family's *ToSvg), <PlotChart> saves an SVG or, with 'png', the canvas. |
onSelect | On a family component, the family's rich hit (a cell, an arc, a node, a Sankey node-or-link) or null, web only. On <Chart> it is always the drawn item's INDEX, on every target. |
onSelectIndex | On a family component, the engine's INDEX hit — what the native tap gesture reports, so a handler written once works on all three targets. <Chart> has no onSelectIndex: its onSelect already is one. |
theme / width / height / class / accessibleTable | As on <PlotChart>. The canvas is aria-describedby its table; the table stops at 1,000 rows and says so in its caption. |
The same vocabulary crosses: on iOS and Android every host — <PlotChart>and <Chart> included — draws the title block, the legend and the tooltip natively (the tooltip on a tap, cleared by a tap on nothing; the plot's lines come from the crossing tooltipAt / tooltipLines over the sliced series, a named tooltipFormatter lowers, and crosshair — a hover concept — stays web-only and says so). What the legend lists and what a tap says come from one crossing module — treemapLegend, sankeyTip, pieTip, … andrenderTooltip in chrome.ts — that the web host calls too, so the three targets cannot disagree about either. WHERE the legend goes is crossing too:legendPosition (top / bottom / left / right) runs throughplaceLegend in legend.ts, the one implementation all three targets call, so a side legend narrows and indents the plot on a phone exactly as it does in a browser. (Before that split the emit drew every legend at the top, at x = 0 across the full width, while the web host inset it by 8 on each side — a divergence nothing reported.) The four hosts whose engines draw their own frame — Gauge, Candlestick, Heatmap, Boxplot — do not read the prop and say so. <BoxplotChart> crosses (itsfiveNumber reduction runs in the generated engine) and <RadarChart>'s tap reports the engine's { series, axis } hit on every target. <MapChart>crosses from a precomputed GeoShape[] — raw GeoJSON's geometry is aPolygon | MultiPolygon union whose coordinates sit at two different array depths, which the native struct lowering refuses to merge. Project once withgeoShapes(json) on the web or in a build step and pass the const; the registry name, the raw FeatureCollection and geoShapes() itself stay web and decline by name. A host with no theme and no provider follows the phone's colour scheme at runtime, as it followsprefers-color-scheme in a browser. animate crosses too: every host whose engine takes a progress (all of the above except Pie, Radar, Candlestick and Gauge, which draw fully formed everywhere) plays the same cubic ease-out entrance over theme.enterMs inside PyreonChartEntrance, off under Reduce Motion (iOS) and a zero animator scale (Android) — the web host'sprefers-reduced-motion — and animate={false} emits the host exactly as before. On an engine with no entrance the prop is named as inert rather than as a native gap. The theme prop follows too: a named theme or a literal colours the title, legend and tooltip box, seeds the options palette (an explicit palette in the options wins) and paints theme.backgroundbehind the chart on every host, as the web canvas host does. A theme that is not a literal (theme={someVariable}) cannot be read at compile time; it is reported by name and the default applies.
Bars are rounded by default: theme.radius (3) rounds the corners away from the baseline — top for a positive bar, bottom for a negative one, the far end when horizontal — so a bar still reads as growing from zero. A mark's ownborderRadius wins; theme={{ radius: 0 }} restores square bars; stacked and grouped segments keep only their mark radii.
Big data
A dense line is decimated before it is stroked: M4 keeps the first, lowest, highest and last point of each column. By default there are two columns per CSS pixel, finer than a 1× display has pixels, so a noisy line keeps more of its detail. density="device" keeps one column per DEVICE pixel instead, as uPlot does: about half the points on a 1× display and a faster mount, at the cost of a coarser noisy line. Measured against drawing every point (real Chromium, a noisy 20k-point line), the default's error is 2.2M and'device''s 3.6M. density is web-only.
maxPoints caps what one paint draws: past it the visible slice is thinned with LTTB on the first mark — rows stay aligned across marks, so a line and its area never disagree — and a 100k-point series paints as a 1k-point one. Hits, tooltips and selection report the GLOBAL index of the row actually drawn. The engine's throughput is measured, not asserted: bun run --filter=@pyreon/charts bench:engine (layout + render, 1k–100k points, treemap, sankey, LTTB; single machine, author-run — treat magnitudes as the signal).
A mark also takes a COLUMN instead of an accessor: hand it the array the app already holds — a Float64Array or a number[] — and value i belongs to rowi. Resolving a column is an indexed read per point rather than a function call per row, and when every mark is a column <PlotChart> needs no data at all: the chart has one row per index of the longest column. A non-finite value, or an index past a column's end, is a gap.
const prices = new Float64Array(1_000_000) // filled by the app
<PlotChart marks={[line(prices)]} maxPoints={1000} />Row callbacks (x, by, tooltips) still run, receiving undefined and the index. A plain number[] of exactly the chart's length is used AS the series — no copy — when every entry is a number or NaN; the chart never writes into it, so don't mutate it in place either (pass a new array to update). A typed array, a shorter array, or one holding an infinity is copied once. SeeBenchmarks for current measurements against uPlot, Chart.js and Recharts.
Columns are a web path for now: the native lowering reads marks as inline arrows and warns on a column, so shared multiplatform source should keep the accessor form.
Locales
registerLocale(tag, pack) registers a locale pack over Intl; a chart'slocale prop picks it up for its number and date formatting. numberFormatterand dateFormatter are exported from @pyreon/charts/engine for hosts that build specs by hand.
Interaction
Each interaction is opt-in, and so is its code: a static chart in a report pays for none of it.
<Tooltip crosshair? />— the hover tooltip, and the crosshair line.<Zoom />— wheel-zoom and drag-pan (ECharts' inside dataZoom). Its props add the rest:navigator(a strip under the plot with the first series over ALL rows and the window as a draggable band with resize handles),presets={[{ label: '1m', count: 30 }, { label: 'All', count: 0 }]}(the Highcharts range selector),brush={(range) => …}(shift-drag a band) andlink.<Toolbox saveAsImage? restore? magicType? dataZoom? dataView? brush? />— the tool strip.keyboardon<Chart>— focus the canvas and walk the data with Left / Right / Home / End; the focused datum gets a dashed ring and is announced through a live region (the whole row: every series' value). Up / Down step through the series, so a reader can follow one line across the chart —Revenue: 3,200 at Mar, 3 of 12— and step past the last back to the whole row. Enter / Space fireonSelect, Escape clears. The spoken summary states how much each series moved (rising 45%,rising 3.2×), not only which way.updateAnimation(default on) — a data change of the same shape tweens to the new frame instead of snapping;updateDurationsets the length;prefers-reduced-motiondisables it.Touch — every handler is a pointer handler: a finger drags, pans and brushes as a mouse does, two fingers pinch-zoom the window around their midpoint, and a tap shows the tooltip. A zoomable chart sets
touch-action: none; a static one leaves the page free to scroll over it.<Legend position />—top(default),bottom,leftorright; a side legend narrows the plot.<Axis y domain />— pin the y domain.<Zoom link={createChartLink()} />— pass the same link to several charts and their zoom window and crosshair datum stay in sync (EChartsconnect).
const link = createChartLink()
<Chart data={price} x="t" keyboard>
<Line y="close" />
<Tooltip crosshair />
<Zoom navigator link={link} presets={[{ label: '1m', count: 30 }, { label: 'All', count: 0 }]} />
</Chart>
<Chart data={price} x="t">
<Bar y="volume" />
<Tooltip crosshair />
<Zoom link={link} />
</Chart>Rounded bars
borderRadius on a bar-family mark (ECharts' itemStyle.borderRadius): a number rounds all four corners, [topLeft, topRight, bottomRight, bottomLeft] rounds them individually — [6, 6, 0, 0] is the column look that keeps the bar flat on the axis.
<Chart data={rows} x="k">
<Bar y="v" borderRadius={[6, 6, 0, 0]} />
</Chart>The radius travels in the draw list as corners on the rect command, clamped by the engine to half the bar's shorter side — so a bar animating up from zero rounds proportionally, and the web canvas, the SSR SVG and the SwiftUI/Compose canvases all draw the same four arcs.
Gradient fills
gradient on a bar-family or area mark (ECharts' LinearGradient item and area style). You give the stops; the engine resolves the two points against the plot box, so one ramp spans the chart instead of repeating inside every bar.
<Chart data={rows} x="k">
<Area y="v" gradient={{ stops: [{ offset: 0, color: '#2563eb' }, { offset: 1, color: 'rgba(37,99,235,0)' }] }} />
</Chart>direction: 'horizontal' ramps left → right instead of top → bottom. Every gradient-bearing command still carries its solid color, so a backend that cannot paint one — or an SVG serialized command-by-command without a <defs> — falls back to the colour rather than to nothing.
Events and actions
ECharts' on(...) / dispatchAction in Pyreon shapes: every event is a prop, every action is a call on a handle whose signals ARE the chart's state.
selectedMode="single" | "multiple"— a click (or Enter on the keyboard focus) pins the datum; pinned datums keep a heavy outline and report throughonSelectChangewith GLOBAL indices.onSelectstill fires for the pick.onHighlight— the hovered datum's GLOBAL index, -1 on leave (EChartsmouseover/mouseout), whoever moved it: pointer, keyboard, link or dispatch. With it (orselectedMode/handle) the hovered column draws a faint band and its bars/points an outline —emphasis={false}keeps the crosshair alone.onLegendChange— the hidden series (mark indices) after a legend click (legendselectchanged);onZoom— the window after any zoom gesture (datazoom).handle={createChartHandle()}—dispatch({ type })withhighlight/downplay/select/unselect/toggleSelect/legendSelect/legendUnselect/legendToggle/dataZoom/restore; readhandle.selected(),handle.hidden(),handle.zoom(),handle.hover()reactively. A handle is a link too: pass it aslinkto siblings to connect them.
const chart = createChartHandle()
<Chart data={rows} x="k" handle={chart} selectedMode="multiple" onSelectChange={(s) => console.log(s)}>
<Bar y="v" />
</Chart>
<button onClick={() => chart.dispatch({ type: 'select', index: 2 })}>Pin March</button>
<button onClick={() => chart.dispatch({ type: 'dataZoom', start: 0.25, end: 0.75 })}>Middle half</button>
<button onClick={() => chart.dispatch({ type: 'restore' })}>Reset</button>On native the events/actions props warn by name and the chart renders without them (onSelect taps cross today; the handle is the next tier).
Gantt
<GanttChart tasks> lays one row per task on a calendar-aligned time axis — the tick unit (day / week / month / quarter / year) is picked from the span — with lane headers per group, darker progress insets, milestone diamonds, dependency elbows and a dashed today marker. layoutGantt / renderGantt / hitGanttIndex (from @pyreon/charts/engine) and ganttToSvg (from @pyreon/charts/svg) are the pure halves.
<GanttChart
tasks={[
{ id: 'design', name: 'Design', start: '2024-03-01', end: '2024-03-10', progress: 0.5, group: 'Phase 1' },
{ id: 'build', name: 'Build', start: '2024-03-08', end: '2024-03-24', dependencies: ['design'], group: 'Phase 1' },
{ id: 'launch', name: 'Launch', start: '2024-03-25', milestone: true, dependencies: ['build'], group: 'Phase 2' },
]}
gantt={{ today: '2024-03-16' }}
height={240}
/>Sonification
sonifyValues(values, { duration, minHz, maxHz, link }) (from @pyreon/charts/engine) plays a series as pitch over time: one oscillator steps through the values, gaps are silence, onStep(index) fires per datum, and a ChartLink moves every linked chart's crosshair with the sound. Start it from a click — browsers keep audio suspended until a user gesture.
const sound = sonifyValues(closes, { duration: 3000, link })
<button onClick={() => void sound.play()}>Play</button>Native
The engine's geometry is generated into Swift and Kotlin twins(PyreonChartEngine.swift / .kt in the native runtimes) from the same TypeScript sources, drift-locked byte-for-byte in CI and compile-proven with the real toolchains. The flat draw-list design is what makes this possible: a native canvas executes the same commands the web canvas does. Every chart component lowers to a native PyreonChartCanvas over that draw list: <Chart>with its marks, and every family component (<MapChart> from a precomputedGeoShape[] — the map registry, raw GeoJSON and geoShapes() stay web). Theme tokens resolve at compile time. What stays on the web is named at compile time (see below): <Histogram>'s row reshape, locale, facet, density, column inputs, and the event / handle props (onSelect taps do cross). See themultiplatform capability matrix for device status.
How the engine is verified
Engine geometry is pure and runs in the node suite (scales, layout, marks, stacks, arcs, formatting, a11y, decimation — ~98% there).
Every canvas host runs in real Chromium: its own family suite (geometry, click, reactive repaint) plus one shared sweep that drives all 20 family hosts through the same paths — the accessible surface, tooltip hit / miss / leave, click and keyboard selection through both callbacks, PNG export. Browser coverage over the host files is a gate with measured floors; the node run excludes exactly those files, and the two lists are kept in sync so a host is never measured nowhere.
Draw-list goldens — one SVG per family for a fixed dataset, committed and compared byte-for-byte. The SVG is the draw list the canvases paint, and it is deterministic across platforms where a pixel baseline is not.
The shipped compiler: the app-showcase e2e hovers, clicks and keyboards the plot-engine chart on a real page under
@pyreon/vite-plugin— the hosts' own suites run under vitest's JSX transform, and template-path bugs live in the difference.Native: the generated Swift/Kotlin engines are drift-locked and compiled by the real toolchains per PR; device assertions ride the tasks showcase. The scale switches cross with the engine:
yScale,yTime,stackNormalize, the axis titles andxLabelslower as literals on both targets (<Scale>and<Axis title labels scale time>desugar to them), the waterfall mark lowers, and the slanted labels paint through the native canvases' own rotation. Error bars cross too:errorLow/errorHighmap over the same rows the values do (the bubble radius channel's shape) into the engine'serrLow/errHigh. What stays on the web is named at compile time rather than dropped:<Histogram>(the row reshape;binValuesitself crosses),locale(Intl) andfacet(a DOM panel grid).
Migrating from 0.51
In 0.51, @pyreon/charts was a wrapper around the ECharts library:<Chart options>, useChart, and the /manual, /vite and /webviewentries. 0.52 replaces it with this engine and removes the wrapper, along with the echarts peer dependency and the tslib Vite alias it needed. An ECharts option has no mechanical translation to marks, so there is no codemod;pyreon check lists every import still on the wrapper.
Each series becomes a mark, each component a child:
| 0.51 (ECharts option) | 0.52 |
|---|---|
xAxis: { type: 'category', data } + series: [{ data }] | rows: <Chart data={rows} x="month">, each series a field of the row |
{ type: 'bar' }, stack: 'total' | <Bar y="revenue" />, <Bar y="revenue" stack /> |
{ type: 'line' }, smooth: true, areaStyle: {} | <Line y>, <Line y curve={smooth}>, <Area y> |
{ type: 'scatter' } | <Dot y>, with r for bubbles |
{ type: 'pie', radius: ['40%', '70%'] } | <Arc value label innerRadius={0.57}> |
{ type: 'funnel' }, 'heatmap', 'candlestick' | <Stage>, <Cell>, <Candle> |
'gauge', 'radar', 'sankey', 'treemap', 'graph', … | the family components: <GaugeChart>, <RadarChart>, <SankeyChart>, <TreemapChart>, <GraphChart>, … |
tooltip, legend, dataZoom, toolbox | <Tooltip />, <Legend />, <Zoom />, <Toolbox /> |
yAxis.axisLabel.formatter | <Axis y format={currency('EUR')} /> — one formatter feeds the axis, tooltip and table |
markLine / markPoint | <Rule> / <Label> |
visualMap | visualMap({ domain }) on <Cell>, <CalendarChart>, <MapChart> |
useChart(() => option) + chart.instance() | <Chart> reads signals directly; createChartHandle() for imperative actions |
<ChartWebView> on iOS / Android | <Chart> itself: it draws natively |
chartsViteAlias() in vite.config.ts | delete it |