graphein-mcp 0.4.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.
@@ -0,0 +1,1382 @@
1
+ # Graphein Spec Reference
2
+
3
+ Every Graphein chart is described by a single **`ChartSpec`** — a plain, JSON‑serializable
4
+ object. There are no functions, classes, or callbacks in a spec, so specs round‑trip
5
+ through `JSON.stringify` and are safe for a coding agent to emit, store, and replay.
6
+
7
+ ```ts
8
+ import { render } from 'graphein';
9
+
10
+ const instance = render('#chart', {
11
+ type: 'line',
12
+ data: [{ month: '2024-01', users: 4200 }, { month: '2024-02', users: 4650 }],
13
+ encoding: {
14
+ x: { field: 'month', type: 'temporal' },
15
+ y: { field: 'users', type: 'quantitative' },
16
+ },
17
+ });
18
+ ```
19
+
20
+ - **`type`** is the discriminator. One of:
21
+ `line`, `area`, `bar`, `scatter`, `pie`, `heatmap`, `kpi`, `table`, `matrix`.
22
+ - **`data`** is a tidy/row‑oriented array of records (`Array<Record<string, unknown>>`).
23
+ The same long‑format table feeds every chart; you select fields via `encoding`
24
+ (cartesian charts) or via explicit field lists (`table`, `matrix`).
25
+
26
+ Runnable JSON for every chart type lives in [`docs/examples/`](./examples).
27
+
28
+ ---
29
+
30
+ ## Table of contents
31
+
32
+ - [Common fields (`BaseSpec`)](#common-fields-basespec)
33
+ - [Encoding & `FieldDef`](#encoding--fielddef)
34
+ - [Scales](#scales)
35
+ - [Transforms](#transforms)
36
+ - [Annotations (reference lines, bands, zones, points)](#annotations-reference-lines-bands-zones-points)
37
+ - [Self-explaining charts (summaries & auto-insights)](#self-explaining-charts-summaries--auto-insights)
38
+ - [Trendlines (regression overlays)](#trendlines-regression-overlays)
39
+ - [Faceting (small multiples)](#faceting-small-multiples)
40
+ - [Chart types](#chart-types)
41
+ - [line](#line) · [area](#area) · [bar](#bar) · [scatter](#scatter) · [combo](#combo) · [histogram](#histogram) · [pie](#pie)
42
+ - [heatmap](#heatmap) · [kpi](#kpi) · [table](#table) · [matrix](#matrix)
43
+ - [box](#box) · [funnel](#funnel) · [sankey](#sankey) · [choropleth](#choropleth)
44
+ - [treemap](#treemap) · [gauge](#gauge) · [bullet](#bullet) · [calendarHeatmap](#calendarheatmap)
45
+ - [waterfall](#waterfall) · [slope](#slope) · [dumbbell](#dumbbell)
46
+ - [Slicers](#slicers)
47
+ - [dropdown](#dropdown) · [search](#search) · [list](#list) · [range](#range) · [dateRange](#daterange)
48
+ - [Interactivity (selection · highlight · filter)](#interactivity-selection--highlight--filter)
49
+ - [Dashboards](#dashboards)
50
+ - [Conditional formatting](#conditional-formatting)
51
+ - [Themes](#themes)
52
+ - [Sketch (hand-drawn) mode](#sketchconfig)
53
+ - [Format mini‑language](#format-mini-language)
54
+ - [Enumerations](#enumerations)
55
+ - [Runtime API](#runtime-api)
56
+ - [Validation & linting](#validation--linting) · [Self-repairing specs](#self-repairing-specs) · [Render report](#render-report) · [Performance](#performance) · [Animation](#animation)
57
+ - [Accessibility](#accessibility)
58
+
59
+ ---
60
+
61
+ ## Common fields (`BaseSpec`)
62
+
63
+ Shared by **all** chart types.
64
+
65
+ | Field | Type | Default | Notes |
66
+ | --- | --- | --- | --- |
67
+ | `data` | `Datum[]` | — | Row‑oriented records. Required for every chart/table. |
68
+ | `transform` | `Transform[]` | — | Declarative pipeline that reshapes `data` **before** charting (aggregate, bin, filter, fold, timeUnit). See [Transforms](#transforms). |
69
+ | `theme` | `'light' \| 'dark' \| ThemeOverride` | `'light'` | Theme name or a partial override (see [Themes](#themes)). |
70
+ | `dimensions` | `{ width?, height?, autoResize? }` | responsive | Omit `width`/`height` to fill the container and track resizes. |
71
+ | `title` | `string \| TitleConfig` | — | `string`, or `{ text, subtitle?, align? }`. |
72
+ | `description` | `string` | auto | Accessible alt text. Used verbatim as the chart's `aria-label`; auto‑synthesized from type/title/data when omitted (see [Accessibility](#accessibility)). |
73
+ | `legend` | `LegendConfig \| boolean` | auto | `false` hides it; `{ show?, position?, title? }`. `position`: `top \| right \| bottom \| left`. |
74
+ | `tooltip` | `TooltipConfig \| boolean` | `true` | `false` (or `{ show: false }`) disables hover tooltips. |
75
+ | `axes` | `{ x?: AxisConfig, y?: AxisConfig }` | auto | Per‑axis overrides (cartesian charts). |
76
+ | `animation` | `AnimationConfig \| boolean` | on | Brief entrance on first render. `false` disables; `{ enabled?, duration?, easing? }`. Honors `prefers-reduced-motion` (see [Animation](#animation)). |
77
+ | `padding` | `Partial<Insets>` | auto | Extra `{ top, right, bottom, left }` px around the plot. |
78
+ | `background` | `string` | theme bg | CSS color override for the chart surface. |
79
+ | `sketch` | `boolean \| SketchConfig` | `false` | Render with the hand‑drawn ("sketch") look — wobbly outlines, hachure fills, and a handwriting font (see [`SketchConfig`](#sketchconfig)). |
80
+ | `params` | `SelectionParam[]` | — | Named selections this visual **publishes** (click/brush/slicer). See [Interactivity](#interactivity-selection--highlight--filter). |
81
+ | `highlight` | `HighlightConfig \| HighlightConfig[]` | — | Emphasize rows matching a param; dim the rest. An array unions sources. |
82
+ | `filter` | `FilterClause[]` | — | Subset rows to those matching **every** clause (a `{ param }` or a literal predicate). |
83
+
84
+ ### `TitleConfig`
85
+
86
+ ```jsonc
87
+ { "text": "Monthly active users", "subtitle": "Trailing 6 months", "align": "left" }
88
+ ```
89
+
90
+ ### `AxisConfig`
91
+
92
+ | Field | Type | Notes |
93
+ | --- | --- | --- |
94
+ | `show` | `boolean` | Hide the whole axis with `false`. |
95
+ | `title` | `string` | Axis title (overrides the field title). |
96
+ | `grid` | `boolean` | Toggle gridlines for this axis. |
97
+ | `ticks` | `number` | Approximate tick count (a hint, not exact). |
98
+ | `tickValues` | `number[]` | Explicit tick positions. |
99
+ | `format` | `string` | [Format hint](#format-mini-language) for tick labels. |
100
+ | `labels` | `boolean` | Show/hide tick labels. |
101
+
102
+ ### `SketchConfig`
103
+
104
+ Turns on the alternate **hand‑drawn renderer** — a from‑scratch, rough.js‑style
105
+ engine baked into `graphein` (no extra dependency). Marks get wobbly multi‑pass
106
+ outlines and hachure fills; text switches to a bundled handwriting font (Patrick
107
+ Hand, SIL OFL). Works for **every** chart type, including the DOM charts
108
+ (`kpi`/`table`/`matrix`, which get the font plus subtle hand‑drawn chrome).
109
+
110
+ Set `sketch: true` for all defaults, or pass an object to tune the look:
111
+
112
+ | Field | Type | Default | Notes |
113
+ | --- | --- | --- | --- |
114
+ | `roughness` | `number` | `1` | Jitter amount. `0` ≈ clean; higher is wilder. |
115
+ | `bowing` | `number` | `1` | How much straight strokes bow/curve. |
116
+ | `fillStyle` | `'hachure' \| 'solid' \| 'cross-hatch'` | `'hachure'` | Shape fill style. |
117
+ | `hachureGap` | `number` | auto | Px between hachure lines (scales with stroke when omitted). |
118
+ | `hachureAngle` | `number` | `-41` | Hachure line angle, in degrees. |
119
+ | `strokeWidth` | `number` | `1` | Outline width multiplier. |
120
+ | `seed` | `number` | derived | Explicit PRNG seed. Omit for a stable seed derived from the spec (renders identically every time). |
121
+ | `font` | `boolean` | `true` | Apply the handwriting font. Set `false` to keep the theme font. |
122
+
123
+ ```jsonc
124
+ // All defaults
125
+ { "type": "bar", "data": [/* … */], "sketch": true }
126
+
127
+ // Tuned: bolder cross-hatch fill
128
+ { "type": "pie", "data": [/* … */], "sketch": { "fillStyle": "cross-hatch", "roughness": 1.6 } }
129
+ ```
130
+
131
+ Rendering is **deterministic**: a given spec always produces the same drawing, so
132
+ sketch charts are safe to snapshot/screenshot‑test. Turning sketch off restores the
133
+ default crisp rendering path exactly (zero cost when unused — the font and engine are
134
+ code‑split and only loaded on the sketch path).
135
+
136
+ ---
137
+
138
+ ## Encoding & `FieldDef`
139
+
140
+ Cartesian charts (`line`, `area`, `bar`, `scatter`) and `pie`/`heatmap` map data
141
+ columns onto visual **channels** through `encoding`.
142
+
143
+ ```jsonc
144
+ "encoding": {
145
+ "x": { "field": "month", "type": "temporal" },
146
+ "y": { "field": "users", "type": "quantitative", "format": ",d" },
147
+ "series": { "field": "region" }
148
+ }
149
+ ```
150
+
151
+ ### Channels (`Encoding`)
152
+
153
+ | Channel | Used by | Purpose |
154
+ | --- | --- | --- |
155
+ | `x` | line, area, bar, scatter, heatmap | Horizontal position. |
156
+ | `y` | line, area, bar, scatter, heatmap | Vertical position. |
157
+ | `y2` | area | Upper bound for ranged/band marks. |
158
+ | `color` | heatmap, pie, choropleth | Continuous color (heatmap/choropleth) or slice color (pie). |
159
+ | `size` | scatter | Bubble radius. |
160
+ | `series` | line, area, bar, box | Splits data into multiple series (multi‑line, grouped/stacked bars, stacked areas, grouped boxes). |
161
+ | `theta` | pie | Angular measure (the slice value). |
162
+ | `source` | sankey | Link source node (one row per link). |
163
+ | `target` | sankey | Link target node. |
164
+ | `value` | sankey | Flow magnitude (ribbon/node thickness). |
165
+ | `key` | choropleth | Joins a data row to a map feature. |
166
+ | `label` | any | Text/label channel. |
167
+
168
+ ### `FieldDef`
169
+
170
+ | Field | Type | Notes |
171
+ | --- | --- | --- |
172
+ | `field` | `string` | **Required.** Column name. Dotted paths (`a.b`) read nested values. |
173
+ | `type` | `FieldType` | `quantitative \| temporal \| ordinal \| nominal`. Inferred from the data when omitted. |
174
+ | `aggregate` | `AggOp` | Aggregation when grouping (e.g. `sum` of `sales`). See [enums](#enumerations). |
175
+ | `title` | `string` | Axis/legend title override. |
176
+ | `format` | `string` | [Format hint](#format-mini-language) for labels/tooltips. |
177
+ | `scale` | `ScaleConfig` | Per‑channel scale overrides (see below). |
178
+
179
+ > **Temporal fields:** JSON has no `Date` type, so pass dates as ISO‑ish strings
180
+ > (`"2024-01-15"`, `"2024-01"`) or epoch milliseconds. Graphein parses them for time
181
+ > axes, and `%` date formats coerce date strings for display in tables.
182
+
183
+ ---
184
+
185
+ ## Scales
186
+
187
+ `FieldDef.scale` tunes how a channel maps data → pixels/color.
188
+
189
+ | Field | Type | Applies to | Notes |
190
+ | --- | --- | --- | --- |
191
+ | `type` | `'linear' \| 'log' \| 'time' \| 'band' \| 'point'` | x/y | Override the inferred scale type. |
192
+ | `domain` | `[number, number] \| string[]` | x/y/color | Explicit domain; numbers for continuous, strings for categorical. |
193
+ | `nice` | `boolean` | continuous | Round the domain to nice tick values. |
194
+ | `zero` | `boolean` | continuous | Force the domain to include 0. |
195
+ | `clamp` | `boolean` | continuous | Clamp out‑of‑domain values into range. |
196
+ | `padding` | `number` (0..1) | band/point | Inter‑category padding. |
197
+ | `base` | `number` | log | Log base. |
198
+ | `reverse` | `boolean` | any | Reverse the output range. |
199
+
200
+ ---
201
+
202
+ ## Transforms
203
+
204
+ `transform` is an ordered pipeline that reshapes the `data` array **inside the
205
+ spec**, before the chart model is built. It exists to kill the most common agent
206
+ mistake — *mis‑shaping the data array before charting*. Instead of pre‑aggregating
207
+ or pivoting rows in code, point a chart at raw rows and let a validatable transform
208
+ do the shaping. Encodings may reference columns the pipeline produces.
209
+
210
+ ```json
211
+ {
212
+ "type": "bar",
213
+ "data": [ { "region": "West", "sales": 10 }, { "region": "West", "sales": 5 }, { "region": "East", "sales": 8 } ],
214
+ "transform": [
215
+ { "filter": { "field": "sales", "gt": 0 } },
216
+ { "aggregate": [ { "op": "sum", "field": "sales", "as": "sales" } ], "groupby": ["region"] }
217
+ ],
218
+ "encoding": { "x": { "field": "region" }, "y": { "field": "sales" } }
219
+ }
220
+ ```
221
+
222
+ Steps run in array order. Each step carries **exactly one** operator key. The
223
+ pipeline is pure (it never mutates `data`) and is also exported standalone as
224
+ `applyTransforms(transforms, data)`. It runs **before** any selection cross‑filter.
225
+
226
+ | Operator | Shape | Notes |
227
+ | --- | --- | --- |
228
+ | `aggregate` | `{ aggregate: AggregateOp[], groupby?: string[] }` | Group rows and summarize. Omit `groupby` to collapse to one row. |
229
+ | `bin` | `{ bin: string, as: string \| [string,string], maxbins?, step?, extent?, nice? }` | Bucket a numeric field. `as` as a `[start,end]` pair captures both edges (drives the histogram). |
230
+ | `filter` | `{ filter: FilterPredicate }` | Keep rows matching a JSON predicate. |
231
+ | `fold` | `{ fold: string[], as?: [string,string] }` | Wide → long: gather columns into key/value rows (`as` defaults to `['key','value']`). |
232
+ | `timeUnit` | `{ timeUnit: TimeUnit, field: string, as: string }` | Truncate a timestamp to a calendar unit start (writes a `Date`). |
233
+ | `calculate` | `{ calculate: string, as: string }` | Derive a column from a safe expression (see [`calculate`](#calculate-expressions)). |
234
+
235
+ ### `AggregateOp`
236
+
237
+ | Field | Type | Notes |
238
+ | --- | --- | --- |
239
+ | `op` | `AggOp` | `sum \| mean \| avg \| min \| max \| count \| countDistinct \| median \| first \| last`. |
240
+ | `field` | `string` | Source column. Omit only for `count`. |
241
+ | `as` | `string` | Output column. |
242
+
243
+ ### `FilterPredicate`
244
+
245
+ A leaf predicate tests one `field`; `and` / `or` / `not` compose them. Comparisons
246
+ coerce numerically (numbers first, then dates), so temporal bounds work as ISO
247
+ strings.
248
+
249
+ - Leaf on a `field`: one of `equals`, `ne`, `oneOf`, `range: [lo,hi]`, `contains`,
250
+ `gt`, `gte`, `lt`, `lte`, or `valid: boolean` (drops null/NaN when `true`).
251
+ - Composite: `{ and: [...] }`, `{ or: [...] }`, `{ not: {...} }`.
252
+
253
+ ```json
254
+ { "filter": { "and": [
255
+ { "field": "year", "gte": 2020 },
256
+ { "field": "region", "oneOf": ["West", "East"] }
257
+ ] } }
258
+ ```
259
+
260
+ ### `TimeUnit`
261
+
262
+ `year · quarter · month · week · day · hour · minute · second`. Truncates to the
263
+ start of the unit so rows aggregate by period without manual date math:
264
+
265
+ ```json
266
+ { "timeUnit": "month", "field": "date", "as": "month" }
267
+ ```
268
+
269
+ ### `calculate` expressions
270
+
271
+ `{ "calculate": "<expr>", "as": "col" }` derives a column by evaluating `<expr>`
272
+ for each row. Bare identifiers reference columns; use `datum['my field']` for
273
+ names with spaces. The expression is parsed to an AST and evaluated with **no
274
+ `eval`/`Function`** and no access to globals — it is pure and deterministic.
275
+
276
+ ```json
277
+ { "calculate": "round(revenue / users, 2)", "as": "arpu" }
278
+ ```
279
+
280
+ Supported:
281
+
282
+ - **Operators:** `+ - * / %`, comparisons `< <= > >= == != === !==`, logical
283
+ `&& || !`, ternary `cond ? a : b`. `+` concatenates if either side is a string,
284
+ else adds numerically; comparisons coerce to numbers when both sides are numeric.
285
+ - **Literals:** numbers, `'single'`/`"double"` quoted strings, `true`, `false`, `null`.
286
+ - **Member access:** `datum.field` and `datum['field']` (prototype keys are blocked).
287
+ - **Functions:** `abs round floor ceil trunc sign sqrt exp log log10 log2 pow min
288
+ max number isFinite isNaN str lower upper trim length substring replace contains
289
+ startsWith endsWith concat coalesce if year month day hours minutes`. No
290
+ `now()`/`random()` — transforms stay deterministic.
291
+
292
+ > **Note:** `FieldDef.aggregate` (on `kpi`/`matrix`) still aggregates at encode
293
+ > time. For cartesian charts, prefer a `transform` `aggregate` step so there is one
294
+ > row per mark.
295
+
296
+ ---
297
+
298
+ ## Annotations (reference lines, bands, zones, points)
299
+
300
+ Cartesian charts (`line`, `area`, `bar`, `scatter`, `box`) accept an optional
301
+ `annotations: Annotation[]` — reference lines, shaded bands, threshold zones, and labeled
302
+ points drawn over the plot. They're declarative data (no callbacks) and ship as overlay
303
+ marks plus an HTML label layer, so an agent can call out a target, SLA, safe range, or a
304
+ specific data point in one field.
305
+
306
+ ```ts
307
+ {
308
+ type: 'line',
309
+ data: rows,
310
+ encoding: { x: { field: 'month', type: 'temporal' }, y: { field: 'latency' } },
311
+ annotations: [
312
+ { value: 200, label: 'SLA', color: '#ef4444' }, // horizontal rule on y
313
+ { type: 'zone', from: 0, to: 100, label: 'Healthy' }, // shaded threshold band
314
+ { axis: 'x', value: '2024-06', label: 'Launch' }, // vertical rule on x
315
+ { type: 'point', x: '2024-04', y: 210, label: 'Spike' }, // labeled marker dot
316
+ ],
317
+ }
318
+ ```
319
+
320
+ | Field | Type | Default | Notes |
321
+ | --- | --- | --- | --- |
322
+ | `type` | `'line' \| 'band' \| 'zone' \| 'point'` | inferred | Omit to infer: `line` when `value` is set, `band` when `from`/`to` are set. `zone` is a semantic alias for a threshold band; `point` marks a single `(x, y)` coordinate. |
323
+ | `axis` | `'x' \| 'y'` | `'y'` | `y` draws a horizontal line / full‑width band; `x` draws a vertical line / full‑height band. |
324
+ | `value` | `number \| string \| Date` | — | Reference value for a `line` (matches the chosen axis). |
325
+ | `from`, `to` | `number \| string \| Date` | — | Span extents for a `band`/`zone`. |
326
+ | `x`, `y` | `number \| string \| Date` | — | Data coordinates for a `point` callout (matching the x/y axes). |
327
+ | `markerRadius` | `number` | `3.5` | Dot radius in pixels for a `point`. |
328
+ | `label` | `string` | — | Short text drawn beside the annotation. |
329
+ | `color` | `string` | muted theme color | Stroke (line) / fill (band) / dot (point) color. |
330
+ | `strokeWidth` | `number` | `1.5` | Line width in pixels. |
331
+ | `strokeDash` | `number[]` | dashed | Dash pattern; `[]` is solid. |
332
+ | `fillOpacity` | `number` | `0.12` | Band/zone fill opacity (0..1). |
333
+ | `labelPosition` | `'start' \| 'middle' \| 'end'` | `'end'` | Where the label anchors along a line/band. |
334
+
335
+ Validation rules: a `line` needs `value`; a `band`/`zone` needs both `from` and `to`;
336
+ `value` and `from`/`to` are mutually exclusive; values must be scalars (number, string,
337
+ or date). Annotations on a non‑cartesian chart produce a warning (they're ignored).
338
+
339
+ ---
340
+
341
+ ## Self-explaining charts (summaries & auto-insights)
342
+
343
+ Graphein derives the analytical labeling and prose an agent would otherwise have to reason
344
+ out — deterministically, with no LLM. Two surfaces share one pure analysis core
345
+ ([`analyze/`](../packages/core/src/analyze)):
346
+
347
+ **`summarize(spec): string`** — a one-line natural-language summary of what the numbers say
348
+ (trend + net change, the largest/smallest category and its share, scatter correlation, a
349
+ value vs. its target). It doubles as alt-text and is attached to every render report:
350
+
351
+ ```ts
352
+ import { summarize, render } from 'graphein';
353
+
354
+ summarize(spec); // "Users rose 46% from 4,200 to 6,150 between 2024-01 and 2024-06, peaking at 6,400 in 2024-03."
355
+ render('#app', spec).report().summary; // same string, on the RenderReport
356
+ ```
357
+
358
+ The summary also feeds the chart's `aria-description` automatically (unless you set an
359
+ explicit `description`), so the chart explains itself to screen readers.
360
+
361
+ **`insights: boolean | InsightOptions`** — opt a cartesian chart (`line`, `area`, `bar`)
362
+ into automatic on-chart callouts. The library finds the notable points and draws labeled
363
+ `point` annotations, so an agent never hardcodes where the peak is:
364
+
365
+ ```ts
366
+ { type: 'line', data: rows, encoding: { /* … */ }, insights: true } // marks max ▲ + min ▼
367
+ { type: 'bar', data: rows, encoding: { /* … */ }, insights: { outliers: true } }
368
+ ```
369
+
370
+ | `InsightOptions` | Type | Default | Notes |
371
+ | --- | --- | --- | --- |
372
+ | `max` | `boolean` | `true` | Mark the maximum point (or top category for a `bar`). |
373
+ | `min` | `boolean` | `true` | Mark the minimum point (or bottom category). |
374
+ | `outliers` | `boolean` | `false` | Mark points beyond the 1.5×IQR Tukey fences. |
375
+
376
+ `insights: true` is shorthand for `{ max: true, min: true }`. Multi-series charts are
377
+ skipped (markers on every series would clutter the plot) — use `insights` on a single
378
+ series. Auto-insight annotations merge with any explicit `annotations` you provide.
379
+
380
+ ---
381
+
382
+ ## Trendlines (regression overlays)
383
+
384
+ `trendline: boolean | TrendlineConfig` — overlay a **linear line of best fit** on a
385
+ `scatter`, `line`, or `area` chart. The library computes the ordinary-least-squares
386
+ regression from the plotted rows, so an agent never derives slope/intercept coordinates by
387
+ hand — it just declares intent. A trendline needs a **continuous or temporal x-axis**;
388
+ asking for one on a categorical/band chart (e.g. `bar`) is a no-op warning.
389
+
390
+ ```ts
391
+ { type: 'scatter', data: rows, encoding: { /* … */ }, trendline: true } // one fit
392
+ { type: 'scatter', data: rows, encoding: { /* … */, color: { field: 'group' } },
393
+ trendline: { label: true } } // a fit per group + R²
394
+ ```
395
+
396
+ When the chart splits into multiple series (a `color`/`series` channel), Graphein fits one
397
+ line **per group**, colored to match that group's points; otherwise it fits a single
398
+ overall line. The fit is stroked across each group's observed x-extent and clipped to the
399
+ plot.
400
+
401
+ | `TrendlineConfig` | Type | Default | Notes |
402
+ | --- | --- | --- | --- |
403
+ | `method` | `'linear'` | `'linear'` | Fit method (ordinary least squares). |
404
+ | `groupBy` | `boolean` | auto | Fit per series group. Defaults `true` when the chart has multiple series, else one overall fit. |
405
+ | `label` | `boolean` | `false` | Draw an `R²=…` label at the end of each line. |
406
+ | `color` | `string` | series color | Override the line color. |
407
+ | `strokeWidth` | `number` | `2` | Line width in px. |
408
+ | `strokeDash` | `number[]` | `[]` (solid) | Dash pattern. |
409
+
410
+ `trendline: true` is shorthand for `{ method: 'linear' }`. The overlay is purely derived —
411
+ no rows are added to your data, keeping the spec declarative and validatable.
412
+
413
+ ---
414
+
415
+ ## Faceting (small multiples)
416
+
417
+ `facet: FacetConfig` — split a chart into a **trellis grid of panels**, one per distinct
418
+ value of a field, all sharing **identical x/y/color scales** so the panels are directly
419
+ comparable. A single field reference yields a whole comparison grid, which is why faceting
420
+ is so agent-friendly. Supported on `line`, `area`, `bar`, and `scatter`.
421
+
422
+ ```ts
423
+ { type: 'line', data: rows,
424
+ encoding: { x: { field: 'month' }, y: { field: 'sales' } },
425
+ facet: { field: 'region', columns: 2 } } // one panel per region, 2 across
426
+ ```
427
+
428
+ | `FacetConfig` | Type | Default | Notes |
429
+ | --- | --- | --- | --- |
430
+ | `field` | `string` | — (required) | The field whose distinct values become one panel each. |
431
+ | `columns` | `number` | ≈ √n | Number of grid columns (capped for readability). |
432
+ | `sort` | `'ascending' \| 'descending' \| 'none'` | `'ascending'` | Order the panels by their facet value. |
433
+
434
+ Every panel reuses **one shared set of scales** derived from the full dataset: the y-domain
435
+ spans the global extent (not each panel's local max), the x categories/domain are the union
436
+ across panels, and a series keeps its color in every panel — even when a panel's subset is
437
+ missing some categories or series. Multi-series facets render a single **shared legend** in
438
+ the header. Faceting is static (no per-panel interaction) in this release, and renders
439
+ identically headless via [`@graphein/node`](#render-report).
440
+
441
+ ---
442
+
443
+ ## Chart types
444
+
445
+ ### line
446
+
447
+ Time/continuous series with optional multi‑series, markers, and area fill.
448
+ Large series are automatically [decimated (LTTB)](#performance) for fast redraws.
449
+
450
+ | Field | Type | Notes |
451
+ | --- | --- | --- |
452
+ | `encoding` | requires `x`, `y`; optional `series` | Multi‑series via `series`. |
453
+ | `curve` | `CurveType` | `linear \| monotone \| step \| stepBefore \| stepAfter \| catmullRom`. |
454
+ | `points` | `boolean` | Draw point markers. |
455
+ | `area` | `boolean` | Fill under the line. |
456
+ | `trendline` | `boolean \| TrendlineConfig` | Overlay a linear [line of best fit](#trendlines-regression-overlays). |
457
+ | `facet` | `FacetConfig` | Split into a [trellis grid of small multiples](#faceting-small-multiples). |
458
+
459
+ → [`examples/line.json`](./examples/line.json)
460
+
461
+ ### area
462
+
463
+ Filled series; stack multiple series into a band chart.
464
+
465
+ | Field | Type | Notes |
466
+ | --- | --- | --- |
467
+ | `encoding` | requires `x`, `y`; optional `series` | — |
468
+ | `curve` | `CurveType` | Same options as `line`. |
469
+ | `stack` | `boolean` | Stack series (totals). Non‑stacked areas overlap with translucency. |
470
+ | `trendline` | `boolean \| TrendlineConfig` | Overlay a linear [line of best fit](#trendlines-regression-overlays). |
471
+ | `facet` | `FacetConfig` | Split into a [trellis grid of small multiples](#faceting-small-multiples). |
472
+
473
+ → [`examples/area-stacked.json`](./examples/area-stacked.json)
474
+
475
+ ### bar
476
+
477
+ Columns/bars with grouped or stacked series and rounded corners.
478
+
479
+ | Field | Type | Notes |
480
+ | --- | --- | --- |
481
+ | `encoding` | requires `x`, `y`; optional `series` | — |
482
+ | `orientation` | `'vertical' \| 'horizontal'` | Default `vertical`. |
483
+ | `stack` | `boolean` | Stack series. |
484
+ | `group` | `boolean` | Side‑by‑side groups. Default when `series` is present and not stacked. |
485
+ | `cornerRadius` | `number` | Bar corner radius in px. |
486
+ | `facet` | `FacetConfig` | Split into a [trellis grid of small multiples](#faceting-small-multiples). |
487
+
488
+ → [`examples/bar-grouped.json`](./examples/bar-grouped.json)
489
+
490
+ ### scatter
491
+
492
+ Points/bubbles with optional size and color grouping. Hover focuses the nearest point.
493
+
494
+ | Field | Type | Notes |
495
+ | --- | --- | --- |
496
+ | `encoding` | requires `x`, `y`; optional `size`, `series` | `size` drives bubble radius; `series` colors groups. |
497
+ | `trendline` | `boolean \| TrendlineConfig` | Overlay a linear [line of best fit](#trendlines-regression-overlays) (per group when `series`/`color` is set). |
498
+ | `facet` | `FacetConfig` | Split into a [trellis grid of small multiples](#faceting-small-multiples). |
499
+
500
+ → [`examples/scatter.json`](./examples/scatter.json)
501
+
502
+ ### combo
503
+
504
+ Dual-axis / layered cartesian chart — the canonical BI **bar + line**. Each entry in
505
+ `layers` is a mark (`bar`/`line`/`area`/`scatter`) plotting its own `y` measure over the
506
+ shared `encoding.x`. A layer can read against the primary (`left`) or a secondary
507
+ (`right`) y-axis, each with an independent scale. Multiple `bar` layers group side-by-side;
508
+ line/area/point layers align to category centres. The legend shows one entry per layer.
509
+
510
+ | Field | Type | Notes |
511
+ | --- | --- | --- |
512
+ | `encoding` | requires `x` | The shared category/time axis. Bars force a categorical x. |
513
+ | `layers` | `ComboLayer[]` (≥1) | One mark + measure per layer (see below). |
514
+
515
+ **`ComboLayer`**
516
+
517
+ | Field | Type | Notes |
518
+ | --- | --- | --- |
519
+ | `mark` | `'line' \| 'bar' \| 'area' \| 'scatter'` | The mark drawn for this layer. |
520
+ | `encoding` | requires `y` | The measure (a `FieldDef`, with optional per-layer `format`) plotted on `y`. |
521
+ | `axis` | `'left' \| 'right'` | Which y-axis to measure against (default `left`). Add a `right` layer for dual-axis. |
522
+ | `curve` | `CurveType` | Interpolation for `line`/`area` layers. |
523
+ | `points` | `boolean` | Show point markers (line/area). |
524
+ | `area` | `boolean` | Fill under a `line` layer. |
525
+ | `cornerRadius` | `number` | Bar corner radius. |
526
+ | `name` | `string` | Legend label (defaults to the y field's title/name). |
527
+ | `color` | `string` | Override the layer colour (else the theme palette). |
528
+
529
+ > Dual-axis charts can imply correlations that aren't real — the linter emits an
530
+ > advisory `combo-dual-axis` (info) when both axes are used. Reserve a secondary axis for
531
+ > genuinely different units, and label both axes.
532
+
533
+ → [`examples/combo-dual-axis.json`](./examples/combo-dual-axis.json)
534
+
535
+ ### histogram
536
+
537
+ Distribution of a single quantitative field. Binning happens **inside** the chart
538
+ (reusing the `bin` transform), so you pass raw observations — no manual pre-binning. Bars
539
+ are gapless on a continuous x-axis; height is the per-bin count, or a probability density
540
+ when `density:true`.
541
+
542
+ | Field | Type | Notes |
543
+ | --- | --- | --- |
544
+ | `encoding` | requires `x` | `x` is the quantitative field to bin (carries the axis title/format). |
545
+ | `bin` | `HistogramBin` | `{ maxbins?, step?, extent?, nice? }` — default ~10 nice bins. |
546
+ | `density` | `boolean` | Normalize heights to a probability density (area sums to 1). Default `false` (raw counts). |
547
+ | `color` | `string` | Bar colour (defaults to the first theme palette colour). |
548
+ | `cornerRadius` | `number` | Bar corner radius. |
549
+
550
+ **`HistogramBin`**
551
+
552
+ | Field | Type | Notes |
553
+ | --- | --- | --- |
554
+ | `maxbins` | `number` | Target bin count (approximate — a "nice" step is chosen). Default `10`. |
555
+ | `step` | `number` | Explicit bin width (overrides `maxbins`). |
556
+ | `extent` | `[number, number]` | Restrict binning to `[min, max]`; values outside are dropped. |
557
+ | `nice` | `boolean` | Snap bin edges to round numbers (default `true`). |
558
+
559
+ → [`examples/histogram.json`](./examples/histogram.json)
560
+
561
+ ### pie
562
+
563
+ Pie or donut with value/percent labels and slice‑lift hover.
564
+
565
+ | Field | Type | Notes |
566
+ | --- | --- | --- |
567
+ | `encoding` | requires `theta`, `color` | `theta` = value, `color` = slice category. |
568
+ | `donut` | `boolean \| number` | `true` for a default donut, or a `0..1` inner‑radius ratio. |
569
+ | `labels` | `boolean \| PieLabels` | `true`/`false` toggles labels; pass a `PieLabels` object for callout control (default `true` ⇒ auto). |
570
+
571
+ **`PieLabels`** — auto inside/outside callouts with leader lines:
572
+
573
+ | Field | Type | Notes |
574
+ | --- | --- | --- |
575
+ | `show` | `boolean` | Master toggle (default `true`). |
576
+ | `placement` | `'inside' \| 'outside' \| 'auto'` | `auto` (default) keeps a label inside when the text fits, otherwise an outside callout with a leader line. |
577
+ | `content` | `'percent' \| 'value' \| 'category' \| 'category-percent' \| 'category-value'` | What each label says (default: `percent` inside, `category-percent` for outside callouts). |
578
+ | `minShare` | `number` | Hide labels for slices below this share of the total, `0..1` (default `0.01`). |
579
+ | `connector` | `'slice' \| 'muted'` | Leader-line colour for outside callouts (default `slice`). |
580
+
581
+ → [`examples/pie-donut.json`](./examples/pie-donut.json) · [`examples/donut-callouts.json`](./examples/donut-callouts.json)
582
+
583
+ ### heatmap
584
+
585
+ Dense category × category grid colored by a measure.
586
+
587
+ | Field | Type | Notes |
588
+ | --- | --- | --- |
589
+ | `encoding` | requires `x`, `y`, `color` | `x`/`y` are categories; `color` is the numeric measure. |
590
+ | `scheme` | `string` | Sequential ramp: `blues`, `teal`, `viridis`, `magma`, `greys`. |
591
+
592
+ → [`examples/heatmap.json`](./examples/heatmap.json)
593
+
594
+ ### kpi
595
+
596
+ A single stat card: big value, label, delta indicator, and inline sparkline.
597
+
598
+ | Field | Type | Notes |
599
+ | --- | --- | --- |
600
+ | `value` | `number \| { field, aggregate? }` | A literal, or a field aggregated over `data`. |
601
+ | `label` | `string` | Caption under/above the value. |
602
+ | `delta` | `number \| { field, aggregate? }` | Drives the up/down indicator (e.g. `0.124` → +12.4%). |
603
+ | `format` | `string` | [Format hint](#format-mini-language) for the value. |
604
+ | `sparkline` | `boolean \| { field }` | Inline trend from a numeric field. |
605
+
606
+ → [`examples/kpi.json`](./examples/kpi.json)
607
+
608
+ ### table
609
+
610
+ Virtualized, sortable data table with per‑column formatting, alignment, and
611
+ [conditional formatting](#conditional-formatting). Handles large row counts via
612
+ windowing.
613
+
614
+ | Field | Type | Notes |
615
+ | --- | --- | --- |
616
+ | `columns` | `TableColumn[]` | Explicit columns; inferred from `data` keys when omitted. |
617
+ | `sort` | `{ field, order? }` | `order`: `asc \| desc`. |
618
+ | `density` | `'comfortable' \| 'standard' \| 'compact'` | Row/header spacing preset. |
619
+ | `totals` | `boolean \| { label? }` | Adds a sticky footer row; measure columns default to `sum`. |
620
+ | `striped` | `boolean` | Zebra striping (off by default — flat aesthetic). |
621
+ | `stickyHeader` | `boolean` | Sticky header (default `true`). |
622
+
623
+ **`TableColumn`**
624
+
625
+ | Field | Type | Notes |
626
+ | --- | --- | --- |
627
+ | `field` | `string` | **Required.** Column key. |
628
+ | `title` | `string` | Header label (defaults to `field`). |
629
+ | `type` | `FieldType` | Affects default alignment/formatting. |
630
+ | `format` | `string` | [Format hint](#format-mini-language). |
631
+ | `align` | `'left' \| 'center' \| 'right'` | Cell/text alignment. |
632
+ | `width` | `number` | Fixed column width in px. |
633
+ | `conditionalFormat` | `ConditionalFormat` | In‑cell bar, color scale, icons, or rules. |
634
+ | `prefix` / `suffix` | `string` | Display text around formatted numeric values (for example `$`, `%`). |
635
+ | `negativeStyle` | `'sign' \| 'parens' \| 'red' \| 'parens-red'` | Negative number display. |
636
+ | `hidden` | `boolean` | Drops the column from rendering. |
637
+ | `sortable` | `boolean` | Set `false` to remove the sort button for that column. |
638
+ | `wrap` | `boolean` | Allows multi-line cell text. |
639
+ | `group` | `string` | Adds a top header band spanning consecutive columns with the same group. |
640
+ | `total` | `AggOp \| false` | Footer aggregation when `totals` is enabled. |
641
+
642
+ → [`examples/table.json`](./examples/table.json)
643
+
644
+ ### matrix
645
+
646
+ Pivot/cross‑tab: hierarchical row & column groups, aggregated measures, and
647
+ subtotals/grand totals — rendered through the table engine.
648
+
649
+ | Field | Type | Notes |
650
+ | --- | --- | --- |
651
+ | `rows` | `string[]` | **Required.** Row grouping fields (hierarchical, outer→inner). |
652
+ | `columns` | `string[]` | Column grouping fields (hierarchical). |
653
+ | `values` | `MatrixValueDef[]` | **Required.** Measures to aggregate. |
654
+ | `subtotals` | `boolean` | Group subtotals. |
655
+ | `grandTotals` | `boolean` | Overall totals row/column. |
656
+ | `density` | `'comfortable' \| 'standard' \| 'compact'` | Row/header spacing preset. |
657
+ | `columnSort` | `{ by:'value'\|'label', valueIndex?, order? }` | Sort leaf columns by label or aggregated measure. |
658
+
659
+ **`MatrixValueDef`**
660
+
661
+ | Field | Type | Notes |
662
+ | --- | --- | --- |
663
+ | `field` | `string` | **Required.** Measure column. |
664
+ | `op` | `AggOp` | **Required.** Aggregation (`sum`, `mean`, `count`, …). |
665
+ | `label` | `string` | Header label for the measure. |
666
+ | `format` | `string` | [Format hint](#format-mini-language). |
667
+ | `conditionalFormat` | `ConditionalFormat` | Per‑cell formatting. |
668
+ | `prefix` / `suffix` | `string` | Display text around formatted values. |
669
+ | `negativeStyle` | `TableColumn['negativeStyle']` | Negative number display. |
670
+ | `showAs` | `'value' \| 'percentOfRow' \| 'percentOfColumn' \| 'percentOfTotal'` | Display cell shares as percentages. Denominators are computed from leaf cells even when subtotals/grand totals are off. |
671
+
672
+ → [`examples/matrix.json`](./examples/matrix.json)
673
+
674
+ ### box
675
+
676
+ Box‑and‑whisker distributions: one box per category (and per `series`) showing
677
+ quartiles, median, whiskers, and outliers.
678
+
679
+ | Field | Type | Notes |
680
+ | --- | --- | --- |
681
+ | `encoding` | requires `x`, `y`; optional `series` | `x` is the category; `y` holds the **raw observations** (many rows per category) — Graphein computes the quartiles. `series` draws grouped boxes side‑by‑side. |
682
+ | `whisker` | `'tukey' \| 'minMax'` | `tukey` (default): whiskers reach the furthest points within 1.5×IQR of the quartiles; points beyond are outliers. `minMax`: whiskers span the full range (no outliers). |
683
+ | `outliers` | `boolean` | Draw outlier points beyond the whiskers (tukey only; default `true`). |
684
+
685
+ → [`examples/box.json`](./examples/box.json)
686
+
687
+ ### funnel
688
+
689
+ Conversion funnel: tapering stages stacked top‑to‑bottom, each labeled with its value
690
+ and the share retained. Values are **aggregated by stage** (in first‑seen order), so you
691
+ can pass raw rows.
692
+
693
+ | Field | Type | Notes |
694
+ | --- | --- | --- |
695
+ | `encoding` | requires `stage`, `value` | `stage` = the step (ordered as first seen); `value` = the measure (**summed** per stage). |
696
+ | `labels` | `boolean` | Show stage name, value, and percent inside the funnel (default `true`). |
697
+ | `percent` | `'first' \| 'previous'` | Per‑stage percentage basis: `first` (default) = share retained vs. the top of the funnel; `previous` = step conversion vs. the stage above. |
698
+
699
+ → [`examples/funnel.json`](./examples/funnel.json)
700
+
701
+ ### sankey
702
+
703
+ Flow diagram: nodes linked by value‑weighted ribbons. Each data row is one
704
+ **link**; nodes are derived from the distinct `source`/`target` values and laid
705
+ out in layers by longest path, with ribbons colored by their source.
706
+
707
+ | Field | Type | Notes |
708
+ | --- | --- | --- |
709
+ | `encoding` | requires `source`, `target`, `value` | One row per link. `value` sets ribbon/node thickness. |
710
+ | `nodeWidth` | `number` | Node block width in px (default `16`). |
711
+ | `nodePadding` | `number` | Vertical gap between stacked nodes in px (default `14`). |
712
+ | `nodeValues` | `boolean` | Show each node's total beside its label (default `true`). |
713
+
714
+ → [`examples/sankey.json`](./examples/sankey.json)
715
+
716
+ ### choropleth
717
+
718
+ Thematic map: GeoJSON regions filled by a sequential color scale, with a value
719
+ legend and per‑region hover. Data rows join to features by a key.
720
+
721
+ | Field | Type | Notes |
722
+ | --- | --- | --- |
723
+ | `geo` | `GeoFeatureCollection` | **Required.** GeoJSON `FeatureCollection` of `Polygon`/`MultiPolygon` features. |
724
+ | `encoding` | requires `key`, `color` | `key` joins each row to a feature; `color` is the numeric value driving the fill. |
725
+ | `featureId` | `string` | Where to read a feature's join id: a property name (e.g. `'name'` → `feature.properties.name`), or `'id'` for the top‑level `feature.id`. Defaults to `feature.id` → `properties.id` → `properties.name`. |
726
+ | `projection` | `MapProjection` | `mercator` (default), `equirectangular`, or `identity` (coordinates are already planar `[x, y]`). |
727
+ | `scheme` | `string` | Sequential ramp: `blues`, `teal`, `viridis`, `magma`, `greys`. |
728
+
729
+ The map auto‑fits its container. Antimeridian‑crossing geometry (e.g. Alaska's
730
+ Aleutians) is handled by choosing a central meridian from the widest longitude
731
+ gap. For composite layouts like Alaska/Hawaii insets, pre‑project the geometry to
732
+ planar coordinates and use `projection: 'identity'`.
733
+
734
+ → [`examples/choropleth.json`](./examples/choropleth.json)
735
+
736
+ ### treemap
737
+
738
+ Part‑to‑whole as nested rectangles sized by a measure. A **squarified** layout keeps
739
+ tiles close to square so areas stay visually comparable; input order is preserved for
740
+ deterministic output. An optional `group` field nests leaves under one level of parent
741
+ tiles, each with a header label.
742
+
743
+ | Field | Type | Notes |
744
+ | --- | --- | --- |
745
+ | `encoding` | requires `category`, `value` | `category` labels/identifies each leaf tile; `value` is the numeric field sizing its area (summed per leaf). |
746
+ | `encoding.group` | `FieldDef` | Optional parent grouping → nested parent tiles (one level deep), each with a header label. |
747
+ | `encoding.color` | `FieldDef` | Optional field driving tile color — numeric ⇒ sequential `scheme`, otherwise the categorical palette. Without it, tiles color by group (else category). |
748
+ | `scheme` | `string` | Sequential ramp name when `color` is numeric (default `teal`). |
749
+ | `labels` | `boolean` | Show the value beneath each tile label (default `true`). |
750
+
751
+ → [`examples/treemap.json`](./examples/treemap.json)
752
+
753
+ ### gauge
754
+
755
+ A radial dial showing one value against a `[min, max]` scale, with an optional `target`
756
+ tick and qualitative background `bands`. Like [`kpi`](#kpi), the value is a literal
757
+ **or** a field (optionally aggregated over `data`). Renders to canvas (headless‑safe).
758
+
759
+ | Field | Type | Notes |
760
+ | --- | --- | --- |
761
+ | `value` | `ValueRef` | The measured value: a literal number, or `{ field, aggregate? }` summarized over `data`. |
762
+ | `max` | `number` | **Required.** Scale end (full‑scale). |
763
+ | `min` | `number` | Scale start (default `0`). |
764
+ | `target` | `ValueRef` | Optional threshold drawn as a needle/tick. |
765
+ | `bands` | `{ to: number, color? }[]` | Qualitative arc bands, each filling the scale up to `to`. |
766
+ | `label` | `string` | Caption under the value (defaults to the title or value field). |
767
+ | `format` | `string` | Number format for the value + scale ticks (e.g. `,.0f`, `.0%`). |
768
+
769
+ → [`examples/gauge.json`](./examples/gauge.json)
770
+
771
+ ### bullet
772
+
773
+ A compact linear KPI‑vs‑target: a measure bar over qualitative `ranges` (poor/ok/good
774
+ background bands) with a `target` comparative tick. Value and target are literals or
775
+ fields (optionally aggregated) — ideal as a dashboard tile. Renders to canvas
776
+ (headless‑safe).
777
+
778
+ | Field | Type | Notes |
779
+ | --- | --- | --- |
780
+ | `value` | `ValueRef` | The featured measure (literal or `{ field, aggregate? }`). |
781
+ | `target` | `ValueRef` | Comparative marker drawn as a vertical tick. |
782
+ | `ranges` | `number[]` | Qualitative range boundaries on the scale (e.g. `[600000, 800000, 1000000]`). |
783
+ | `min` / `max` | `number` | Scale bounds (`min` default `0`; `max` derived from value/target/ranges when omitted). |
784
+ | `label` | `string` | Caption to the left of the bar (defaults to the title or value field). |
785
+ | `format` | `string` | Number format for the value + axis ticks. |
786
+
787
+ → [`examples/bullet.json`](./examples/bullet.json)
788
+
789
+ ### calendarHeatmap
790
+
791
+ A GitHub‑contributions‑style grid: one cell per day colored by a value on a sequential
792
+ scale, with weekday rows and month labels. Pass tidy `{ date, value }` rows — dates may
793
+ be `Date` objects or ISO strings (both coerce for the `temporal` field).
794
+
795
+ | Field | Type | Notes |
796
+ | --- | --- | --- |
797
+ | `encoding` | requires `date`, `color` | `date` → one cell per day; `color` is the numeric value driving the cell fill. |
798
+ | `scheme` | `string` | Sequential ramp name for the value scale (default `teal`). |
799
+
800
+ → [`examples/calendar-heatmap.json`](./examples/calendar-heatmap.json)
801
+
802
+ ### waterfall
803
+
804
+ A cash‑flow / bridge chart: floating bars walk a running total across ordered `stage`s,
805
+ where each `value` is a **signed delta** (positive rises, negative falls). Mark a stage in
806
+ `totals` (or append one with `showTotal`) to draw an absolute bar from the baseline — it
807
+ shows the running total but does **not** advance it. Dashed connectors join each step to
808
+ the next. Renders to canvas (headless‑safe).
809
+
810
+ | Field | Type | Notes |
811
+ | --- | --- | --- |
812
+ | `encoding` | requires `stage`, `value` | `stage` → one bar per row (in data order); `value` is the **signed** change at that stage. |
813
+ | `totals` | `string[]` | Stage labels to draw as absolute running‑total bars from zero. |
814
+ | `showTotal` | `boolean` | Append a final bar summing every step (default `false`). |
815
+ | `totalLabel` | `string` | Label for the appended total bar (default `'Total'`). |
816
+ | `labels` | `boolean` | Show per‑bar value labels (default `true`). |
817
+ | `cornerRadius` | `number` | Bar corner radius in px (default `2`). |
818
+ | `increaseColor` / `decreaseColor` / `totalColor` | `string` | Override the up / down / total colors (default theme positive / negative / accent). |
819
+
820
+ → [`examples/waterfall.json`](./examples/waterfall.json)
821
+
822
+ ### slope
823
+
824
+ A slope graph — a minimal before/after chart: each `series` is a line joining its `y`
825
+ value across two (or a few) ordinal `x` positions, with **direct end labels** instead of a
826
+ legend, so change‑in‑rank and rise/fall read at a glance. Pass tidy `{ x, y, series }`
827
+ rows (one per series per x position). Renders to canvas (headless‑safe).
828
+
829
+ | Field | Type | Notes |
830
+ | --- | --- | --- |
831
+ | `encoding` | requires `x`, `y`, `series` | `x` → the ordinal positions (typically two); `y` is the numeric value; `series` is one line each. |
832
+ | `colorByChange` | `boolean` | Color each line green/red by its net rise/fall instead of by series. |
833
+ | `labels` | `boolean` | Direct labels (series name + value) at each line's ends (default `true`). |
834
+ | `format` | `string` | Number format for the value labels and y‑axis. |
835
+
836
+ → [`examples/slope.json`](./examples/slope.json)
837
+
838
+ ### dumbbell
839
+
840
+ A dumbbell / connected‑dot plot: for each `category`, a dot per `group` placed on a shared
841
+ **horizontal** value axis and joined by a connector, so the gap between groups (before vs.
842
+ after, male vs. female) reads directly. Categories run down the y‑axis. Pass tidy
843
+ `{ category, value, group }` rows. Renders to canvas (headless‑safe).
844
+
845
+ | Field | Type | Notes |
846
+ | --- | --- | --- |
847
+ | `encoding` | requires `category`, `value`, `group` | `category` → one band down the y‑axis; `value` → dot position on the x‑axis; `group` → one dot each (2+ levels), connected. |
848
+ | `sort` | `'ascending' \| 'descending' \| 'gap'` | Order the category rows (default: data order). `gap` sorts by dot spread. |
849
+ | `labels` | `boolean` | Value labels beside the dots (default `false`). |
850
+ | `format` | `string` | Number format for the value labels and x‑axis. |
851
+
852
+ → [`examples/dumbbell.json`](./examples/dumbbell.json)
853
+
854
+ ---
855
+
856
+ ## Slicers
857
+
858
+ Interactive controls that **publish a selection** instead of plotting marks. A slicer
859
+ reads one `field` and writes to a `param` (defaulting to the field name), so it
860
+ auto‑connects to any visual that filters/highlights on that param. Options and bounds
861
+ derive from the **unfiltered** data, so a slicer never hides its own choices. Slicers
862
+ render standalone (they're ordinary DOM widgets) and slot into a [dashboard](#dashboards)
863
+ like any other view. They share the [common slicer fields](#common-slicer-fields) below
864
+ plus their own.
865
+
866
+ #### Common slicer fields
867
+
868
+ | Field | Type | Default | Notes |
869
+ | --- | --- | --- | --- |
870
+ | `field` | `string` | **required** | Column the slicer reads options/bounds from and filters on. |
871
+ | `param` | `string` | `field` | Param name to publish to (the wire other visuals consume). |
872
+ | `label` | `string` | `title`/`field` | Label shown above the control. |
873
+ | `as` | `'filter' \| 'highlight'` | `'filter'` | How consumers react. |
874
+
875
+ Plus all [`BaseSpec`](#common-fields-basespec) chrome (`theme`, `title`, `sketch`, …).
876
+
877
+ ### dropdown
878
+
879
+ Choose one (single) or several (multi) of a field's distinct values. Emits a `set`.
880
+
881
+ | Field | Type | Notes |
882
+ | --- | --- | --- |
883
+ | `multiple` | `boolean` | Allow multiple values. Default `false` (single‑select). |
884
+ | `placeholder` | `string` | Shown when nothing is selected. |
885
+
886
+ ### search
887
+
888
+ A debounced, case‑insensitive substring filter over a text field. Emits a `text`
889
+ selection (`contains`).
890
+
891
+ | Field | Type | Notes |
892
+ | --- | --- | --- |
893
+ | `placeholder` | `string` | Input placeholder. |
894
+ | `debounce` | `number` | Ms before publishing the query (default `200`). |
895
+
896
+ ### list
897
+
898
+ A scrollable checkbox list of a field's distinct values (multi‑select). Emits a `set`.
899
+
900
+ | Field | Type | Notes |
901
+ | --- | --- | --- |
902
+ | `selectAll` | `boolean` | Show the "Select all" / "Clear" row (default `true`). |
903
+ | `searchThreshold` | `number` | Show a search‑within box once options exceed this (default `8`). |
904
+
905
+ ### range
906
+
907
+ A numeric min/max range over a quantitative field (dual‑thumb slider). Emits a `range`.
908
+
909
+ | Field | Type | Notes |
910
+ | --- | --- | --- |
911
+ | `min` / `max` | `number` | Bounds; default to the data min/max of `field`. |
912
+ | `step` | `number` | Thumb step; defaults to a fraction of the range. |
913
+ | `format` | `string` | Number [format hint](#format-mini-language) for value labels. |
914
+
915
+ ### dateRange
916
+
917
+ A temporal min/max range over a date field, with relative presets. Emits a `range`.
918
+
919
+ | Field | Type | Notes |
920
+ | --- | --- | --- |
921
+ | `presets` | `boolean` | Show relative presets (last 7/30/90 days, all). Default `true`. |
922
+ | `format` | `string` | Date [format hint](#format-mini-language) for value labels. |
923
+
924
+ → [`examples/slicer-dropdown.json`](./examples/slicer-dropdown.json)
925
+
926
+ ---
927
+
928
+ ## Interactivity (selection · highlight · filter)
929
+
930
+ The unit of interactivity is a **selection** — a named, JSON‑serializable value a
931
+ visual *publishes* and others *consume*. Selections are plain data (no callbacks), so
932
+ specs still round‑trip through `JSON.stringify`. Inspired by Vega‑Lite params, pared
933
+ down to point/interval definitions and four resolved value shapes.
934
+
935
+ **Publish — `params`.** A `SelectionParam` names a selection a visual writes when the
936
+ user clicks a mark, brushes, or changes a slicer:
937
+
938
+ ```jsonc
939
+ "params": [{
940
+ "name": "pick",
941
+ "select": {
942
+ "type": "point", // 'point' (discrete picks) | 'interval' (a range)
943
+ "on": "click", // 'click' (default) | 'hover'
944
+ "fields": ["region"], // identity fields; default = the chart's key channel
945
+ "toggle": true, // click to add/remove (default true for click)
946
+ "empty": "all" // empty selection ⇒ match all (default) | none
947
+ }
948
+ }]
949
+ ```
950
+
951
+ **Consume — `highlight` & `filter`.** `highlight: { param }` emphasizes rows matching a
952
+ param and dims the rest (per‑mark, ~22% dim alpha); an array unions several sources.
953
+ `filter` subsets rows to those matching **every** clause (logical AND). Each clause is a
954
+ named param or a literal predicate:
955
+
956
+ | Clause | Shape | Meaning |
957
+ | --- | --- | --- |
958
+ | param | `{ "param": "region" }` | Match the param's current value. |
959
+ | equals | `{ "field": "region", "equals": "West" }` | `field === value`. |
960
+ | oneOf | `{ "field": "region", "oneOf": ["West","East"] }` | Membership. |
961
+ | range | `{ "field": "sales", "range": [100, 500] }` | Inclusive numeric/temporal span. |
962
+ | contains | `{ "field": "product", "contains": "wid" }` | Case‑insensitive substring. |
963
+
964
+ **Resolved value shapes** (`SelectionValue` — what `getSelection` returns and
965
+ `setSelection` accepts):
966
+
967
+ | `kind` | Shape | Emitted by |
968
+ | --- | --- | --- |
969
+ | `point` | `{ kind, fields, tuples }` | clicking marks |
970
+ | `set` | `{ kind, field, values }` | `dropdown`, `list` |
971
+ | `range` | `{ kind, field, min?, max? }` | `range`, `dateRange`, brushing |
972
+ | `text` | `{ kind, field, query }` | `search` |
973
+
974
+ To link **independently rendered** charts, pass a shared store:
975
+
976
+ ```ts
977
+ import { render, createSelectionStore } from 'graphein';
978
+ const store = createSelectionStore();
979
+ render('#a', specA, { store }); // a publishes `params`
980
+ render('#b', specB, { store }); // b consumes via `highlight`/`filter`
981
+ ```
982
+
983
+ ---
984
+
985
+ ## Dashboards
986
+
987
+ A `dashboard` is the single‑JSON, agent‑facing layer that composes charts and slicers
988
+ into one cross‑interacting page. It owns a shared dataset and selection store, lays
989
+ views out on a responsive grid, and **auto‑wires** cross‑interaction. Validated by
990
+ `validateSpec`; rendered with `renderDashboard`.
991
+
992
+ | Field | Type | Default | Notes |
993
+ | --- | --- | --- | --- |
994
+ | `type` | `'dashboard'` | — | Discriminator. |
995
+ | `data` | `Datum[]` | — | Shared dataset; views without their own `data` inherit it. |
996
+ | `views` | `DashboardView[]` | **required** | The placed charts/slicers. |
997
+ | `layout` | `DashboardLayout` | see below | Grid sizing + responsive behavior. |
998
+ | `interactions` | `'auto' \| 'none' \| InteractionLink[]` | `'auto'` | Cross‑interaction policy. |
999
+ | `params` | `SelectionParam[]` | — | Dashboard‑level selections (e.g. seeded initial values). |
1000
+ | `subtitle` | `string` | — | Muted line shown under the title in the page header. |
1001
+ | `theme` `title` `background` `dimensions` | — | — | Page chrome; `theme` cascades to every view. |
1002
+
1003
+ **`DashboardLayout`** — `{ cols?, rowHeight?, gap?, breakpoints?, navigators?, sections?, preset?, maxWidth?, density?, padding? }`:
1004
+
1005
+ | Field | Type | Default | Notes |
1006
+ | --- | --- | --- | --- |
1007
+ | `cols` | `number` | `12` | Grid columns at full width. |
1008
+ | `rowHeight` | `number` | `96` | Height of one grid row (px). |
1009
+ | `gap` | `number` | `14` | Gap between cells (px). |
1010
+ | `breakpoints` | `{ maxWidth, cols }[]` | `[{600,1},{960,6}]` | Responsive column counts: when narrower than a breakpoint's `maxWidth`, the grid switches to its `cols` and tiles reflow (DataZen‑style). Smallest match wins. |
1011
+ | `navigators` | `'top' \| 'inline'` | `'top'` | `top`: compact slicers (`dropdown`/`search`/`range`/`dateRange`) form a navigator strip above the grid (a BI filter bar); `inline`: every slicer is placed in the grid like a chart. |
1012
+ | `sections` | `DashboardSection[]` | — | Stack multiple section grids with header bands; views omitted from all sections render in an implicit trailing section. |
1013
+ | `preset` | `'auto' \| 'kpi-first' \| 'sidebar'` | `'auto'` | Auto-arrange unplaced views. Explicit `x`/`y`/`w`/`h` placement always wins. |
1014
+ | `maxWidth` | `number` | — | Constrain and center the dashboard page. |
1015
+ | `density` | `'compact' \| 'standard' \| 'comfortable'` | `'standard'` | Applies spacing/row-height presets before explicit `gap`/`rowHeight` overrides. |
1016
+ | `padding` | `number` | derived from `gap` | Page padding in px. |
1017
+
1018
+ **`DashboardSection`**:
1019
+
1020
+ | Field | Type | Default | Notes |
1021
+ | --- | --- | --- | --- |
1022
+ | `id` | `string` | — | Optional stable id for the section. |
1023
+ | `title` / `subtitle` | `string` | — | Header band copy above the section grid. |
1024
+ | `views` | `string[]` | **required** | View ids in this section; each id may appear in only one section. |
1025
+ | `cols` | `number` | layout `cols` | Section-specific columns. |
1026
+ | `rowHeight` | `number` | layout `rowHeight` | Section-specific row height. |
1027
+ | `background` | `string` | transparent | Header band tint. |
1028
+ | `collapsed` | `boolean` | `false` | Start with the body hidden behind a clickable header. |
1029
+
1030
+ **`DashboardView`** — `{ id, spec, x?, y?, w?, h?, title?, subtitle?, frame?, background?, accent?, padding?, responsive? }`.
1031
+ `id` is unique within the dashboard (used for layout + link references). `spec` is any
1032
+ chart or slicer spec (inherits the dashboard's `data` when it has none). `x`/`y` are
1033
+ 1‑based grid placement (omit to auto‑flow); `w`/`h` are column/row spans (sensible
1034
+ per‑type defaults).
1035
+
1036
+ | Field | Type | Default | Notes |
1037
+ | --- | --- | --- | --- |
1038
+ | `title` / `subtitle` | `string` | — | Card header drawn by the dashboard. When present, Graphein suppresses the inner chart `title` to avoid duplicate headings. |
1039
+ | `frame` | `boolean` | `true` | Draw the framed card chrome. Set `false` for frameless tiles. |
1040
+ | `background` | `string` | theme surface | Card background override. |
1041
+ | `accent` | `string` | — | Solid left accent bar color. |
1042
+ | `padding` | `'none' \| 'standard'` | `'standard'` | Use `'none'` for flush tables/maps. |
1043
+ | `responsive` | `{ maxWidth, w?, h?, hidden? }[]` | — | Per-view span overrides at section/dashboard widths; smallest matching `maxWidth` wins. |
1044
+
1045
+ **`interactions: 'auto'`** (Power BI semantics):
1046
+
1047
+ - **Slicers filter the whole page** — every non‑slicer view whose data contains the
1048
+ field is subset by the slicer (a KPI, a table, a chart that inherits the dashboard
1049
+ data). A filter on a column a view's data doesn't contain is **ignored for that view**,
1050
+ not blanked — so a pre‑aggregated view only reacts to the dimensions it carries.
1051
+ - **Chart clicks cross‑highlight** — clicking a mark emphasizes the matching subset in
1052
+ views that encode the same field, and always self‑highlights.
1053
+
1054
+ **Explicit links** replace auto‑wiring with an array of `InteractionLink`:
1055
+
1056
+ | Field | Type | Notes |
1057
+ | --- | --- | --- |
1058
+ | `source` | `string` | View id whose selection drives the interaction. |
1059
+ | `target` | `string \| string[] \| '*'` | Target view id(s), or `'*'` for every other view. |
1060
+ | `as` | `'highlight' \| 'filter' \| 'none'` | How targets react (defaults by source type). |
1061
+ | `fields` | `string[]` | Identity fields to match on; defaults to the source's key field(s). |
1062
+
1063
+ ```jsonc
1064
+ "interactions": [
1065
+ { "source": "region", "target": "*", "as": "filter" },
1066
+ { "source": "byRegion", "target": ["trend"], "as": "highlight", "fields": ["region"] }
1067
+ ]
1068
+ ```
1069
+
1070
+ `renderDashboard(target, spec, options?)` returns a **`DashboardInstance`**:
1071
+ `update(next)`, `resize()`, `destroy()`, `spec`, plus the
1072
+ [selection API](#runtime-api) (`getSelection` / `setSelection` / `clearSelection` /
1073
+ `on('selectionchange', …)` / `off`) and `views` (the wired view specs).
1074
+
1075
+ → [`examples/dashboard.json`](./examples/dashboard.json)
1076
+
1077
+ ---
1078
+
1079
+ ## Conditional formatting
1080
+
1081
+ Used by `table` columns and `matrix` values.
1082
+
1083
+ ```jsonc
1084
+ // In‑cell horizontal bar sized by value; zero-baseline bars diverge for negatives
1085
+ { "type": "bar", "color": "#0d9488", "negativeColor": "#dc2626", "baseline": "zero" }
1086
+
1087
+ // Background or text color scale
1088
+ { "type": "colorScale", "scheme": "teal", "domain": [0, 1], "target": "background" }
1089
+
1090
+ // Unicode icon set (no icon fonts)
1091
+ { "type": "icon", "set": "arrows", "position": "left" }
1092
+
1093
+ // First matching rule wins
1094
+ { "type": "rules", "rules": [{ "when": "lt", "value": 0, "color": "#dc2626", "weight": "bold" }] }
1095
+ ```
1096
+
1097
+ | Field | Type | Applies to | Notes |
1098
+ | --- | --- | --- | --- |
1099
+ | `type` | `'bar' \| 'colorScale' \| 'icon' \| 'rules'` | all | Selects the style. |
1100
+ | `domain` | `[number, number]` | `bar`, `colorScale` | Value range; inferred from the column when omitted. |
1101
+ | `color`, `negativeColor`, `baseline`, `showValue` | strings/boolean | `bar` | Bar fill, diverging negative fill, zero/min baseline, and value overlay toggle. |
1102
+ | `scheme`, `midpoint`, `diverging`, `target` | mixed | `colorScale` | Sequential/diverging ramp controls; `target:'text'` colors text instead of background. |
1103
+ | `set`, `rules`, `position` | mixed | `icon` | Built-ins: `arrows`, `triangles`, `dots`, `trafficLights`; rules override thresholds. |
1104
+ | `rules` | `ValueRule[]` | `rules` | `when`: `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `between`; `between` is inclusive and needs `to`. |
1105
+
1106
+ Icon glyphs are Unicode: arrows `▲ ▬ ▼`, triangles `▲ ▼`, dots/traffic lights `●` with green/amber/red tones.
1107
+
1108
+ ---
1109
+
1110
+ ## Themes
1111
+
1112
+ `theme` is either a built‑in name (`'light'` / `'dark'`) or a partial override
1113
+ with an optional `base`:
1114
+
1115
+ ```jsonc
1116
+ "theme": { "base": "dark", "color": { "accent": "#2dd4bf" } }
1117
+ ```
1118
+
1119
+ Overridable token groups: `color` (`background`, `surface`, `text`, `textMuted`,
1120
+ `axis`, `grid`, `border`, `accent`, `palette[]`, `positive`, `negative`),
1121
+ `font`, `spacing`, `radius`, `stroke`. The default categorical `palette` is a
1122
+ vibrant, accessibility‑tuned 10‑color set that reads well on light and dark.
1123
+
1124
+ ---
1125
+
1126
+ ## Format mini‑language
1127
+
1128
+ A small, dependency‑free subset of d3‑format (numbers) plus strftime‑style dates.
1129
+
1130
+ **Numbers** — grammar `[$][,][.precision][type]`:
1131
+
1132
+ | Hint | Input | Output |
1133
+ | --- | --- | --- |
1134
+ | `,d` | `1234567` | `1,234,567` |
1135
+ | `.1f` | `3.14159` | `3.1` |
1136
+ | `.0%` | `0.42` | `42%` |
1137
+ | `$,.0f` | `5230` | `$5,230` |
1138
+ | `.1s` | `1200` | `1.2k` |
1139
+ | `.2e` | `12300` | `1.23e+4` |
1140
+ | `.3g` | `12345` | `12300` |
1141
+
1142
+ **Dates** — a hint containing `%` is treated as a date pattern. Tokens:
1143
+ `%Y` (2024), `%y` (24), `%m` (01), `%d` (01), `%e` (1), `%B`/`%b` (January/Jan),
1144
+ `%A`/`%a` (Monday/Mon), `%H` (00‑23), `%I` (01‑12), `%M`, `%S`, `%L` (ms),
1145
+ `%p` (AM/PM), `%j` (day of year), `%%` (literal `%`). Example: `%b %e, %Y` →
1146
+ `Jan 2, 2024`.
1147
+
1148
+ ---
1149
+
1150
+ ## Enumerations
1151
+
1152
+ | Name | Values |
1153
+ | --- | --- |
1154
+ | `ChartType` | `line`, `area`, `bar`, `scatter`, `pie`, `funnel`, `heatmap`, `kpi`, `table`, `matrix`, `box`, `sankey`, `choropleth`, `dropdown`, `search`, `list`, `range`, `dateRange` |
1155
+ | `SlicerType` | `dropdown`, `search`, `list`, `range`, `dateRange` |
1156
+ | `SelectionKind` | `point`, `set`, `range`, `text` |
1157
+ | `FieldType` | `quantitative`, `temporal`, `ordinal`, `nominal` |
1158
+ | `AggOp` | `sum`, `mean`, `avg`, `min`, `max`, `count`, `countDistinct`, `median`, `first`, `last` |
1159
+ | `CurveType` | `linear`, `monotone`, `step`, `stepBefore`, `stepAfter`, `catmullRom` |
1160
+ | `MapProjection` | `mercator`, `equirectangular`, `identity` |
1161
+ | Sequential schemes | `blues`, `teal`, `viridis`, `magma`, `greys` |
1162
+ | Diverging schemes | `redBlue`, `spectral`, `blueRed` |
1163
+ | `LegendPosition` | `top`, `right`, `bottom`, `left` |
1164
+
1165
+ ---
1166
+
1167
+ ## Runtime API
1168
+
1169
+ ```ts
1170
+ import { render } from 'graphein';
1171
+
1172
+ const chart = render(target, spec); // target: HTMLElement | CSS selector string
1173
+ chart.update(nextSpec); // re-render with new data/config
1174
+ chart.resize(width?, height?); // re-measure (or force explicit dims) and redraw
1175
+ chart.destroy(); // tear down DOM, observers, listeners
1176
+ chart.spec; // the currently rendered spec (readonly)
1177
+ ```
1178
+
1179
+ `render()` returns a **`ChartInstance`**. With responsive `dimensions` (the
1180
+ default), the chart tracks its container via `ResizeObserver`. When a render
1181
+ settles, Graphein sets `data-graphein-ready="true"` on the surface root and increments
1182
+ `window.__GRAPHEIN_READY` — handy for screenshot/automation tooling to wait on.
1183
+
1184
+ ### Validation & linting
1185
+
1186
+ ```ts
1187
+ import { validateSpec, lintSpec } from 'graphein';
1188
+
1189
+ const { valid, errors, warnings } = validateSpec(spec);
1190
+ ```
1191
+
1192
+ `validateSpec(spec)` returns `{ valid, errors, warnings }`. Each item is a
1193
+ `ValidationError` `{ path, message, rule?, severity?, fix?, suggestion? }`:
1194
+
1195
+ - **`errors`** are structural problems (missing channel, bad enum, unknown field).
1196
+ Fix every one — `valid` is `false` while any remain.
1197
+ - **`warnings`** include **dataviz lint** findings: best-practice advice that never
1198
+ blocks rendering. Lint findings carry a stable `rule` id and a `severity`
1199
+ (`'warning'` | `'info'`) so you can recognize or suppress a specific one.
1200
+ - **`fix`** — when the right correction is unambiguous (a misspelled chart `type`
1201
+ or enum value, a temporal-looking field typed as a category), the error carries
1202
+ a list of **JSON Patch** (RFC 6902) ops that resolve it. Apply them directly
1203
+ instead of regenerating, or let [`repairSpec`](#self-repairing-specs) do it.
1204
+ - **`suggestion`** — `{ kind, candidates }` "did you mean" hints (nearest first)
1205
+ for an unrecognized `type`, enum, or field name. Suggestions are advisory; only
1206
+ the unambiguous ones also come with a `fix`.
1207
+
1208
+ `lintSpec(spec)` runs just the lint rules (also reachable via `validateSpec`
1209
+ warnings). Current rules:
1210
+
1211
+ | `rule` | Fires when |
1212
+ | --- | --- |
1213
+ | `temporal-typed-as-categorical` | a date‑like field is typed `nominal`/`ordinal` (set `type:"temporal"`). |
1214
+ | `pie-too-many-slices` | a pie/donut has more than 7 slices. |
1215
+ | `too-many-series` | a `series`/`color` field has more than 12 distinct values. |
1216
+ | `bar-nonzero-baseline` | a bar's `y` scale disables zero (`zero:false`) or starts above 0. |
1217
+ | `log-nonpositive-data` | a `log` axis covers data with values ≤ 0. |
1218
+ | `high-cardinality-axis` | a discrete `x`/`y` axis has more than 50 categories. |
1219
+
1220
+ Lint rules evaluate the **post‑`transform`** data, so cardinality reflects what
1221
+ actually renders.
1222
+
1223
+ ### Self-repairing specs
1224
+
1225
+ ```ts
1226
+ import { repairSpec } from 'graphein';
1227
+
1228
+ const { spec, applied, remaining } = repairSpec(brokenSpec);
1229
+ // spec → a corrected copy (input is never mutated)
1230
+ // applied → the JsonPatchOp[] that were applied, in order
1231
+ // remaining → structural errors still unresolved (empty ⇒ the spec is now valid)
1232
+ ```
1233
+
1234
+ `repairSpec(spec)` applies every **safe, unambiguous** `fix` that `validateSpec`
1235
+ attaches, then re-validates — iterating, because one fix can unlock another (e.g.
1236
+ correcting `type` changes which channels are required). It only applies fixes the
1237
+ validator is confident about; genuinely ambiguous problems (a missing channel
1238
+ with no obvious field, a far-from-anything type) are left in `remaining` for you
1239
+ to resolve. This turns the common agent mistakes — a typo'd chart type, a
1240
+ misspelled aggregate `op`, a date field typed `nominal` — into a one-step
1241
+ correction rather than a full regenerate.
1242
+
1243
+ You can also apply fixes yourself with the exported JSON Patch helpers
1244
+ `applyPatch(doc, ops)` and `toPointer(dottedPath)`.
1245
+
1246
+ ### Render report
1247
+
1248
+ After a chart draws, `instance.report()` returns a **machine-readable** description
1249
+ of what was actually rendered — so an agent can verify the chart "looks right"
1250
+ without ever seeing a pixel. It closes the critique loop: generate → validate →
1251
+ render → **report** → repair.
1252
+
1253
+ ```ts
1254
+ const chart = render('#app', spec);
1255
+ const report = chart.report();
1256
+ // report.ok → true when no warning/error diagnostics were raised
1257
+ // report.markCount → number of data marks drawn
1258
+ // report.seriesCount → distinct series
1259
+ // report.colorCount → distinct series colors
1260
+ // report.plot → the plot rectangle (cartesian charts)
1261
+ // report.diagnostics → RenderDiagnostic[] (most-severe first)
1262
+ for (const d of report.diagnostics) {
1263
+ // d.code · d.severity ('error' | 'warning' | 'info') · d.message · d.axis? · d.details?
1264
+ }
1265
+ ```
1266
+
1267
+ `buildRenderReport(input)` is also exported as a pure function if you want to
1268
+ compute a report outside the render lifecycle. The report is derived entirely
1269
+ from the resolved model (scales, ticks, legend, theme colors) — **no canvas
1270
+ read-back** — so it returns identically in the browser and headless. To run the
1271
+ loop server-side, [`@graphein/node`](https://www.npmjs.com/package/@graphein/node)'s
1272
+ `renderChart(spec)` returns the PNG **and** this report; core's dependency-free
1273
+ `renderToContext(target, spec)` returns it while painting onto any 2D context.
1274
+
1275
+ **Diagnostic codes**
1276
+
1277
+ | `code` | Severity | Meaning |
1278
+ | --- | --- | --- |
1279
+ | `empty-data` | warning | No rows to plot — the chart is blank. |
1280
+ | `empty-plot` | error | The plot area collapsed (chrome ate all the space). |
1281
+ | `axis-label-overlap` | warning | Adjacent x-axis category labels collide — too many to show. |
1282
+ | `legend-overflow` | warning | A vertical legend was truncated; some series aren't shown. |
1283
+ | `degenerate-axis` | warning/info | The y values are all equal (flat line) or the scale is too narrow. |
1284
+ | `marks-clipped` | warning | Data falls outside the y range and is clipped at the plot edge. |
1285
+ | `low-contrast-mark` | warning | A series color is nearly invisible against the background. |
1286
+ | `low-contrast-text` | warning | Axis/legend label color fails the 4.5:1 text-contrast minimum. |
1287
+ | `too-many-colors` | info | More than ~8 series share one color scale — hard to tell apart. |
1288
+
1289
+ In `@graphein/react`, read the report from the instance handed to `onReady`:
1290
+ `<Chart spec onReady={(c) => console.log(c.report())} />`.
1291
+
1292
+ ### Selections & dashboards
1293
+
1294
+ Both `ChartInstance` and `DashboardInstance` expose an imperative **selection API**,
1295
+ and `renderDashboard` mounts a whole [dashboard](#dashboards) spec:
1296
+
1297
+ ```ts
1298
+ import { renderDashboard, render, createSelectionStore } from 'graphein';
1299
+
1300
+ const d = renderDashboard(target, dashboardSpec);
1301
+ d.getSelection(name?); // current SelectionValue (or a map when name omitted)
1302
+ d.setSelection('region', value); // drive a param programmatically
1303
+ d.clearSelection(name?); // clear one / all params
1304
+ const off = d.on('selectionchange', (name, value) => {/* … */});
1305
+ off(); // or d.off('selectionchange', fn)
1306
+ d.update(next); d.resize(); d.destroy(); d.spec; d.views; d.store;
1307
+
1308
+ // Linking standalone charts: share one store across render() calls.
1309
+ const store = createSelectionStore();
1310
+ const a = render('#a', specA, { store, onSelectionChange: (n, v) => {} });
1311
+ ```
1312
+
1313
+ `render(target, spec, options?)` accepts `{ store?, onSelectionChange? }` (both
1314
+ optional, backward compatible). In `@graphein/react`, `<Dashboard spec onSelectionChange? />`,
1315
+ `<Chart spec store? onSelectionChange? />`, and `useSelection(target, name?) → [value, setValue]`
1316
+ mirror this surface.
1317
+
1318
+ ### Performance
1319
+
1320
+ - **LTTB decimation** downsamples very large line/area series to roughly one point
1321
+ per pixel for drawing, while hit‑testing/tooltips keep full resolution. A 50k‑point
1322
+ series renders crisply and pans smoothly.
1323
+ - **Layered canvases:** hover/crosshair paints only an interaction layer, never the
1324
+ marks layer — so interaction never triggers a full redraw.
1325
+ - **Virtualized tables** window rows so `table`/`matrix` stay responsive at large
1326
+ row counts.
1327
+
1328
+ ### Animation
1329
+
1330
+ Charts play a brief **entrance animation** the first time they render; **resizes**
1331
+ are always instant (no re‑animation, no jank while dragging):
1332
+
1333
+ - **Cartesian charts** (line/area/bar/scatter/box/heatmap) sweep their marks in
1334
+ left‑to‑right with a short fade — the axes, gridlines, and labels are drawn
1335
+ immediately so only the data "draws on".
1336
+ - **Pie, funnel, KPI, sankey, choropleth, tables** fade and rise in subtly.
1337
+
1338
+ On **`update()`** (new data or config), canvas‑mark charts
1339
+ (line/area/bar/scatter/box/pie/heatmap/sankey/choropleth) **cross‑fade** the marks
1340
+ layer from the previous frame to the next — a smooth dissolve whose final frame is
1341
+ pixel‑identical to an instant redraw. DOM charts (`kpi`/`table`/`matrix`) update
1342
+ instantly, and a simultaneous size change snaps (that's a resize, not a data
1343
+ morph) to avoid a stretched bitmap.
1344
+
1345
+ Tuning via the `animation` field:
1346
+
1347
+ ```jsonc
1348
+ { "animation": false } // disable entirely
1349
+ { "animation": { "duration": 700, "easing": "cubicOut" } }
1350
+ ```
1351
+
1352
+ - Default entrance duration is **480ms** (easing `cubicOut`); the update
1353
+ cross‑fade is **360ms** (easing `cubicInOut`).
1354
+ - **`prefers-reduced-motion`** is honored automatically — when the OS requests
1355
+ reduced motion, entrances and update cross‑fades are suppressed and charts
1356
+ render directly in their final state.
1357
+ - Automation/screenshot harnesses can force‑disable all motion by setting
1358
+ `window.__GRAPHEIN_DISABLE_ANIM = true` before rendering, which keeps captures
1359
+ deterministic without changing any spec.
1360
+ - **Web fonts:** axis‑gutter and label widths depend on the chart's font. If a
1361
+ chart renders before its web font has loaded, it lays out with fallback metrics
1362
+ and then **re‑lays‑out automatically once the font loads**, so the final result
1363
+ is always correct. (For pixel‑exact screenshots, also `await
1364
+ document.fonts.ready` before capturing.)
1365
+
1366
+ ---
1367
+
1368
+ ## Accessibility
1369
+
1370
+ Every rendered chart is wrapped as an accessible **figure**:
1371
+
1372
+ - The surface root gets `role="figure"` and an `aria-label`. Set `description`
1373
+ for precise alt text; otherwise Graphein synthesizes one from the type, title, and
1374
+ row count (e.g. _"Bar chart: Quarterly revenue. 4 data points."_).
1375
+ - The canvas layers are `aria-hidden` (decorative). For canvas‑drawn charts
1376
+ (line/area/bar/scatter/pie/heatmap) Graphein also injects a **visually‑hidden
1377
+ `<table>`** mirroring the data (capped at 100 rows) so screen‑reader users can
1378
+ read the underlying numbers. `table`/`matrix` already render a semantic
1379
+ `<table>` (with `aria-sort` on sortable headers) and `kpi` renders real text,
1380
+ so no fallback is added for those.
1381
+ - All titles, axis labels, and legends are real DOM text (not canvas pixels), so
1382
+ they're selectable and readable by assistive tech.