@x12i/charts 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 x12i
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,528 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @x12i/charts
2
+
3
+ Accessible charts for the Unified UI Kit. You describe a chart as one
4
+ JSON-serializable `ChartSpec`; the package draws it as SVG with the kit's design
5
+ tokens, adds a legend, tooltip, keyboard navigation, loading/empty/error states,
6
+ a generated text description and a data table, and switches to an ECharts canvas
7
+ for very large data.
8
+
9
+ - **16 chart types**: bar, lollipop, line, area, step, sparkline, combo (bars and
10
+ lines, optionally on two axes), scatter, bubble, histogram, waterfall, donut,
11
+ pie, heatmap, gauge and progress ring.
12
+ - **Accessible by construction**: every chart has a name, a description, keyboard
13
+ navigation, a screen-reader announcement per point and a "View as table"
14
+ toggle. Color is never the only way to tell series apart.
15
+ - **Tokens only**: no colors, sizes or fonts of its own. Light, dark and
16
+ high-contrast themes work through `data-theme`.
17
+ - **Right to left** and **localized**: logical CSS, mirrored axes, `Intl` for
18
+ numbers, dates and lists, and a message catalog for every word it shows.
19
+ - **Server-safe**: the root entry has no React and no DOM.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ pnpm add @x12i/charts @x12i/ui @x12i/themes react react-dom
25
+ # Only if you want the canvas renderer (see "Performance"):
26
+ pnpm add echarts
27
+ ```
28
+
29
+ `@x12i/ui`, `react` and `react-dom` are peer dependencies; `echarts` is an
30
+ **optional** peer dependency. The package depends on `@x12i/tokens` and a few
31
+ small D3 modules (`d3-array`, `d3-scale`, `d3-shape`, `d3-time`, `d3-format`,
32
+ `d3-time-format`; all ISC). It is ESM only.
33
+
34
+ Import both stylesheets once, and set the theme on a parent element:
35
+
36
+ ```tsx
37
+ import "@x12i/ui/styles.css";
38
+ import "@x12i/charts/styles.css";
39
+ ```
40
+
41
+ The stylesheet is made of zero-specificity rules (`:where(...)`) on
42
+ `data-kit="chart-*"` hooks, so your own CSS always wins without `!important`.
43
+
44
+ ## Two entry points
45
+
46
+ | Import | Contents | Needs |
47
+ | --- | --- | --- |
48
+ | `@x12i/charts` | The headless core: `ChartSpec` types and `defineChartSpec`, validation, data transforms, statistics, scales and layout math, formatting, message catalog, text descriptions, data tables and CSV, the ECharts option builder. | Nothing. No React, no DOM, no browser globals. Safe in server code, workers and scripts. |
49
+ | `@x12i/charts/react` | `Chart`, `ChartFrame`, `ChartCard`, `ChartGrid`, `ChartDataTable`, `ChartLegend`, `ChartState`, `ChartConfigProvider`, `SvgChart`, `EChartsChart`. | React 19, `@x12i/ui`. |
50
+
51
+ `dist/react.js` starts with `"use client"`, so the components work in a Next.js
52
+ App Router tree without wrapping. The root entry has no directive and runs in
53
+ server components, route handlers and workers.
54
+
55
+ ### Server rendering
56
+
57
+ `Chart` renders on the server. Without a `width` it uses a fixed default width
58
+ (640) on the server **and** on the first client render, so hydration matches;
59
+ then it measures its container and re-draws to fit. Pass `width` and `height`
60
+ for charts that must not move. The default locale is the fixed `"en"`, never the
61
+ runtime's, so the server and browser print identical text. Pass `locale` (or set
62
+ it once with `ChartConfigProvider`) to localize.
63
+
64
+ ```ts
65
+ // A server component, route handler or script: no React involved.
66
+ import { chartToCsv, describeChart, validateChartSpec } from "@x12i/charts";
67
+
68
+ const issues = validateChartSpec(spec);
69
+ const { name, text } = describeChart(spec); // the screen-reader description
70
+ const csv = chartToCsv(spec, { locale: "de" });
71
+ ```
72
+
73
+ ## Quick start
74
+
75
+ ```tsx
76
+ import { ChartFrame } from "@x12i/charts/react";
77
+ import type { ChartSpec } from "@x12i/charts";
78
+
79
+ // Define specs outside the component, or memoize them. A new object on every
80
+ // render makes the chart rebuild its model on every render.
81
+ const tickets: ChartSpec = {
82
+ title: "Platform has the most open tickets",
83
+ source: "Support desk, 1 Nov 2025",
84
+ data: {
85
+ values: [
86
+ { team: "Platform", tickets: 42 },
87
+ { team: "Growth", tickets: 31 },
88
+ { team: "Payments", tickets: 18 },
89
+ { team: "Search", tickets: 12 },
90
+ ],
91
+ },
92
+ mark: "bar",
93
+ x: { field: "team", type: "category", title: "Team" },
94
+ y: { field: "tickets", type: "number", title: "Open tickets" },
95
+ highlight: { where: "team == 'Platform'" },
96
+ labels: { values: true },
97
+ a11y: { description: "Platform has 42 open tickets, more than any other team." },
98
+ };
99
+
100
+ export function TicketsChart() {
101
+ return <ChartFrame spec={tickets} />;
102
+ }
103
+ ```
104
+
105
+ `ChartFrame` is a `<figure>` with a heading (the title states the finding), a
106
+ subtitle, a toolbar (**View as table**, **Download CSV**), the chart, the source
107
+ line and the data table. Use `Chart` alone when you supply your own frame, and
108
+ `ChartCard` for the same frame on a bordered surface.
109
+
110
+ ### Line
111
+
112
+ ```tsx
113
+ const revenue: ChartSpec = {
114
+ title: "Revenue outgrew cost every month",
115
+ subtitle: "Monthly, USD thousands",
116
+ source: "Finance ledger",
117
+ data: {
118
+ values: [
119
+ { month: "2025-01-01", revenue: 120, cost: 90 },
120
+ { month: "2025-02-01", revenue: 135, cost: 96 },
121
+ { month: "2025-03-01", revenue: 128, cost: 99 },
122
+ { month: "2025-04-01", revenue: 160, cost: 104 },
123
+ ],
124
+ },
125
+ mark: "line",
126
+ x: { field: "month", type: "time", title: "Month" },
127
+ y: { field: "revenue", type: "number", title: "USD thousands" },
128
+ series: [
129
+ { id: "revenue", label: "Revenue", field: "revenue" },
130
+ { id: "cost", label: "Cost", field: "cost" },
131
+ ],
132
+ annotations: [{ type: "reference-line", y: 130, label: "Target" }],
133
+ a11y: { description: "Revenue rose from 120 to 160 while cost rose from 90 to 104." },
134
+ };
135
+
136
+ <ChartFrame spec={revenue} />;
137
+ ```
138
+
139
+ Series that differ in kind are listed in `series`. Without `series`, one series
140
+ is drawn from `y`, or one per value of `color: { field }`.
141
+
142
+ ### Bar variants
143
+
144
+ ```ts
145
+ // Grouped (default), stacked, normalized to 100%, or horizontal:
146
+ { mark: "bar", color: { field: "region", type: "category" } }
147
+ { mark: "bar", color: { field: "region", type: "category" }, stack: "stacked" }
148
+ { mark: "bar", color: { field: "region", type: "category" }, stack: "normalized" }
149
+ { mark: "bar", orientation: "horizontal" }
150
+ ```
151
+
152
+ ### Donut
153
+
154
+ ```tsx
155
+ const share: ChartSpec = {
156
+ title: "Platform and Growth hold three quarters of tickets",
157
+ data: { ref: "tickets" }, // resolved from `datasets`
158
+ mark: "donut",
159
+ x: { field: "team", type: "category", title: "Team" },
160
+ y: { field: "tickets", type: "number", title: "Open tickets" },
161
+ donut: { centerCaption: "tickets" }, // the center shows the total
162
+ a11y: { description: "Platform 41 percent, Growth 30 percent, the rest 29 percent." },
163
+ };
164
+
165
+ <ChartFrame spec={share} datasets={{ tickets: rows }} />;
166
+ ```
167
+
168
+ Slices beyond `donut.maxSlices` (default 5) merge into "Other". Slices show their
169
+ share; the legend toggles them.
170
+
171
+ ### Sparkline
172
+
173
+ ```tsx
174
+ import { Chart } from "@x12i/charts/react";
175
+
176
+ const trend: ChartSpec = {
177
+ title: "Revenue trend",
178
+ data: { values: monthly },
179
+ mark: "sparkline",
180
+ x: { field: "month", type: "time" },
181
+ y: { field: "revenue", type: "number" },
182
+ a11y: { description: "Revenue trends up." },
183
+ };
184
+
185
+ // 48 high by default. A non-interactive sparkline is a labelled image.
186
+ <Chart spec={trend} width={120} height={32} interactive={false} />;
187
+ ```
188
+
189
+ ### Other types
190
+
191
+ | `mark` | Key fields | Notes |
192
+ | --- | --- | --- |
193
+ | `area` | `x`, `y`, optional `color` + `stack` | Stacked or normalized with `stack`. |
194
+ | `step` | `x`, `y` | Steps instead of slopes. |
195
+ | `lollipop` | `x` (category), `y` | A stem and a dot per category. |
196
+ | `combo` | `series` with `mark: "bar" \| "line" \| "area" \| "step" \| "scatter"` | Second axis needs `dualAxis: true` and `axis: "y2"`; a visible warning is added. |
197
+ | `scatter` | `x` (number), `y` | Optional `color` for groups. |
198
+ | `bubble` | `x`, `y`, `size` | Bubble area encodes `size`. |
199
+ | `histogram` | `x` (the measure) and `bins: { count }` or `bins: { thresholds }` | Bins the raw values. |
200
+ | `waterfall` | `x` (steps), `y` (change), `waterfall: { totals }` | Increase, decrease and total bars with connectors. |
201
+ | `heatmap` | `x`, `y` (categories), `value` | Sequential ramp, or diverging when values cross zero. |
202
+ | `gauge` | `gauge: { value, min, max, target, bands, format }` | `data: { values: [] }` is fine for a literal value. |
203
+ | `progress-ring` | `gauge: { value, min, max, format: "percent" }` | |
204
+
205
+ Everything else in `chartTypes` (for example `treemap`, `sankey`, `stat`)
206
+ is reserved in the spec but not drawn yet; see "Limitations".
207
+
208
+ ## ChartSpec in brief
209
+
210
+ ```ts
211
+ interface ChartSpec {
212
+ title: string; // a sentence that states the finding (required)
213
+ subtitle?: string; // scope and units
214
+ source?: string; // where the data came from
215
+ data: { ref: string } | { values: Row[] };
216
+ mark: ChartType;
217
+ x?: Encoding; y?: Encoding; color?: Encoding; size?: Encoding; value?: Encoding;
218
+ series?: SeriesSpec[]; // explicit series: id, label, field | points, mark, axis, tone, marker, dash, stack, hidden, format
219
+ stack?: "none" | "stacked" | "normalized" | "diverging";
220
+ orientation?: "vertical" | "horizontal";
221
+ scales?: { x?, y?, y2? }; // linear | log | sqrt | time | band | ordinal, domain, nice, zero, reverse, padding
222
+ axes?: { x?, y?, y2? }; // show, title, grid, ticks, tickValues, format, labelAngle, maxLabelLength
223
+ thresholds?: Threshold[]; // labelled limits; points that breach get an extra mark and a note
224
+ annotations?: Annotation[]; // reference-line, reference-band, text, forecast, point (all labelled)
225
+ transforms?: Transform[]; // aggregate, bin, stack, percent-of-total, sort, top-n, rolling-average, cumulative-sum, period-change, downsample
226
+ highlight?: { where: string }; // one accent: `team == 'Platform'`
227
+ legend?: { show?: boolean | "auto"; position?: "top" | "bottom" };
228
+ labels?: { direct?: boolean | "auto"; values?: boolean };
229
+ interactions?: { tooltip?; legendIsolate? };
230
+ dualAxis?: boolean;
231
+ renderer?: "svg" | "canvas" | "auto";
232
+ direction?: "ltr" | "rtl" | "auto";
233
+ timeZone?: string; // IANA zone for formatting time values (default "UTC")
234
+ a11y: { description: string; table?: boolean }; // description is required
235
+ }
236
+ ```
237
+
238
+ Formats are named (`compact`, `percent`, `integer`, `decimal`, `currency:USD`,
239
+ `date`, `datetime`, `time`, `month`, `year`), d3-format specifiers (`,.2f`) or
240
+ d3-time-format patterns (`%b %Y`).
241
+
242
+ `validateChartSpec(spec, { datasets })` returns `{ severity, code, path, message }`
243
+ issues for anything you can get wrong (missing title, unknown field, a bar chart
244
+ with a truncated baseline, a second axis without `dualAxis`, too many series,
245
+ too many rows for SVG, ...). `isValidChartSpec` is the boolean version.
246
+
247
+ Design rules the validator and defaults enforce: bars start at zero, one accent
248
+ on grey, at most 8 categorical hues, dual axes only behind an explicit flag,
249
+ targets and limits always labelled.
250
+
251
+ ## Component API
252
+
253
+ ### `Chart` / `SvgChart` / `EChartsChart`
254
+
255
+ | Prop | Type | Description |
256
+ | --- | --- | --- |
257
+ | `spec` | `ChartSpec` | The chart. Memoize it. |
258
+ | `datasets` | `Record<string, Row[]>` | Rows for `data: { ref }`. Falls back to `ChartConfigProvider`. |
259
+ | `width`, `height` | `number` | Layout units. Default: fill the container; height by type (320, sparkline 48, gauge 220). |
260
+ | `locale` | `string` | BCP 47 tag for numbers, dates, lists and plurals. Default `"en"`. |
261
+ | `messages` | `ChartMessagesInput` | Overrides for every word the chart shows or announces. |
262
+ | `dir` | `"ltr" \| "rtl"` | Text direction. Default: the page's `dir`, then the locale. |
263
+ | `renderer` | `"svg" \| "canvas" \| "auto"` | Overrides `spec.renderer`. |
264
+ | `target` | `"screen" \| "print" \| "report"` | `print` and `report` always use SVG. |
265
+ | `loadEcharts` | `() => Promise<{ init }>` | Loads echarts for canvas. Default `import("echarts")`. |
266
+ | `loading`, `error`, `onRetry` | `boolean`, `unknown`, `() => void` | Loading and error states at the chart's height. |
267
+ | `hiddenSeries`, `defaultHiddenSeries`, `onHiddenSeriesChange` | `string[]` | Controlled or uncontrolled legend toggles. |
268
+ | `interactive` | `boolean` | `false` draws a static, non-focusable labelled image. Default `true`. |
269
+ | `tooltip` | `boolean` | Default `spec.interactions.tooltip !== false`. |
270
+ | `onPointActivate` | `(info: ChartPointInfo, event) => void` | Click, Enter or Space on a point. |
271
+ | `onActivePointChange` | `(info \| null) => void` | The hovered or focused point changed. |
272
+ | `className`, `style`, `id` | | On the root element. |
273
+
274
+ `SvgChart` always draws SVG. `EChartsChart` always uses canvas (with an SVG
275
+ fallback, see below). `ChartPointInfo` has `seriesId`, `seriesLabel`,
276
+ `seriesIndex`, `pointIndex`, `x`, `xLabel`, `y`, `size` and `row` (the source row).
277
+
278
+ ### `ChartFrame` / `ChartCard`
279
+
280
+ Everything `Chart` takes, plus `headingLevel` (2-6, default 3), `toolbar`
281
+ (default `true`), `actions` (extra buttons), `view` / `defaultView` /
282
+ `onViewChange` (`"chart" | "table"`), `tableMaxRows` (default 200; the CSV always
283
+ has every row), `csvFileName`, `onDownload(csv, fileName)` (replaces the browser
284
+ download) and `variant` (`"plain" | "card"`).
285
+
286
+ ### `ChartGrid`, `ChartDataTable`, `ChartLegend`, `ChartState`
287
+
288
+ `ChartGrid` lays charts out in a responsive grid (`columns` fixes the count,
289
+ `label` makes it a named group). `ChartDataTable` is the data table on its own.
290
+ `ChartLegend` and `ColorScaleLegend` and `ChartState` are the building blocks,
291
+ exported for custom layouts.
292
+
293
+ ### `ChartConfigProvider`
294
+
295
+ Sets `locale`, `messages`, `direction`, `datasets`, `loadEcharts` and `target`
296
+ for every chart inside. Providers nest and merge (the inner one wins; `markNames`
297
+ merge key by key); props on a chart win over the provider.
298
+
299
+ ```tsx
300
+ <ChartConfigProvider locale="de" messages={deMessages} datasets={datasets}>
301
+ <ChartFrame spec={spec} />
302
+ </ChartConfigProvider>
303
+ ```
304
+
305
+ ## Theming
306
+
307
+ Series colors are `--kit-color-chart-*` tokens (`accent`, `muted`, `reference`,
308
+ `good`, `warning`, `critical`, `categorical-1` to `categorical-8`); heatmaps use
309
+ the `--kit-color-sequential-*` and `--kit-color-diverging-*` ramps; axes and
310
+ gridlines use `--kit-color-chart-axis` and `--kit-color-chart-grid`. Everything
311
+ follows the theme set by `ThemeProvider` / `data-theme` (light, dark,
312
+ high-contrast) and the density tokens. To change a color, override the token
313
+ where you need it:
314
+
315
+ ```css
316
+ .my-dashboard {
317
+ --kit-color-chart-categorical-1: var(--kit-color-accent-default);
318
+ }
319
+ ```
320
+
321
+ Specs choose a tone by name (`series: [{ id: "a", tone: "good" }]`), never a
322
+ color. The canvas renderer reads the same variables at runtime and redraws when
323
+ `data-theme` changes on the page or an ancestor.
324
+
325
+ Every part of a chart has a hook: `data-kit="chart"`, `chart-svg`, `chart-line`,
326
+ `chart-bar`, `chart-point`, `chart-arc`, `chart-cell`, `chart-axis`,
327
+ `chart-tick-label`, `chart-legend`, `chart-tooltip`, `chart-state`,
328
+ `chart-frame`, `chart-table` and so on, with states as `data-*` attributes
329
+ (`data-hidden`, `data-active`, `data-highlighted`, `data-muted`, `data-breach`,
330
+ `data-state`, `data-renderer`). The stylesheet is tested to contain no hex or
331
+ `rgb()` colors, no px/rem lengths, only logical properties and only existing
332
+ `--kit-*` variables.
333
+
334
+ ## Accessibility
335
+
336
+ - **Name and description.** The plot is `role="application"` with an
337
+ `aria-label` of "{title} ({chart type})", `aria-roledescription="chart"` and an
338
+ `aria-describedby` pointing to a generated description: chart type, series and
339
+ point counts, ranges, trends, the highest and lowest points, thresholds and
340
+ breaches, plus your own `a11y.description`.
341
+ - **Keyboard.** Tab to the chart, then the arrow keys move between points: left
342
+ and right along the categories, up and down between series at the same
343
+ category; on a heatmap along both axes; on a scatter plot to the nearest point
344
+ in that direction; through the slices of a pie. Home / End jump to the first
345
+ and last category, PageUp / PageDown move ten categories, Enter or Space
346
+ activates (`onPointActivate`), Escape dismisses the tooltip. In a right-to-left chart, left and right swap. A visible focus
347
+ ring is drawn on the focused mark.
348
+ - **Screen readers.** Each focused point is announced politely through a live
349
+ region ("Mar 1, 2025, Cost: 99. 6 of 12."). The tooltip is for sighted users
350
+ and is `aria-hidden`, so nothing is announced twice. The decorative SVG groups
351
+ are `aria-hidden`.
352
+ - **Data table.** `ChartFrame` always renders the data as a real `<table>` with
353
+ caption, column headers and row headers: visible in table view, visually hidden
354
+ (but in the accessibility tree) in chart view. Disable with
355
+ `a11y: { table: false }`.
356
+ - **Color is never the only channel.** Series differ by hue **and** marker shape
357
+ and dash style (lines), or hatch pattern (bars, areas, slices; eight patterns
358
+ cycle so hue + pattern pairs stay unique for 64 series). Threshold breaches get
359
+ an extra mark and a note in the tooltip. Waterfall decreases are hatched, and
360
+ the tooltip names every change with a sign. Status annotations carry text.
361
+ - **Legend.** Entries are `<button aria-pressed>`; hidden series are removed from
362
+ the plot, the description and the announcements.
363
+ - **Reduced motion.** Animation is skipped when `prefers-reduced-motion: reduce`.
364
+ - **Forced colors.** In Windows high-contrast mode, marks use system colors and
365
+ keep their outlines and patterns.
366
+ - **Touch.** A tap selects a point and keeps its tooltip after the finger lifts
367
+ (a hovering mouse hides it when the pointer leaves).
368
+ - **States.** Loading (`role="status"`, `aria-busy`), empty, missing dataset and
369
+ unsupported type use `role="status"`; errors use `role="alert"` with an optional
370
+ retry. All reserve the chart's height so the page does not jump.
371
+
372
+ Tested with axe (`vitest-axe`) on every chart type, in chart and table view, in
373
+ every state, in right-to-left, and on the canvas renderer, with zero violations.
374
+ Axe cannot check color contrast in jsdom; contrast is guaranteed by the token
375
+ pairs in `@x12i/themes`.
376
+
377
+ ## Internationalization
378
+
379
+ - Every word the charts print or announce is in `defaultMessages` (86 keys:
380
+ states, toolbar, table columns, tooltips, point announcements, the generated
381
+ description, trends). Pass `messages` with any subset. Templates use an ICU
382
+ MessageFormat subset: `{name}`, `{n, number}`,
383
+ `{n, plural, =0 {none} one {# row} other {# rows}}` (`#` is the number) and
384
+ `{kind, select, up {rose} other {fell}}`. Values you pass as text are shown
385
+ as-is; numbers go through `Intl` for `{n, number}` and for plural rules.
386
+ `markNames` renames chart types ("line chart").
387
+ - Numbers, dates, times, percentages, compact numbers and lists go through
388
+ `Intl` with your `locale`; the default is the fixed `"en"`.
389
+ - Right-to-left (Arabic, Hebrew, Persian, Urdu, ...): the chart sets `dir`,
390
+ time and category axes mirror, the value axis moves to the other side, label
391
+ anchors flip for right-to-left text and keyboard left/right swap. Numeric
392
+ labels stay left-to-right. Direction comes from the `dir` prop, then the
393
+ page's `dir` attribute (observed for changes), then the locale.
394
+
395
+ ```tsx
396
+ <ChartConfigProvider
397
+ locale="ar"
398
+ messages={{ viewAsTable: "عرض كجدول", downloadCsv: "تنزيل CSV", legend: "المفتاح" }}
399
+ >
400
+ <ChartFrame spec={spec} />
401
+ </ChartConfigProvider>
402
+ ```
403
+
404
+ ## Performance
405
+
406
+ | Data size | Renderer | Notes |
407
+ | --- | --- | --- |
408
+ | Up to ~5,000 points | SVG | Fully accessible, keyboard navigable, vector. Always used for `target="print"` and `"report"`. |
409
+ | Above 5,000 points | Canvas (`renderer: "auto"`) | `SVG_POINT_LIMIT = 5000`. Needs `echarts`. |
410
+
411
+ Before reaching for canvas, thin the data: `transforms: [{ type: "downsample",
412
+ method: "lttb", field: "value", threshold: 1000 }]` keeps the visual shape of a
413
+ line with a fraction of the points, and `aggregate` / `bin` reduce rows before
414
+ drawing. The 5,000-point threshold is a comfort limit, not a measured
415
+ guarantee: DOM-based SVG gets heavier with every mark, so test on your slowest
416
+ target device. Layout is memoized on the spec, size, locale, direction and
417
+ hidden series, so memoize your spec.
418
+
419
+ ### The canvas renderer
420
+
421
+ `EChartsChart`, `renderer="canvas"` and `renderer="auto"` above the limit draw
422
+ with Apache ECharts, loaded lazily by `import("echarts")` the first time a chart
423
+ needs it (apps that only draw SVG never download it). Colors are read from your
424
+ `--kit-*` variables, and the chart redraws on theme changes. The legend, title,
425
+ frame, data table and text description are the same as for SVG.
426
+
427
+ If `echarts` cannot be loaded, or cannot draw the particular chart (for example
428
+ a waterfall that crosses zero), the chart falls back to SVG and shows a note.
429
+
430
+ `echarts` is an optional peer, and bundlers resolve `import("echarts")` at build
431
+ time, so if you do not install it your bundler may warn or fail on that import.
432
+ Either install `echarts`, or pass your own loader, which also lets you register
433
+ only the echarts modules you use:
434
+
435
+ ```tsx
436
+ import * as echarts from "echarts/core";
437
+ import { LineChart } from "echarts/charts";
438
+ import { GridComponent, TooltipComponent } from "echarts/components";
439
+ import { CanvasRenderer } from "echarts/renderers";
440
+
441
+ echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
442
+
443
+ <ChartConfigProvider loadEcharts={async () => echarts}>{children}</ChartConfigProvider>;
444
+ ```
445
+
446
+ ## CSV and tables
447
+
448
+ `chartToTable(spec)` and `chartToCsv(spec, { locale, delimiter, bom })` produce
449
+ the same table the component shows; the CSV is RFC 4180 (CRLF line ends, quoted
450
+ fields). The download button adds a UTF-8 byte-order mark so spreadsheets open it
451
+ correctly, and `onDownload` lets you send the CSV to your own handler.
452
+
453
+ ## Headless API
454
+
455
+ Everything in `@x12i/charts`, by area:
456
+
457
+ - **Spec**: `defineChartSpec`, `chartTypes`, the `ChartSpec` types.
458
+ - **Validation**: `validateChartSpec`, `isValidChartSpec`.
459
+ - **Model and data**: `buildChartModel`, `supportedMarks`, `isSupportedMark`,
460
+ `applyTransforms`, `resolveRows`, `countPoints`, `effectiveStack`, `readField`,
461
+ `histogramBins`, `stackValues`, `lttb`, `minMax`, `compileWhere` /
462
+ `evaluateWhere` (the `highlight.where` expression language).
463
+ - **Statistics**: `mean`, `median`, `quantile`, `sum`, `variance`,
464
+ `standardDeviation`, `extent`, `correlation`, `linearRegression`,
465
+ `regressionBand`, `forecastLinear`, `movingAverage`, `cumulativeSum`,
466
+ `percentChange`, `periodChange`, `trendDirection`.
467
+ - **Scales and layout**: `createContinuousScale`, `createBandScale`,
468
+ `createPointScale`, `niceTicks`, `fitTickLabels`, `layoutChart` (spec model to
469
+ plain geometry; the SVG component only draws it), `patternIndex`.
470
+ - **Interaction as pure functions**: `hitTest`, `navigate`, `tooltipFor`,
471
+ `describeTarget`, `pointInfo`, `isNavKey`.
472
+ - **Formatting and messages**: `createFormatters`, `createNumberFormatter`,
473
+ `createDateFormatter`, `formatList`, `formatShare`, `isRtlLocale`,
474
+ `DEFAULT_LOCALE`, `defaultMessages`, `resolveMessages`, `formatMessage`.
475
+ - **Text alternatives and exports**: `describeChart`, `describeModel`,
476
+ `chartToTable`, `modelToTable`, `chartToCsv`, `tableToCsv`.
477
+ - **Renderer**: `resolveRenderer`, `SVG_POINT_LIMIT`.
478
+ - **Canvas mapping**: `toEChartsOption`, `themeColorsFromTokens` (no echarts
479
+ import).
480
+
481
+ Because layout is a pure function, you can render a chart to your own target
482
+ (a PDF, a canvas, an email) from `layoutChart(buildChartModel(spec), { width,
483
+ height, rtl })`.
484
+
485
+ ## Limitations
486
+
487
+ Honest list of what is not there yet:
488
+
489
+ - **Chart types not drawn.** `chartTypes` reserves 50 types so saved specs stay
490
+ valid, but the renderer draws the 16 in `supportedMarks`. Others (`treemap`,
491
+ `sankey`, `box-plot`, `stat`, `funnel`, `calendar-heatmap`, maps, ...) show an
492
+ "unavailable" state, and `validateChartSpec` warns.
493
+ - **Interactions.** `interactions.brush`, `zoom` and `drilldown` are in the spec
494
+ for forward compatibility and are ignored (the validator warns). There is no
495
+ shared filter store. Legend toggling, isolate (`legendIsolate`), tooltips,
496
+ keyboard navigation and `onPointActivate` work.
497
+ - **Canvas.** The canvas renderer has no keyboard navigation and no right-to-left
498
+ mirroring (it is an image with a description, the table and the legend); use
499
+ SVG when you need them. A chart that falls back to SVG (echarts missing, or a
500
+ type canvas cannot draw) stays on SVG until it is re-mounted.
501
+ - **Forced colors.** In Windows high-contrast mode, heatmap cells lose their color
502
+ ramp (system colors have no ramp); the values are in the tooltip, the labels and
503
+ the table.
504
+ - **Time axes** are laid out in UTC; `timeZone` changes how values are formatted,
505
+ not where they sit on the axis.
506
+ - **Waterfalls** that cross zero draw on SVG only.
507
+ - **Gauges** are single-value. `format: "percent"` is the share of the min to max
508
+ range: 64 of 100 and 0.64 of 1 both read "64%". A literal gauge still needs
509
+ `data: { values: [] }`.
510
+ - **Hatch patterns** are applied automatically (the second series onward in a
511
+ multi-series bar, area or slice chart); they are an option of `layoutChart`
512
+ (`patterns: true`), not a spec field.
513
+ - **Message catalog** is English only. Other languages come from your `messages`.
514
+ - **Contrast** depends on your theme; axe cannot measure it in jsdom.
515
+
516
+ ## Development
517
+
518
+ ```sh
519
+ pnpm --filter @x12i/charts run build # tsup (two entries) + copy styles.css
520
+ pnpm --filter @x12i/charts run typecheck
521
+ pnpm --filter @x12i/charts run lint # also bans hard-coded colors and px/rem in src
522
+ pnpm --filter @x12i/charts run test # vitest: headless units, components, axe, styles, SSR
523
+ ```
524
+
525
+ The tests cover every headless function, every chart type (marks, axes, legend,
526
+ tooltip, keyboard, states, messages, right-to-left), axe on every chart type,
527
+ the stylesheet's token rules, server rendering, and the canvas renderer with a
528
+ mocked `echarts`.