pyreon

@pyreon/charts — API Reference

Generated from charts's src/manifest.ts — the same source that powers llms.txt and MCP get_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see charts.

Two independent charting engines behind two subpaths. @pyreon/charts/plot is Pyreon's OWN: pure-TypeScript geometry over a flat draw list, marks as imported bindings so tree-shaking is structural, a canvas backend, and a PURE SVG backend that renders on a server. @pyreon/charts is the ECharts bridge: zero ECharts bytes in your bundle until a chart actually renders — chart types and components are auto-detected from your options and dynamically imported on demand. Signal-driven options reactively update the chart when tracked signals change. useChart is the low-level hook with full control; <Chart /> is the declarative component with event binding. Both auto-resize via ResizeObserver and clean up on unmount.

Features

  • useChart<TOption>(optionsFn, config?) — low-level reactive hook with full lifecycle control

  • Chart component with declarative options, event binding, and auto-resize

  • onEvents map for ANY ECharts event (legendselectchanged, datazoom, brushselected, …), leak-safe binding

  • showLoading — reactive toggle of the ECharts loading overlay

  • Zero-byte lazy loading — chart types auto-detected and dynamically imported

  • Generic TOption for strict typed options via ComposeOption<SeriesUnion>

  • @pyreon/charts/manual entry for explicit tree-shaking control

  • All ECharts option and series types re-exported for single-import convenience

Complete example

A full, end-to-end usage of the package:

import { Chart, useChart, type EChartsOption, type ComposeOption, type BarSeriesOption, type LineSeriesOption } from '@pyreon/charts'
import { signal } from '@pyreon/reactivity'

const months = signal(['Jan', 'Feb', 'Mar', 'Apr'])
const revenue = signal([100, 200, 150, 300])

// Declarative component — simplest usage
<Chart
  options={() => ({
    xAxis: { type: 'category', data: months() },
    yAxis: { type: 'value' },
    series: [{ type: 'bar', data: revenue() }],
    tooltip: { trigger: 'axis' },
  })}
  style="height: 400px"
  onClick={(params) => console.log('clicked:', params.name)}
/>

// useChart hook — full control over instance lifecycle
const MyChart = () => {
  const chart = useChart(() => ({
    xAxis: { type: 'category', data: months() },
    yAxis: { type: 'value' },
    series: [
      { type: 'bar', data: revenue() },
      { type: 'line', data: revenue().map((v) => v * 1.1) },
    ],
  }))

  return (
    <div>
      {chart.loading() ? 'Loading chart...' : null}
      <div ref={chart.ref} style="height: 400px" />
      <button onClick={() => chart.resize()}>Resize</button>
    </div>
  )
}

// Strict typed options — only bar + line allowed
type MyOption = ComposeOption<BarSeriesOption | LineSeriesOption>
const typedChart = useChart<MyOption>(() => ({
  series: [{ type: 'bar', data: [1, 2, 3] }],  // only 'bar' | 'line' autocomplete
}))

// Manual entry for tree-shaking control:
// import { useChart, Chart } from '@pyreon/charts/manual'
// — you register ECharts components yourself

Exports

