@x12i/charts 1.0.0 → 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/README.md +514 -23
- package/dist/chunk-U3U6ZV7A.js +5040 -0
- package/dist/chunk-U3U6ZV7A.js.map +1 -0
- package/dist/echarts-option-DHFfEvN1.d.ts +1251 -0
- package/dist/index.d.ts +262 -154
- package/dist/index.js +537 -27
- package/dist/index.js.map +1 -1
- package/dist/react.d.ts +251 -0
- package/dist/react.js +1271 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.css +821 -0
- package/package.json +64 -5
package/README.md
CHANGED
|
@@ -1,37 +1,528 @@
|
|
|
1
1
|
# @x12i/charts
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
7
63
|
|
|
8
64
|
```ts
|
|
9
|
-
|
|
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";
|
|
10
78
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
},
|
|
15
92
|
mark: "bar",
|
|
16
|
-
x: { field: "
|
|
17
|
-
y: { field: "
|
|
18
|
-
highlight: { where: "
|
|
19
|
-
|
|
20
|
-
a11y: { description: "
|
|
21
|
-
}
|
|
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} />;
|
|
22
137
|
```
|
|
23
138
|
|
|
24
|
-
|
|
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 }`.
|
|
25
141
|
|
|
26
|
-
|
|
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 |
|
|
27
192
|
| --- | --- | --- |
|
|
28
|
-
|
|
|
29
|
-
|
|
|
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
|
|
30
252
|
|
|
31
|
-
`
|
|
253
|
+
### `Chart` / `SvgChart` / `EChartsChart`
|
|
32
254
|
|
|
33
|
-
|
|
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
|
+
```
|
|
34
524
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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`.
|