SymbolKindSummary
useCharthookCreate a reactive ECharts instance.
ChartcomponentDeclarative chart component that wraps useChart internally.
PlotcomponentThe grammar — <Plot data x> with MARK CHILDREN (Plot, because the package's default entry already exports the EChart
PlotChartcomponentPyreon's OWN charting engine, from the @pyreon/charts/plot subpath — no ECharts, no third-party engine.
ChartThemeProvidercomponentProvides ONE theme to every /plot chart below it.
BoxplotChartcomponentA boxplot per category from RAW SAMPLES: values={(d) => d.samples} is reduced with fiveNumber (min, q1, median, q3,
smafunctionIndicator MARKS over a value accessor, for the finance and telemetry charts that draw a signal beside its smoothing: `sm
chartToSvgfunctionRender a chart to a standalone <svg> STRING.
PieChartcomponentPie and donut from the same engine (@pyreon/charts/plot); innerRadius is what makes it a donut.
CandlestickChartcomponentCandlestick chart from the plot engine (@pyreon/charts/plot) — open/high/low/close accessors per datum, direction enco
HeatmapChartcomponentHeatmap from the plot engine (@pyreon/charts/plot): two categorical axes, a value per cell, color as the third channel
RadarChartcomponentRadar (spider) chart from the plot engine (@pyreon/charts/plot) — one polygon per datum over shared spokes.
TreemapChartcomponentThe hierarchy families of Pyreon's own engine share ONE data shape: TreeNode { name, value?, children?, color? }.
MapChartcomponentGeoJSON regions filled by value.
optionToSvgfunctionThe ECharts option-compat facade: an ECharts-SHAPED option in, this engine out — cartesian series (line/bar/scatter/effe
OptionChartcomponentThe ECharts-option-driven host: an ECharts-shaped option in (a value or an accessor), a live chart out.
GanttChartcomponentThe Gantt family — one row per task on a calendar-aligned time axis.
createChartHandlefunctionThe imperative handle (ECharts dispatchAction) for ONE <PlotChart handle>: a link (zoom, hover) plus selected
createChartLinkfunctionLinked charts (ECharts connect): a shared { zoom, hover } pair of signals that every <PlotChart link> in a group u
sonifyValuesfunctionA series as sound: each value maps linearly to a pitch between minHz and maxHz (valueToHz — a FINITE value outside

API

useChart hook

<TOption extends EChartsOption = EChartsOption>(optionsFn: () => TOption, config?: UseChartConfig) => UseChartResult

Create a reactive ECharts instance. Options are passed as a function — signal reads inside are tracked and the chart updates automatically when any tracked signal changes. Lazy-loads the required ECharts modules on first render (zero bytes until mount). Returns ref (bind to a container div), instance (Signal<ECharts | null>), loading (Signal<boolean>), error (Signal<Error | null>), and resize(). Auto-resizes via ResizeObserver (autoresize: false | { throttle } to opt out/throttle) and disposes on unmount. theme accepts an accessor for reactive swaps; initOptions passes through to core.init; warm mounts (modules cached) are synchronous. getCore()/connect() are exported for registerMap/registerTheme/linked charts.

Example

const chart = useChart(() => ({
  xAxis: { type: 'category', data: months() },
  yAxis: { type: 'value' },
  series: [{ type: 'bar', data: revenue() }],
}))

<div ref={chart.ref} style="height: 400px" />
// chart.loading() — true until ECharts modules loaded + chart initialized
// chart.instance() — raw ECharts instance for imperative API

Common mistakes

  • Forgetting to set a height on the container div — ECharts requires explicit dimensions, it does not auto-size to content

  • Passing options as a plain object instead of a function — signal reads are not tracked and the chart never updates

  • Reading chart.instance() immediately after useChart — the instance is null until the async module load completes; check chart.loading() first

  • Calling chart.resize() during SSR — useChart is browser-only; the hook no-ops safely on the server but resize is meaningless

See also: Chart


Chart component

(props: ChartProps) => VNodeChild

Declarative chart component that wraps useChart internally. Accepts options (reactive function), style/class for the container, and event handlers. onEvents binds ANY ECharts event by name (legendselectchanged, datazoom, finished, …), with onClick/onMouseover/onMouseout as shorthands — binding is leak-safe (handler changes swap listeners, all removed on unmount). showLoading reactively toggles the ECharts loading overlay. Renders a div with the chart — auto-resizes and cleans up on unmount. Simpler than useChart for most use cases.

Example

<Chart
  options={() => ({
    legend: {},
    series: [{ type: 'pie', data: [{ value: 60, name: 'A' }, { value: 40, name: 'B' }] }],
  })}
  style="height: 300px"
  showLoading={isFetching()}
  onEvents={{
    legendselectchanged: (p) => console.log('toggled', p.name),
    datazoom: (_p, instance) => syncOtherChart(instance.getOption()),
  }}
/>

Common mistakes

  • Missing style height on the Chart component — same as useChart, ECharts requires explicit container dimensions

  • Passing a static options object — wrap in () => ({...}) so signal reads inside are tracked reactively

  • Using onClick/onMouseover/onMouseout for a non-mouse event — those are only shorthands; reach for the general onEvents map (e.g. onEvents={{ legendselectchanged: fn }}) for any other ECharts event

  • Passing theme as a plain VALUE and expecting runtime swaps — a value is applied once at init; pass an ACCESSOR (theme: () => (dark() ? 'dark' : null)) and a flip disposes + re-inits with the option, group, and events preserved

  • Relying on the default merge when data shrinks — a signal change that removes a series/point leaves the old one; pass notMerge or replaceMerge="series"

See also: useChart


Plot component

<T>(props: PlotProps<T>) => VNode

The grammar — <Plot data x> with MARK CHILDREN (Plot, because the package's default entry already exports the ECharts bridge as <Chart>). Channels are FIELD NAMES typed against the row (y="revenue") or accessors; marks are JSX children (<Bar y stack? group? waterfall?>, <Line y>, <Area y>, <Dot y r?>r makes area-mapped bubbles; every cartesian mark takes errorLow / errorHigh channels for error bars) and draw in order; <Rule y | from to>, <Axis x|y|y2 format domain time hidden title labels scale>, <Scale y="log"|"time" x="time" normalize> (the log view, calendar labels, the 100% stack), <Histogram x bins> (bins the rows and draws one bar per bin — the whole plot, like the pivot), <Tip crosshair format>, <Legend toggle maxRows position>, <Zoom inside navigator presets link brush> and <Label text at series> (a datum-anchored point marker) declare annotations, axes, scales, the tooltip, the legend, every zoom surface and markers as data beside the marks. facet="region" renders small multiples — one titled panel per value in a facetColumns grid, every panel sharing the y domain; locale="de-DE" formats every number surface through Intl. The FAMILY marks cover the row-array hosts with the same grammar — <Arc value label color? innerRadius?> (pie / donut), <Stage value label color? sort? gap?> (funnel), <Cell x y value colors? gap?> (heatmap), <Candle open high low close upColor? downColor?> (candlestick, the plot's x labels the period) — one family per plot, and <Plot> renders that host instead of the cartesian plot (<Tip> / <Legend> / <Axis y format> still apply; a cartesian mark or <Zoom> beside one is reported and ignored). A <Show> around a mark adds/removes its series, and a <For each> (or a plain .map()) generates one per item — its render callback is resolved here, inside the resolving computed, so an accessor each tracks. A child that is not a mark renders nothing and says so in dev. color="region" switches to LONG format: one series per distinct value, categories from x, gaps where a (category, series) pair is absent, bars grouped unless stack. Marks are branded components <Plot> scans structurally (never invoked); it resolves them into the marks={[bars(…)]} props <PlotChart> takes, so the array form is the same spec — resolveGrammar is exported for that equivalence. Native: the compiler desugars <Plot> to <PlotChart marks> (byte-identical emit); the runtime color pivot warns by name and renders wide-format.

Example

import { Axis, Bar, Legend, Line, Plot, Tip, currency } from '@pyreon/charts/plot'

interface Row { month: string; revenue: number; target: number }
const rows: Row[] = [{ month: 'Jan', revenue: 3200, target: 3000 }, { month: 'Feb', revenue: 4100, target: 3400 }]

<Plot<Row> data={rows} x="month" title="Revenue vs target" showTitle>
  <Bar y="revenue" label="Revenue" />
  <Line y="target" label="Target" />
  <Axis y format={currency('
Two charting engines — API Reference
)} /> <Tip /> <Legend /> </Plot>

Common mistakes

See also: PlotChart · ChartThemeProvider


PlotChart component

<T>(props: PlotChartProps<T>) => VNodeChild

Pyreon's OWN charting engine, from the @pyreon/charts/plot subpath — no ECharts, no third-party engine. Marks are IMPORTED BINDINGS (bars, line, area, points, stackedBars, groupedBars, stackedArea (shares over time — areas filled between running totals), band(low, high) (a REGION between two channels: a confidence interval or min/max range, whose floor is the data rather than the axis an area closes to), waterfall, plus the histogram() spread over the crossing binValues), so tree-shaking is structural rather than a build flag: a bar chart never pulls the radial trigonometry, the decimation or the time scales. Geometry is pure TypeScript over plain data and the platform half is a short backend that walks a flat DrawCmd[], which is why the same source is the path to native rendering. Renders to canvas with a device-pixel-ratio-correct surface; showLegend, tooltip, crosshair and a title are opt-in props, and width falls back to the container's own so a chart in a flexible column fills it. The legend is INTERACTIVE by default: clicking an entry toggles its series, the domain rescales to what is visible, and hidden entries render muted (legendToggle: false opts out). rtl lays the chart out right-to-left — implemented as a MIRROR of the finished draw list about the canvas centreline, so bands run from the right, the value axis moves to the right gutter and the legend's swatch sits right of its label, while every pointer is mirrored back before it is hit tested (a click still reports the category it landed on). Text is repositioned, never reversed. The mirror is a TWO-WAY seam: screen -> chart turns a pointer into chart space before a hit test, and chart -> screen (screenX / screenRectX) turns chart geometry back into DOM space before it reaches an overlay's style.left — the tooltip goes through the second half, and a custom host that positions a DOM overlay from chart geometry must too. saveAsImage serialises the MIRRORED list, so an SVG export is the chart on screen rather than its mirror image. It lowers to native through pyreonMirrorCmds, whose parity with the web mirror is asserted by executing all three implementations; the rtl prop on a FAMILY host (treemap, sankey, …) is not lowered on native yet and warns by name.

Example

import { PlotChart, bars, line } from '@pyreon/charts/plot'
import { signal } from '@pyreon/reactivity'

interface Row { month: string; revenue: number; target: number }
const sales = signal<Row[]>([{ month: 'Jan', revenue: 120, target: 100 }])

<PlotChart
  data={() => sales()}
  x={(d: Row) => d.month}
  marks={[bars((d: Row) => d.revenue), line((d: Row) => d.target)]}
  showLegend
  tooltip
  title="Monthly revenue"
  height={240}
/>

Common mistakes

See also: chartToSvg · PieChart


ChartThemeProvider component

(props: { mode?: ChartThemeMode | (() => ChartThemeMode); theme?: Partial<ChartTheme> | (() => Partial<ChartTheme> | undefined); children? }) => VNodeChild

Provides ONE theme to every /plot chart below it. ChartTheme is a token map — palette (series colours in draw order), background, surface (tooltip / pager cards), text, label (ticks, legend entries), axis, grid, fontFamily, fontSize, titleSize, radius (the bar corner marks fall back to), enterMs / updateMs — and every host, family, legend, title, tooltip and accessible description reads from it. With NO provider a chart follows the system colour scheme (chartThemes.light / chartThemes.dark by prefers-color-scheme, live); mode pins one or tracks the app's (mode={useMode} hands PyreonUI's reactive mode through); theme merges token overrides over the mode's theme; a host's own theme prop merges over all of it. palettes exports the named sets as data (pyreon — the default —, pyreonDark, echarts6, echarts5, echartsDark, observable10, tableau10, okabeIto, tailwind). On native the provider is transparent: theme each chart there (theme={chartThemes.dark} and palette: palettes.okabeIto resolve at compile time).

Example

import { ChartThemeProvider, PlotChart, bars, palettes } from '@pyreon/charts/plot'
import { signal } from '@pyreon/reactivity'

interface Row { q: string; v: number }
const rows: Row[] = [{ q: 'Q1', v: 3 }, { q: 'Q2', v: 5 }]
const mode = signal<'light' | 'dark'>('dark') // or PyreonUI's useMode

<ChartThemeProvider mode={() => mode()} theme={{ palette: palettes.okabeIto, radius: 4 }}>
  <PlotChart data={rows} x={(d: Row) => d.q} marks={[bars((d: Row) => d.v)]} />
</ChartThemeProvider>

Common mistakes

See also: PlotChart · PieChart


BoxplotChart component

<T>(props: BoxplotChartProps<T>) => VNodeChild

A boxplot per category from RAW SAMPLES: values={(d) => d.samples} is reduced with fiveNumber (min, q1, median, q3, max; whiskers at the extremes) and drawn over the engine's box geometry, one colour per box from the theme palette. fiveNumber / renderBoxplot / hitBox / boxplotToSvg are exported for hosts that build their own. Web-only host today (the native lowering is a follow-up).

Example

import { BoxplotChart } from '@pyreon/charts/plot'

interface Group { name: string; samples: number[] }
const groups: Group[] = [{ name: 'eu', samples: [12, 15, 14, 30, 11] }, { name: 'us', samples: [20, 22, 19, 25] }]

<BoxplotChart data={groups} x={(g: Group) => g.name} values={(g: Group) => g.samples} height={220} title="Latency by region" />

Common mistakes

See also: PlotChart


sma function

<T>(y: Accessor<T>, window: number, options?: MarkOptions) => Mark<T>

Indicator MARKS over a value accessor, for the finance and telemetry charts that draw a signal beside its smoothing: sma(y, window) (simple moving average), ema(y, window) (exponential), trend(y) (least-squares line) and bollinger(y, window, k?) (the ±k·σ envelope as a FILLED band plus its middle line, returned as an ARRAY of marks to spread into marks — two marks, not three lines: a band's bounds can be computed from the series via transform/transform2, which is the only shape a rolling window fits). Each is a mark like line, so it layers in the same marks={[…]} array, takes the same label / color / width options, and the leading window - 1 points are gaps rather than zeros. The value forms smaValues / emaValues / stdevValues / trendValues are exported for hosts that need the numbers, and live in a separate crossing module so sma / ema / trend LOWER to iOS and Android (with a numeric-literal window), and bollinger does too — its array spread expands to the band and the middle line it names.

Example

import { PlotChart, line, sma, bollinger } from '@pyreon/charts/plot'

interface Candle { t: number; close: number }
const candles: Candle[] = [{ t: 1704067200000, close: 101 }, { t: 1704153600000, close: 104 }]

// bollinger returns the band's marks as an ARRAY: spread it into marks.
<PlotChart data={candles} xValue={(d: Candle) => d.t} xTime marks={[...bollinger((d: Candle) => d.close, 20), line((d: Candle) => d.close, { label: 'Close' }), sma((d: Candle) => d.close, 20, { label: 'SMA 20' })]} />

Common mistakes

See also: PlotChart · CandlestickChart


chartToSvg function

<T>(options: ChartToSvgOptions<T>) => string

Render a chart to a standalone <svg> STRING. Pure — no DOM, no canvas, no measurement context — so it runs in an SSG build, a serverless function or an email pipeline, where a canvas surface does not exist. Output is deterministic (coordinates rounded to two decimals, negative zero normalised), which makes an SVG snapshot a real assertion about geometry rather than a pixel flake. Labels are XML-escaped. The <svg> is role="img" named by its <title>; given a title and no description, the long form is DERIVED from the data via describeChart. Text width comes from measureApprox by default — an honest estimate, since a server has no font metrics; pass canvasMeasure(ctx, font) in a browser when label widths must be exact. The whole family has the same one-call form — pieToSvg, gaugeToSvg, radarToSvg, candlestickToSvg, heatmapToSvg, funnelToSvg, treemapToSvg, sunburstToSvg, treeToSvg, riverToSvg, polarToSvg, sankeyToSvg, graphToSvg, calendarToSvg, ganttToSvg, parallelToSvg, boxplotToSvg — so every chart type the engine draws renders on a server. Each takes the same theme its canvas host takes, and reads the same fields from it, so the static export matches the chart the browser paints.

Example

import { chartToSvg, bars } from '@pyreon/charts/plot'

interface Row { month: string; revenue: number }
const rows: Row[] = [{ month: 'Jan', revenue: 120 }]

const svg = chartToSvg({
  data: rows,
  marks: [bars((d: Row) => d.revenue)],
  x: (d: Row) => d.month,
  title: 'Monthly revenue',
})
// -> '<svg xmlns="..." role="img" aria-labelledby=...>...</svg>'

Common mistakes

See also: PlotChart


PieChart component

(props: PieChartProps) => VNodeChild

Pie and donut from the same engine (@pyreon/charts/plot); innerRadius is what makes it a donut. GaugeChart is its sibling for a single value against a range. Both carry the same accessibility contract as PlotChart — a role="img" graphic with a derived description, aria-describedby its hidden data table, keyboard-walkable — because both are built on the shared canvas host every family is (canvasHost, exported: layout / render / hit / a11y in, chrome + pointer + keyboard + animation + table out).

Example

import { PieChart, GaugeChart } from '@pyreon/charts/plot'
import { signal } from '@pyreon/reactivity'

interface Slice { name: string; amount: number }
const slices = signal<Slice[]>([{ name: 'Direct', amount: 40 }])
const cpu = signal(42)

<PieChart data={() => slices()} label={(d: Slice) => d.name} value={(d: Slice) => d.amount} innerRadius={0.6} />
<GaugeChart value={() => cpu()} min={0} max={100} title="CPU" />

Common mistakes

See also: PlotChart


CandlestickChart component

<T>(props: CandlestickChartProps<T>) => VNodeChild

Candlestick chart from the plot engine (@pyreon/charts/plot) — open/high/low/close accessors per datum, direction encoded by color (close vs open; up green, down red by default, both overridable). onSelect fires with the candle index (the full COLUMN is the hit target — a wick is one pixel wide) and tooltip shows the hovered period OHLC. A doji (open == close) keeps a 1px body — flat trading is a fact, and a missing candle reads as missing data. The wick draws first so the body sits over it; the price domain is niced so the axis lands on readable ticks. Geometry (renderCandles, ohlcExtent) exported standalone.

Example

import { CandlestickChart } from '@pyreon/charts/plot'

interface Bar { day: string; o: number; h: number; l: number; c: number }
const bars: Bar[] = [{ day: 'Mon', o: 10, h: 20, l: 5, c: 15 }]

<CandlestickChart data={bars} open={(d: Bar) => d.o} high={(d: Bar) => d.h} low={(d: Bar) => d.l} close={(d: Bar) => d.c} x={(d: Bar) => d.day} />

Common mistakes

See also: PlotChart · HeatmapChart


HeatmapChart component

<T>(props: HeatmapChartProps<T>) => VNodeChild

Heatmap from the plot engine (@pyreon/charts/plot): two categorical axes, a value per cell, color as the third channel. Category order is FIRST-SEEN (weekday names and funnel stages carry an order alphabetical sorting destroys); duplicate (x, y) observations SUM; absent cells are NOT drawn — absence and zero are different facts. The ramp is plain #rrggbb stops interpolated by hand-rolled math, so the same code lowers to native. The row gutter sizes itself from the widest row label, the same rule horizontal bars use. onSelect fires with the tapped CELL (its categories and aggregated value; null for a miss) and tooltip shows row · column: value — both speak in cells because duplicate observations SUM into one cell, so the cell is the unit on screen.

Example

import { HeatmapChart } from '@pyreon/charts/plot'

interface Ev { day: string; hour: string; count: number }
const events: Ev[] = [{ day: 'Mon', hour: '09', count: 12 }]

<HeatmapChart data={events} x={(d: Ev) => d.day} y={(d: Ev) => d.hour} value={(d: Ev) => d.count} />

Common mistakes

See also: PlotChart · PieChart


RadarChart component

<T>(props: RadarChartProps<T>) => VNodeChild

Radar (spider) chart from the plot engine (@pyreon/charts/plot) — one polygon per datum over shared spokes. Each axis normalises by its OWN max, so axes in different units (revenue beside a score out of 5) are comparable on one chart; a shared scale would flatten every small-range axis to the centre. Fewer than three axes draws nothing (no area to enclose). The fill is translucent (fillAlpha, default 0.25) with a full-strength outline, so overlapping polygons stay readable. Geometry (renderRadar, radarPolygon, radarAngles) exported standalone.

Example

import { RadarChart } from '@pyreon/charts/plot'

interface Player { name: string; speed: number; power: number; skill: number }
const players: Player[] = [{ name: 'Ana', speed: 90, power: 40, skill: 80 }]

<RadarChart
  data={players}
  axes={[{ label: 'Speed', max: 100 }, { label: 'Power', max: 100 }, { label: 'Skill', max: 100 }]}
  values={(d: Player) => [d.speed, d.power, d.skill]}
  label={(d: Player) => d.name}
  showLegend
/>

Common mistakes

See also: PlotChart · PieChart


TreemapChart component

(props: TreemapChartProps) => VNode

The hierarchy families of Pyreon's own engine share ONE data shape: TreeNode { name, value?, children?, color? }. <TreemapChart> (squarified), <SunburstChart> (radial partition) and <TreeChart> (tidy node-link, five orientations) all take the same data, so a drill-down can switch views without reshaping. Each is a reactive canvas host over a pure layoutX / renderX / hitX trio and ships an xToSvg for the server; cells and arcs carry a child-index path as a stable selection identity. Siblings in the same wave: <FunnelChart>, <BoxplotChart> (fiveNumber from raw samples), <SankeyChart>, <GraphChart> (a SEEDED force layout — same input, same picture) and <ChordChart>, which takes sankey's { nodes, links } verbatim but closes the layout into a circle — so it drops the axis and with it the acyclicity a sankey needs to read well, which is why a flow that goes BOTH ways (imports and exports, migration between regions, a confusion matrix) belongs on a chord.

Example

import { TreemapChart, SunburstChart } from '@pyreon/charts/plot'
import type { TreeNode } from '@pyreon/charts/plot'

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} />

Common mistakes

See also: PlotChart · optionToSvg


MapChart component

(props: MapChartProps) => VNode

GeoJSON regions filled by value. map takes three shapes: a name registered once with registerMap(name, geojson) (ECharts' shape), a FeatureCollection directly, or already-projected GeoShape[] (what geoShapes(json) returns) — the third is the one that LOWERS TO NATIVE (a Polygon | MultiPolygon union puts one field at two array depths, which the native struct lowering refuses to merge; the two web-only shapes warn by name at compile time, and geoShapes itself reads GeoJSON so shared source passes a PRECOMPUTED const). layoutGeoShapes fits the rings into the box with aspect preserved and north up; renderGeo colours through the SAME ramp the heatmap uses so a visualMap strip cannot disagree with the map; hitGeoIndex is ring-accurate. renderGeoPoints / renderGeoPaths draw scatter, effectScatter halos and flight paths on top through layout.project. The other coordinate families follow the same pattern: <CalendarChart> (contribution grid, strict ISO dates), <ParallelChart>, <PolarChart> (radial or concentric bars, polar lines), <RiverChart> (silhouette streamgraph) and layoutSingleAxis.

Example

import { MapChart, geoShapes, registerMap } from '@pyreon/charts/plot'
import type { GeoJson, GeoShape } from '@pyreon/charts/plot'

declare const euGeoJson: GeoJson
registerMap('eu', euGeoJson)
// Web: the registry name, or the FeatureCollection, reads fine.
<MapChart map="eu" values={{ DE: 83, FR: 68, PL: 38 }} options={{ showLabels: true }} height={360} onSelect={(r) => r && console.log(r.name)} />

// Shared source (web + iOS + Android): only a PRECOMPUTED GeoShape[] crosses.
// geoShapes() reads GeoJSON, so project on the web or in a build step, not here.
const euShapes: GeoShape[] = geoShapes(euGeoJson)
<MapChart map={euShapes} values={{ DE: 83, FR: 68, PL: 38 }} height={360} onSelectIndex={(i) => console.log(i)} />

Common mistakes

See also: TreemapChart · HeatmapChart


optionToSvg function

(option: EChartsOption, opts?: OptionToSvgOptions) => string

The ECharts option-compat facade: an ECharts-SHAPED option in, this engine out — cartesian series (line/bar/scatter/effectScatter/pictorialBar/lines/custom with renderItem), every family (pie, gauge, radar, candlestick, heatmap, funnel, boxplot, treemap, sunburst, tree, sankey, graph, chord, themeRiver, map), coordinateSystem: 'polar' | 'geo' | 'singleAxis' | 'calendar', dataset with filter/sort transforms, graphic, visualMap, markPoint / markLine, title, legend, tooltip. compileOption returns the spec plus warnings — anything unmapped is NAMED (option-key-unsupported, series-option-unsupported, series-type-unsupported, series-data-shape, …), never dropped silently, and a gallery-shaped conformance corpus ratchets the clean pass-rate upward in CI. { theme, locale } apply registered themes (registerTheme; light/dark built in) and Intl-backed locale packs (registerLocale).

Example

import { optionToSvg, compileOption } from '@pyreon/charts/plot'
import type { EChartsOption } from '@pyreon/charts/plot'

declare const echartsOption: EChartsOption
const svg = optionToSvg(
  { xAxis: { data: ['Mon', 'Tue'] }, yAxis: {}, series: [{ type: 'bar', data: [120, 200] }] },
  { width: 640, height: 320, theme: 'dark', locale: 'de' },
)
const { spec, warnings } = compileOption(echartsOption)
if (warnings.length > 0) console.warn(warnings.map((w) => w.code + ' @ ' + w.path))

Common mistakes

See also: PlotChart · chartToSvg · MapChart


OptionChart component

(props: OptionChartProps) => VNode

The ECharts-option-driven host: an ECharts-shaped option in (a value or an accessor), a live chart out. Cartesian plans — single grid or multi-grid — paint on a canvas through the SAME compiledCommands that optionToSvg serialises, so the host and the server never disagree on a pixel; family and geo plans render through the facade into an inline <svg>. A timeline steps on autoPlay (one interval, owned by the effect and cleared on every option change and on unmount) or is driven by timelineIndex; onSelect hit-tests clicks against the painted geometry (bars by rect, other series by nearest x) and reports { seriesIndex, dataIndex, name, value }; theme / locale reach the compilers; the hidden table lists every series by category. It forwards every prop it shares with the shared canvas host — width, title, tooltip, keyboard, toolbox, onSaveImage, accessibleTable, class and rtl — through a passthrough map the type system requires to be TOTAL, so a new host prop is a compile error until it is forwarded or explicitly omitted. Interaction that needs the row model — tooltip, dataZoom, brush, navigator, keyboard — lives on <PlotChart>, whose marks API is the engine's native shape.

Example

import { OptionChart } from '@pyreon/charts/plot'
import type { EChartsOption } from '@pyreon/charts/plot'
import { signal } from '@pyreon/reactivity'

const option = signal<EChartsOption>({ xAxis: { data: ['Mon', 'Tue'] }, yAxis: {}, series: [{ type: 'bar', data: [120, 200] }] })
<OptionChart option={() => option()} width={640} height={320} theme="dark" onSelect={(hit) => hit && console.log(hit.name, hit.value)} />

Common mistakes

See also: optionToSvg · PlotChart · compileOption


GanttChart component

(props: GanttChartProps) => VNode

The Gantt family — one row per task on a calendar-aligned time axis. layoutGantt sizes the label column by the widest name (capped by labelFraction), picks the tick UNIT from the span (day / week / month / quarter / year, ticks aligned to UTC calendar boundaries), lays a lane header wherever group changes, places bars on the padded (or explicit domain) time range, milestones as diamonds at their instant, dependency ELBOWS from a predecessor's end to a successor's start, and a today marker. renderGantt draws lane bands, grid, ticks, bars with darker progress insets, elbows and the dashed today line, with an entrance progress that grows the bars; hitGantt prefers the bar, then the row band right of the labels; ganttToSvg is the server path. ISO YYYY-MM-DD strings or epoch ms both work as dates.

Example

import { GanttChart } from '@pyreon/charts/plot'
import type { GanttTask } from '@pyreon/charts/plot'

const tasks: GanttTask[] = [
  { 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' },
]
<GanttChart tasks={tasks} gantt={{ today: '2024-03-16' }} height={240} onSelect={(row) => row && console.log(row.task.id)} />

Common mistakes

See also: PlotChart · CalendarChart


createChartHandle function

() => ChartHandle

The imperative handle (ECharts dispatchAction) for ONE <PlotChart handle>: a link (zoom, hover) plus selected (pinned datums, GLOBAL indices) and hidden (series by mark index), and dispatch(action) over the ECharts vocabulary — highlight / downplay (VISIBLE-row index, the crosshair's space), select / unselect / toggleSelect (global datum), legendSelect / legendUnselect / legendToggle (series), dataZoom (fractions; a full window reads back as null) and restore (clears all four). Every dispatch is one batch, so the chart repaints once. The signals ARE the chart's state: handle.selected() reads the chart, and the change callbacks (onSelectChange / onHighlight / onLegendChange / onZoom) fire for a dispatch exactly as for a pointer. A handle is also a link — pass it as link to sibling charts to connect them.

Example

import { PlotChart, createChartHandle, bars } from '@pyreon/charts/plot'

interface Row { k: string; v: number }
declare const rows: Row[]
const chart = createChartHandle()
<PlotChart data={rows} x={(d) => d.k} marks={[bars((d: Row) => d.v)]} handle={chart} selectedMode="multiple" onSelectChange={(s) => console.log(s)} />
chart.dispatch({ type: 'select', index: 2 })
chart.dispatch({ type: 'dataZoom', start: 0.25, end: 0.75 })
chart.dispatch({ type: 'restore' })

Common mistakes

See also: createChartLink · PlotChart


() => ChartLink

Linked charts (ECharts connect): a shared { zoom, hover } pair of signals that every <PlotChart link> in a group uses IN PLACE of its private dataZoom window and crosshair datum. No bus, no registry, no unsubscribe — a chart that unmounts simply stops reading them, so there is nothing module-level to leak. Every gesture that writes the window (wheel, pan, brush, navigator drag, zoom presets, double-click reset) and the crosshair datum (hover, leave) therefore propagates to every linked chart; each chart keeps its own series, legend and tooltip. sonifyValues accepts the same link to move the crosshair with the sound.

Example

import { PlotChart, createChartLink, line, bars } from '@pyreon/charts/plot'

interface Bar { t: string; close: number; volume: number }
declare const price: Bar[]
const link = createChartLink()
<PlotChart data={price} x={(d) => d.t} marks={[line((d: Bar) => d.close)]} dataZoom crosshair navigator link={link} />
<PlotChart data={price} x={(d) => d.t} marks={[bars((d: Bar) => d.volume)]} dataZoom crosshair link={link} />

Common mistakes

See also: PlotChart · sonifyValues


sonifyValues function

(values: number[], options?: SonifyOptions) => Sonification

A series as sound: each value maps linearly to a pitch between minHz and maxHz (valueToHz — a FINITE value outside the domain clamps to the range's ends, a non-finite one is a gap), one oscillator steps through them over duration, a gap plays as silence, onStep(index) fires per datum, and a ChartLink moves every linked chart's crosshair along with the audio so the eye and the ear read the same datum. play() resolves when the last datum has sounded or on stop(); every timer is cleared and the oscillator stopped and disconnected on BOTH endings (a replay never trips a stale-oscillator error). The AudioContext is OWNED: one is constructed lazily on the first play() and closed when the run settles or is stopped, because a browser caps live contexts per document (Chrome at ~6) and a per-play context that is never closed throws NotSupportedError on the seventh press. A caller-supplied options.context is used as-is and NEVER closed — it belongs to the caller. Browsers require a user gesture before audio starts, so call play() from a click.

Example

import { sonifyValues, createChartLink } from '@pyreon/charts/plot'

declare const closes: number[]
const link = createChartLink()
const sound = sonifyValues(closes, { duration: 3000, minHz: 220, maxHz: 880, link })
<button onClick={() => void sound.play()}>Play</button>
<button onClick={() => sound.stop()}>Stop</button>

Common mistakes

See also: createChartLink · PlotChart


Package-level notes

Two engines, two subpaths: The package ships TWO independent engines. @pyreon/charts bridges ECharts — mature, enormous chart-type coverage, browser-only. @pyreon/charts/plot is Pyreon's own: pure-TypeScript geometry over a flat draw list, tree-shakeable by construction, with a canvas backend and a pure SVG backend that runs on a server. Import from ONE of them; pulling a name from the default entry drags ECharts back into a bundle that had dropped it.

tslib Vite alias: ECharts imports tslib whose ESM ./modules/index.js entry destructures named helpers from a __toESM(require_tslib()) default — the helpers live as top-level vars on the CJS factory, so the destructure reads undefined and the page throws TypeError: Cannot destructure property "__extends" the moment ECharts loads. Use chartsViteAlias() from @pyreon/charts/vite in your vite.config.ts (resolve: { alias: { ...chartsViteAlias() } }); it resolves tslib to the flat-ESM tslib.es6.js across install layouts. Browser tests use tslibBrowserAlias() from the shared test config. Tracking upstream: microsoft/tslib#189.

Note: Options must be a FUNCTION () => EChartsOption, not a plain object. Signal reads inside the function are tracked — changing any tracked signal reactively updates the chart.

Lazy loading: ECharts modules are auto-detected from your options (series types, components) and dynamically imported. First render has an async loading phase — check loading() or <Chart> handles it internally. Zero ECharts bytes in your initial bundle.

Manual entry: @pyreon/charts/manual skips auto-detection — you register ECharts components yourself via use() for maximum tree-shaking control.

Events: onEvents is the general handler map — any ECharts event by name (legendselectchanged, datazoom, brushselected, finished, …); each handler gets (params, instance). onClick/onMouseover/onMouseout are shorthands merged in (they WIN on a key collision). Binding is leak-safe: a changed handler swaps the listener (no pile-up) and all are removed on unmount.

A non-finite value is a GAP, everywhere: In @pyreon/charts/plot, NaN AND Infinity are gaps: they are dropped from every domain (extent, the auto axis, the parallel/calendar/boxplot/histogram domains), draw as nothing (a zero-height bar at the zero line, a break in a line, an absent parallel segment), are absent from the tooltip and the accessible table, and are silence in sonifyValues. makeTicks returns ZERO ticks for a non-finite BOUND rather than a thousand NaN labels. isFiniteNumber is the engine's predicate and is exported — it is written in the native subset (v === v && v - v === 0) because Number.isFinite has no lowering inside the crossing engine, and it is what a custom mark or family should use so its gaps match the built-ins'.

Theme is not reactive: Reactive theme: pass theme as an ACCESSOR (() => (dark() ? 'dark' : null)) — a flip disposes + re-inits with the current option/group/events preserved (ECharts has no in-place swap; dispose+re-init is the mechanism, as in vue-echarts). A plain value stays static. For map charts, await getCore() then core.registerMap(...) BEFORE rendering a map series.

Two charting engines — API Reference