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,719 @@
1
+ # Graphein Agent Guide
2
+
3
+ A practical playbook for **coding agents** that generate Graphein charts and dashboards.
4
+ Graphein is built for you: one chart = one JSON‑serializable [`ChartSpec`](./spec-reference.md),
5
+ no callbacks, sensible defaults, and good visuals at any size.
6
+
7
+ ## The one rule
8
+
9
+ > Emit a single JSON object with a `type`, a `data` array of flat records, and
10
+ > (for cartesian charts) an `encoding` that names the columns.
11
+
12
+ ```ts
13
+ import { render } from 'graphein';
14
+ render('#chart', {
15
+ type: 'bar',
16
+ data: [
17
+ { quarter: 'Q1', revenue: 210 },
18
+ { quarter: 'Q2', revenue: 245 },
19
+ { quarter: 'Q3', revenue: 268 },
20
+ { quarter: 'Q4', revenue: 290 },
21
+ ],
22
+ encoding: { x: { field: 'quarter' }, y: { field: 'revenue', format: '$,d' } },
23
+ title: 'Quarterly revenue',
24
+ });
25
+ ```
26
+
27
+ ## Shape your data as a tidy table
28
+
29
+ Graphein expects **long/tidy** data: one row per observation, one column per variable.
30
+ The *same* table drives every chart — you just point different channels at columns.
31
+
32
+ ✅ Tidy (preferred):
33
+
34
+ ```json
35
+ [
36
+ { "quarter": "Q1", "region": "West", "revenue": 210 },
37
+ { "quarter": "Q1", "region": "East", "revenue": 180 }
38
+ ]
39
+ ```
40
+
41
+ 🚫 Wide (don't pre‑pivot for charts — let `series` split it, or use a `matrix`):
42
+
43
+ ```json
44
+ [{ "quarter": "Q1", "West": 210, "East": 180 }]
45
+ ```
46
+
47
+ To compare groups, add a `series` channel (line/area/bar) instead of widening:
48
+ `"series": { "field": "region" }`.
49
+
50
+ ## Reshape data in the spec with `transform`
51
+
52
+ The #1 way agents get a chart wrong is **mis‑shaping the data array** — charting
53
+ raw rows that needed aggregating, filtering, or pivoting first. Don't massage rows
54
+ in code. Add a `transform` pipeline to the spec and let Graphein reshape `data`
55
+ *before* it charts. It's plain JSON, it's validated, and a chart's encodings can
56
+ reference the columns a transform produces.
57
+
58
+ ```json
59
+ {
60
+ "type": "line",
61
+ "data": [
62
+ { "ts": "2024-01-03", "region": "West", "amount": 12 },
63
+ { "ts": "2024-01-19", "region": "West", "amount": 18 },
64
+ { "ts": "2024-02-08", "region": "East", "amount": 9 }
65
+ ],
66
+ "transform": [
67
+ { "filter": { "field": "amount", "gt": 0 } },
68
+ { "timeUnit": "month", "field": "ts", "as": "month" },
69
+ { "aggregate": [ { "op": "sum", "field": "amount", "as": "amount" } ], "groupby": ["month", "region"] }
70
+ ],
71
+ "encoding": { "x": { "field": "month", "type": "temporal" }, "y": { "field": "amount" }, "series": { "field": "region" } }
72
+ }
73
+ ```
74
+
75
+ Steps run top‑to‑bottom; each step has exactly one operator key:
76
+
77
+ | Need | Step |
78
+ | --- | --- |
79
+ | One row per bar/point (sum, mean, count…) | `{ "aggregate": [{ "op": "sum", "field": "x", "as": "x" }], "groupby": ["cat"] }` |
80
+ | Keep a subset of rows | `{ "filter": { "field": "year", "gte": 2020 } }` (compose with `and`/`or`/`not`) |
81
+ | Group a timestamp by period | `{ "timeUnit": "month", "field": "ts", "as": "month" }` |
82
+ | Bucket a number (distribution) | `{ "bin": "price", "as": ["lo", "hi"], "maxbins": 20 }` |
83
+ | Wide → long (so `series` can split it) | `{ "fold": ["West", "East"], "as": ["region", "amount"] }` |
84
+ | Derive a column (ratio, label, bucket) | `{ "calculate": "round(revenue / users, 2)", "as": "arpu" }` |
85
+
86
+ Rules of thumb: **pre‑aggregate with a `transform` so there's exactly one row per
87
+ mark** (cartesian charts plot rows as‑is); reach for `fold` instead of pre‑pivoting;
88
+ filter in the spec so the same raw `data` can feed several views. Full field‑by‑field
89
+ reference: [spec-reference → Transforms](./spec-reference.md#transforms).
90
+
91
+ ## Picking a chart type
92
+
93
+ | Goal | Use | Key channels |
94
+ | --- | --- | --- |
95
+ | Trend over time | `line` (`area` to emphasize volume) | `x` temporal, `y`, optional `series` |
96
+ | Part‑to‑whole over time | `area` + `stack: true` | `x`, `y`, `series` |
97
+ | Compare categories | `bar` | `x` category, `y`, optional `series` |
98
+ | Composition of a total | `bar` + `stack`, or `pie`/donut | bar: `series`; pie: `theta`, `color` |
99
+ | Correlation / distribution | `scatter` (+ `size` for a 3rd dim) | `x`, `y`, optional `size`, `series` |
100
+ | Distribution of one measure | `histogram` (auto-bins) | `x` quantitative (+ `bin`, `density?`) |
101
+ | Two measures / different units | `combo` (bar + line, dual-axis) | shared `x`; `layers[]` each with `mark` + `y` (+ `axis:'right'`) |
102
+ | Density across two categories | `heatmap` | `x`, `y`, `color` |
103
+ | Distribution / spread by group | `box` | `x` category, `y` observations, optional `series` |
104
+ | Flow between nodes / stages | `sankey` | `source`, `target`, `value` |
105
+ | Conversion through stages | `funnel` | `stage`, `value` |
106
+ | Values across a geography | `choropleth` | `geo`, `key`, `color` |
107
+ | Hierarchical part‑to‑whole | `treemap` | `category`, `value` (+ `group?`, `color?`) |
108
+ | Single value vs. a scale | `gauge` | `value`, `max` (+ `target?`, `bands?`) |
109
+ | KPI vs. target (compact tile) | `bullet` | `value` (+ `target?`, `ranges?`) |
110
+ | Daily values over weeks/months | `calendarHeatmap` | `date`, `color` |
111
+ | Running total / bridge | `waterfall` | `stage`, `value` (signed deltas) |
112
+ | Before / after by series | `slope` | `x`, `y`, `series` |
113
+ | Gap between two groups | `dumbbell` | `category`, `value`, `group` |
114
+ | Headline metric | `kpi` | `value`, `delta`, `sparkline` |
115
+ | Raw/detail records | `table` | `columns` (+ optional totals, groups, bars/icons/rules) |
116
+ | Aggregated cross‑tab | `matrix` | `rows`, `columns`, `values` (+ `showAs` percentages) |
117
+ | Slice/filter a field | `dropdown` · `list` · `search` · `range` · `dateRange` | `field` (+ `param?`) |
118
+ | Cross‑filtered page | `dashboard` | `views`, `interactions` |
119
+
120
+ Rules of thumb: prefer `bar` over `pie` beyond ~6 slices; use `stack` for
121
+ part‑to‑whole and grouped bars for direct comparison; reserve `pie` for a small
122
+ number of shares. For a donut with several small slices, set `labels` to a
123
+ `PieLabels` object — `placement:'auto'` keeps tight labels readable by moving
124
+ them outside onto leader lines.
125
+
126
+ ## Recipes
127
+
128
+ **Multi‑series line**
129
+
130
+ ```jsonc
131
+ {
132
+ "type": "line",
133
+ "data": [/* { date, value, region } rows */],
134
+ "encoding": {
135
+ "x": { "field": "date", "type": "temporal" },
136
+ "y": { "field": "value" },
137
+ "series": { "field": "region" }
138
+ },
139
+ "curve": "monotone"
140
+ }
141
+ ```
142
+
143
+ **Reference line + threshold zone** — call out a target or safe range with `annotations`
144
+ (works on `line`, `area`, `bar`, `scatter`, `box`):
145
+
146
+ ```jsonc
147
+ {
148
+ "type": "line",
149
+ "data": [/* { month, latency } rows */],
150
+ "encoding": {
151
+ "x": { "field": "month", "type": "temporal" },
152
+ "y": { "field": "latency" }
153
+ },
154
+ "annotations": [
155
+ { "value": 200, "label": "SLA", "color": "#ef4444" }, // horizontal rule on y
156
+ { "type": "zone", "from": 0, "to": 100, "label": "Healthy" }, // shaded band
157
+ { "axis": "x", "value": "2024-06", "label": "Launch" } // vertical rule on x
158
+ ]
159
+ }
160
+ ```
161
+
162
+ A bare `value` infers a `line`; `from`/`to` infers a `band` (`zone` is an alias). Default
163
+ `axis` is `y`. Full field list: [spec-reference → Annotations](./spec-reference.md#annotations-reference-lines-bands-zones-points).
164
+
165
+ **Let the chart label itself** — add `insights: true` to a `line`, `area`, or `bar` and
166
+ Graphein finds the peak and low and marks them with labeled dots — no need to compute or
167
+ hardcode where they are:
168
+
169
+ ```jsonc
170
+ {
171
+ "type": "line",
172
+ "data": [/* { month, users } rows */],
173
+ "encoding": { "x": { "field": "month", "type": "temporal" }, "y": { "field": "users" } },
174
+ "insights": true // marks max ▲ + min ▼; use { "outliers": true } too
175
+ }
176
+ ```
177
+
178
+ For prose, call `summarize(spec)` (also on `render(...).report().summary`) to get a
179
+ deterministic one-liner — _"Users rose 46% from 4,200 to 6,150 between 2024-01 and
180
+ 2024-06, peaking at 6,400 in 2024-03."_ — that doubles as alt-text and the chart's
181
+ `aria-description`. See [spec-reference → Self-explaining charts](./spec-reference.md#self-explaining-charts-summaries--auto-insights).
182
+
183
+
184
+ **Add a trendline (line of best fit)** — set `trendline: true` on a `scatter`, `line`, or
185
+ `area` and Graphein fits the linear regression for you — no hand-computed slope/intercept.
186
+ With a `color`/`series` channel it fits one line per group, colored to match:
187
+
188
+ ```jsonc
189
+ {
190
+ "type": "scatter",
191
+ "data": [/* { spend, revenue } rows */],
192
+ "encoding": { "x": { "field": "spend", "type": "quantitative" }, "y": { "field": "revenue" } },
193
+ "trendline": { "label": true } // a fitted line + an R²=… readout
194
+ }
195
+ ```
196
+
197
+ Needs a continuous/temporal x (it's a no-op warning on a categorical `bar`). See
198
+ [spec-reference → Trendlines](./spec-reference.md#trendlines-regression-overlays).
199
+
200
+
201
+ **Facet into small multiples** — set `facet: { field }` on a `line`, `area`, `bar`, or
202
+ `scatter` to split it into a trellis grid of panels, one per distinct value, all sharing
203
+ identical scales so they compare directly. A single field reference yields the whole grid:
204
+
205
+ ```jsonc
206
+ {
207
+ "type": "line",
208
+ "data": [/* { region, month, sales } rows */],
209
+ "encoding": { "x": { "field": "month" }, "y": { "field": "sales" } },
210
+ "facet": { "field": "region", "columns": 2 } // one panel per region, 2 across
211
+ }
212
+ ```
213
+
214
+ Don't pre-split the data or build one chart per group — let Graphein share the y-domain,
215
+ x categories, colors, and (for multi-series) one legend across panels. See
216
+ [spec-reference → Faceting](./spec-reference.md#faceting-small-multiples).
217
+
218
+
219
+ **Combo / dual-axis (bar + line)** — two measures with different units over a shared x.
220
+ Each layer plots its own `y`; add `axis:"right"` for a secondary scale:
221
+
222
+ ```jsonc
223
+ {
224
+ "type": "combo",
225
+ "data": [/* { month, revenue, conversion } rows */],
226
+ "encoding": { "x": { "field": "month" } }, // shared category/time axis
227
+ "layers": [
228
+ { "mark": "bar", "encoding": { "y": { "field": "revenue" } } }, // left axis
229
+ { "mark": "line", "axis": "right", "points": true,
230
+ "encoding": { "y": { "field": "conversion", "format": ".1%" } } } // right axis
231
+ ]
232
+ }
233
+ ```
234
+
235
+ Bars force a categorical x; multiple `bar` layers group side-by-side. Reserve the `right`
236
+ axis for genuinely different units — a secondary axis can imply a correlation that isn't
237
+ there (the linter warns with `combo-dual-axis`).
238
+
239
+ **Histogram (distribution)** — pass raw observations; the chart bins them for you:
240
+
241
+ ```jsonc
242
+ {
243
+ "type": "histogram",
244
+ "data": [/* { latency } rows — one per observation */],
245
+ "encoding": { "x": { "field": "latency", "title": "Latency (ms)" } },
246
+ "bin": { "maxbins": 20 }, // or { "step": 25 } for fixed-width bins
247
+ "density": false // true ⇒ probability density (area sums to 1)
248
+ }
249
+ ```
250
+
251
+ Don't pre-bin or pre-count — feed one row per observation and let `bin` do the work.
252
+ `x` must be quantitative (the linter warns otherwise).
253
+
254
+ **Treemap (hierarchical part‑to‑whole)** — squarified nested tiles sized by a measure:
255
+
256
+ ```jsonc
257
+ {
258
+ "type": "treemap",
259
+ "data": [/* { group, category, revenue } rows */],
260
+ "encoding": {
261
+ "group": { "field": "group" }, // optional — one level of parent tiles
262
+ "category": { "field": "category" }, // leaf label/identity
263
+ "value": { "field": "revenue", "format": "$,.0f" } // tile area (summed per leaf)
264
+ }
265
+ }
266
+ ```
267
+
268
+ **Gauge (value vs. a scale)** — a literal or aggregated value, with bands + target:
269
+
270
+ ```jsonc
271
+ {
272
+ "type": "gauge",
273
+ "data": [/* rows with an `uptime` column */],
274
+ "value": { "field": "uptime", "aggregate": "mean" },
275
+ "min": 0, "max": 100, "target": 99, "format": ",.1f",
276
+ "bands": [{ "to": 90, "color": "#ef4444" }, { "to": 98, "color": "#f59e0b" }, { "to": 100, "color": "#10b981" }]
277
+ }
278
+ ```
279
+
280
+ **Bullet (KPI vs. target)** — a compact dashboard tile; value/target are literals or fields:
281
+
282
+ ```jsonc
283
+ {
284
+ "type": "bullet",
285
+ "label": "Revenue",
286
+ "value": 820000, // or { "field": "...", "aggregate": "sum" }
287
+ "target": 900000,
288
+ "ranges": [600000, 800000, 1000000], // qualitative poor/ok/good bands
289
+ "format": "$,.0f"
290
+ }
291
+ ```
292
+
293
+ **Calendar heatmap (daily values)** — one cell per day; pass tidy `{ date, value }` rows:
294
+
295
+ ```jsonc
296
+ {
297
+ "type": "calendarHeatmap",
298
+ "data": [/* { date, commits } — one row per day */],
299
+ "scheme": "teal",
300
+ "encoding": {
301
+ "date": { "field": "date", "type": "temporal" },
302
+ "color": { "field": "commits", "type": "quantitative", "title": "Commits" }
303
+ }
304
+ }
305
+ ```
306
+
307
+ **Waterfall (running total / bridge)** — floating bars walk a running total; each `value` is a **signed** delta:
308
+
309
+ ```jsonc
310
+ {
311
+ "type": "waterfall",
312
+ "data": [/* { stage, delta } rows in order; deltas signed */],
313
+ "showTotal": true, // append a bar summing every step
314
+ "totalLabel": "Closing",
315
+ "encoding": {
316
+ "stage": { "field": "stage", "title": "Stage" },
317
+ "value": { "field": "delta", "title": "USD (000s)", "format": "$,.0f" }
318
+ }
319
+ }
320
+ ```
321
+
322
+ **Slope (before / after)** — one line per `series` across two `x` positions, direct end labels:
323
+
324
+ ```jsonc
325
+ {
326
+ "type": "slope",
327
+ "data": [/* { year, brand, share } — one row per series per x */],
328
+ "encoding": {
329
+ "x": { "field": "year" },
330
+ "y": { "field": "share", "title": "Share %" },
331
+ "series": { "field": "brand" }
332
+ },
333
+ "colorByChange": true, // green when rising, red when falling
334
+ "format": ",.0f"
335
+ }
336
+ ```
337
+
338
+ **Dumbbell (gap between two groups)** — a horizontal dot pair per `category`, joined by a connector:
339
+
340
+ ```jsonc
341
+ {
342
+ "type": "dumbbell",
343
+ "data": [/* { country, year, life } — one row per group per category */],
344
+ "encoding": {
345
+ "category": { "field": "country" },
346
+ "value": { "field": "life", "title": "Years" },
347
+ "group": { "field": "year" }
348
+ },
349
+ "sort": "gap", // order rows by dot spread
350
+ "labels": true,
351
+ "format": ",.0f"
352
+ }
353
+ ```
354
+
355
+ **KPI with delta + sparkline**
356
+
357
+ ```jsonc
358
+ {
359
+ "type": "kpi",
360
+ "label": "Total sales",
361
+ "value": { "field": "sales", "aggregate": "sum" },
362
+ "delta": 0.124, // +12.4%, drives the up indicator
363
+ "format": "$,.0f",
364
+ "sparkline": true,
365
+ "data": [/* rows with a `sales` column */]
366
+ }
367
+ ```
368
+
369
+ **Table with conditional formatting**
370
+
371
+ ```jsonc
372
+ {
373
+ "type": "table",
374
+ "data": [/* order rows */],
375
+ "columns": [
376
+ { "field": "order", "title": "Order" },
377
+ { "field": "date", "title": "Date", "format": "%b %e, %Y" },
378
+ { "field": "sales", "title": "Sales", "format": ",.0f", "prefix": "$", "align": "right",
379
+ "group": "Revenue", "conditionalFormat": { "type": "bar", "showValue": true } },
380
+ { "field": "margin", "title": "Margin", "format": ".1%", "align": "right",
381
+ "group": "Revenue", "conditionalFormat": { "type": "icon", "set": "trafficLights" } }
382
+ ],
383
+ "totals": { "label": "Total" },
384
+ "sort": { "field": "sales", "order": "desc" }
385
+ }
386
+ ```
387
+
388
+ **Pivot/matrix with subtotals**
389
+
390
+ ```jsonc
391
+ {
392
+ "type": "matrix",
393
+ "data": [/* { region, segment, category, sales } rows */],
394
+ "rows": ["region", "segment"],
395
+ "columns": ["category"],
396
+ "values": [{ "field": "sales", "op": "sum", "format": "$,.0f" },
397
+ { "field": "sales", "op": "sum", "label": "% total", "showAs": "percentOfTotal" }],
398
+ "subtotals": true,
399
+ "grandTotals": true
400
+ }
401
+ ```
402
+
403
+ **Box plot from raw observations**
404
+
405
+ ```jsonc
406
+ {
407
+ "type": "box",
408
+ "data": [/* many { group, value } rows — one per observation */],
409
+ "encoding": {
410
+ "x": { "field": "group" },
411
+ "y": { "field": "value", "title": "Latency (ms)" }
412
+ },
413
+ "whisker": "tukey" // 1.5×IQR whiskers + outliers (default)
414
+ }
415
+ ```
416
+
417
+ **Sankey from link rows**
418
+
419
+ ```jsonc
420
+ {
421
+ "type": "sankey",
422
+ "data": [
423
+ { "source": "Coal", "target": "Electricity", "value": 120 },
424
+ { "source": "Electricity", "target": "Residential", "value": 170 }
425
+ /* …one row per link; nodes are derived automatically */
426
+ ],
427
+ "encoding": {
428
+ "source": { "field": "source" },
429
+ "target": { "field": "target" },
430
+ "value": { "field": "value", "title": "TWh" }
431
+ }
432
+ }
433
+ ```
434
+
435
+ **Choropleth (data + GeoJSON)**
436
+
437
+ ```jsonc
438
+ {
439
+ "type": "choropleth",
440
+ "geo": { "type": "FeatureCollection", "features": [/* Polygon/MultiPolygon */] },
441
+ "data": [/* { state, value } rows */],
442
+ "encoding": {
443
+ "key": { "field": "state" }, // joins to a feature
444
+ "color": { "field": "value", "title": "Index" }
445
+ },
446
+ "featureId": "name", // read feature.properties.name
447
+ "scheme": "teal"
448
+ }
449
+ ```
450
+
451
+ Copy‑paste starting points for **every** type live in [`docs/examples/`](./examples).
452
+
453
+ ## Interactivity (selection, highlight, filter)
454
+
455
+ Visuals can react to one another. The unit of interactivity is a **selection** — a
456
+ named, JSON‑serializable value a visual *publishes* (clicking a mark, brushing, or
457
+ changing a slicer) that others *consume* as either a **highlight** (emphasize the
458
+ matches, dim the rest) or a **filter** (subset the rows). Selections are plain data, so
459
+ specs still round‑trip through `JSON.stringify`. Three optional fields on any spec:
460
+
461
+ ```jsonc
462
+ {
463
+ // publish: clicking a bar writes a "point" selection to the param `pick`
464
+ "params": [{ "name": "pick", "select": { "type": "point", "fields": ["region"] } }],
465
+ // consume (emphasize): dim rows that don't match another visual's param
466
+ "highlight": { "param": "pick" },
467
+ // consume (subset): keep only rows matching every clause (param or literal)
468
+ "filter": [{ "param": "region" }, { "field": "sales", "range": [100, 500] }]
469
+ }
470
+ ```
471
+
472
+ - `params[].select.type` is `point` (discrete picks; toggles/multi‑selects) or
473
+ `interval` (a continuous range). `fields` defaults to the chart's key channel.
474
+ - `highlight` references a param by name; pass an array to union several sources.
475
+ - `filter` clauses are a `{ param }` (cross‑filter) or a literal predicate:
476
+ `{ field, equals }`, `{ field, oneOf }`, `{ field, range:[min,max] }`,
477
+ `{ field, contains }`. An empty/absent selection matches everything (`empty:'all'`).
478
+
479
+ **Slicers** are first‑class visuals that publish a selection from a control:
480
+
481
+ | Slicer | Emits | Notable fields |
482
+ | --- | --- | --- |
483
+ | `dropdown` | set (or single) | `multiple`, `placeholder` |
484
+ | `list` | set (checkboxes) | `selectAll`, `searchThreshold` |
485
+ | `search` | text (contains) | `placeholder`, `debounce` |
486
+ | `range` | numeric range | `min`, `max`, `step`, `format` |
487
+ | `dateRange` | temporal range | `presets`, `format` |
488
+
489
+ Each reads one `field` and writes to `param` (default = the field name), so a slicer
490
+ auto‑connects to any visual that filters/highlights on that param name. Options and
491
+ bounds derive from the *unfiltered* data, so a slicer never hides its own choices.
492
+
493
+ ```jsonc
494
+ { "type": "dropdown", "field": "region", "multiple": true, "title": "Region" }
495
+ ```
496
+
497
+ ## Building dashboards
498
+
499
+ A `dashboard` spec composes charts and slicers into one cross‑interacting page — a
500
+ single JSON object, validated by `validateSpec`, rendered with `renderDashboard`.
501
+
502
+ ```ts
503
+ import { renderDashboard, validateSpec } from 'graphein';
504
+
505
+ const dash = {
506
+ type: 'dashboard',
507
+ data: rows, // shared dataset; views inherit it
508
+ layout: {
509
+ cols: 12,
510
+ density: 'comfortable',
511
+ sections: [
512
+ { title: 'Overview', views: ['region', 'total'] },
513
+ { title: 'Sales detail', views: ['byRegion', 'trend'] },
514
+ ],
515
+ },
516
+ views: [
517
+ { id: 'region', title: 'Region filter', spec: { type: 'dropdown', field: 'region', multiple: true }, w: 3, h: 2 },
518
+ { id: 'total', title: 'Total sales', accent: '#14b8a6',
519
+ spec: { type: 'kpi', value: { field: 'sales', aggregate: 'sum' } }, w: 3, h: 2 },
520
+ { id: 'byRegion', spec: { type: 'bar', data: salesByRegionProduct,
521
+ encoding: { x: { field: 'region' }, y: { field: 'sales' }, series: { field: 'product' } }, stack: true }, w: 9, h: 3 },
522
+ { id: 'trend', spec: { type: 'line',
523
+ encoding: { x: { field: 'month', type: 'temporal' }, y: { field: 'sales' }, series: { field: 'region' } } }, w: 9, h: 3 },
524
+ ],
525
+ interactions: 'auto',
526
+ };
527
+
528
+ validateSpec(dash);
529
+ const d = renderDashboard('#app', dash);
530
+ // d.getSelection(name?) · d.setSelection(name, value) · d.clearSelection(name?)
531
+ // d.on('selectionchange', (name, value) => …) · d.update(next) · d.resize() · d.destroy()
532
+ ```
533
+
534
+ **Auto‑wiring** (`interactions:'auto'`, the default) follows Power BI semantics:
535
+
536
+ - **Slicers filter the whole page** — every non‑slicer view whose **data contains the
537
+ field** is subset by the slicer (a KPI total, a table, a chart). A view that inherits
538
+ the dashboard's `data` carries every column, so it responds. A view with its own
539
+ pre‑aggregated `data` only responds to the dimensions it actually carries — a filter on
540
+ a column that view's data lacks is **ignored for that view** (it is *not* blanked). So
541
+ if you want a pre‑aggregated chart to react to a slicer, include that field in its
542
+ rows (e.g. aggregate by `region × product`, not `region` alone).
543
+ - **Chart clicks cross‑highlight** — clicking a mark emphasizes the matching subset in
544
+ views that encode the same field (and always self‑highlights). Highlight is per‑mark,
545
+ so it only applies where the field is plotted.
546
+
547
+ Opt out with `interactions:'none'`, or replace auto‑wiring with explicit links:
548
+
549
+ ```jsonc
550
+ "interactions": [
551
+ { "source": "region", "target": "*", "as": "filter" },
552
+ { "source": "byRegion", "target": ["trend"], "as": "highlight", "fields": ["region"] }
553
+ ]
554
+ ```
555
+
556
+ Layout is a responsive 12‑column grid; give a view `x`/`y`/`w`/`h` to place it, or omit
557
+ them to auto‑flow. Use `layout.sections` to create stacked BI bands (unlisted views land
558
+ in an implicit trailing section), `layout.preset:'kpi-first'|'sidebar'` for good default
559
+ placement, and page chrome like `maxWidth`, `density`, and `padding`. Each view can add
560
+ dashboard card chrome (`title`, `subtitle`, `accent`, `background`, `frame:false`,
561
+ `padding:'none'`) and per-view `responsive:[{maxWidth,w,h,hidden}]` spans. The grid still
562
+ reflows at `layout.breakpoints`, and compact slicers gather into a **navigator strip**
563
+ unless `layout.navigators:'inline'`. Theme cascades to every view.
564
+
565
+ > **Prefer the `dashboard` spec** for cross‑interaction. You can still hand‑place
566
+ > independent `render()` calls in your own grid for a static dashboard — share one
567
+ > `theme`, give each container a size (charts track resizes via `ResizeObserver`), and
568
+ > pass a shared `store` (`createSelectionStore()`) to `render(el, spec, { store })` if
569
+ > you want them to cross‑interact manually.
570
+
571
+ ## Formatting & dates
572
+
573
+ - Numbers: use a [format hint](./spec-reference.md#format-mini-language) like `$,.0f`,
574
+ `.1%`, `,d`, `.1s`.
575
+ - Dates: JSON has no `Date`, so pass **ISO strings** (`"2024-01-15"`) or epoch ms.
576
+ Mark the field `"type": "temporal"` for time axes. A `%` format (e.g. `%b %Y`)
577
+ renders date strings in tables/labels.
578
+
579
+ ## Theming
580
+
581
+ ```jsonc
582
+ "theme": "dark"
583
+ // or derive an accent from a brand color:
584
+ "theme": { "base": "light", "color": { "accent": "#7c3aed" } }
585
+ ```
586
+
587
+ The default look is **flat and modern** (solid fills, minimal shadows). The built‑in
588
+ palette is accessible on both light and dark backgrounds.
589
+
590
+ ## Hand-drawn ("sketch") mode
591
+
592
+ Add `"sketch": true` to **any** spec to render it as a rough.js‑style hand‑drawn
593
+ sketch — wobbly outlines, hachure fills, and a handwriting font. It works for every
594
+ chart type and composes with everything else (themes, legends, animation, dashboards).
595
+
596
+ ```jsonc
597
+ { "type": "bar", "data": [/* … */], "encoding": { "x": { "field": "q" }, "y": { "field": "v" } }, "sketch": true }
598
+ ```
599
+
600
+ Pass an object to tune it: `fillStyle` (`hachure` | `solid` | `cross-hatch`),
601
+ `roughness`, `bowing`, `hachureGap`/`hachureAngle`, `strokeWidth`, `font`, and an
602
+ optional `seed`. Reach for it for a casual, low‑fidelity, "napkin sketch" feel
603
+ (brainstorms, draft dashboards, playful reports); keep it off for precise/formal
604
+ charts. The output is deterministic, so the same spec always looks identical.
605
+
606
+ ## Validation & gotchas
607
+
608
+ - **`encoding` is required** for `line`/`area`/`bar`/`scatter`/`box` (`x`+`y`), `pie`
609
+ (`theta`+`color`), `heatmap` (`x`+`y`+`color`), `sankey` (`source`+`target`+`value`),
610
+ and `choropleth` (`key`+`color`, plus a `geo` FeatureCollection). `kpi`/`table`/`matrix`
611
+ use their own field lists instead of `encoding`.
612
+ - **Field names must exist** in every `data` row (dotted paths like `a.b` read nested
613
+ values).
614
+ - **Don't pre‑pivot** for charts — pass tidy rows and split with `series`. Use `matrix`
615
+ when you actually want an aggregated cross‑tab.
616
+ - **Aggregation:** `kpi.value` and `matrix.values[].op` aggregate for you. Cartesian
617
+ charts (`bar`/`line`/…) plot rows **as‑is** — `FieldDef.aggregate` is ignored there,
618
+ so pre‑aggregate to one row per mark (or split with `series`, or use `matrix`).
619
+ - **Tables/matrices:** conditional-format icons are Unicode glyphs (no icon fonts).
620
+ Matrix `showAs` percentages compute denominators from leaf cells even when
621
+ subtotals/grand totals are not shown.
622
+ - Everything is plain JSON: no functions, no DOM nodes, no `Date` objects inside a spec.
623
+ - **Read the `warnings`.** Beyond structural `errors`, `validateSpec` runs a **dataviz
624
+ linter** (also `lintSpec(spec)`) that flags best‑practice issues — too many pie
625
+ slices, a date field typed `nominal`, a truncated bar baseline, a log axis over
626
+ non‑positive data, too many series, a high‑cardinality axis. Each finding has a
627
+ stable `rule` id and a `severity`; they never block rendering but usually point at a
628
+ better chart.
629
+ - **Let the spec repair itself.** When a mistake has an unambiguous fix (a typo'd
630
+ chart `type` or aggregate `op`, a temporal field typed `nominal`), the
631
+ `ValidationError` carries a `fix` (JSON Patch ops) and/or a `suggestion`
632
+ (`{ kind, candidates }` "did you mean"). Call `repairSpec(spec)` to apply every
633
+ safe fix and re-validate in one shot — it returns `{ spec, applied, remaining }`,
634
+ where `remaining` is empty once the spec is valid. Prefer this over regenerating
635
+ from scratch; only the truly ambiguous problems are left for you to resolve.
636
+
637
+ ## Verify the render (the critique loop)
638
+
639
+ Validation catches structural and best-practice problems **before** drawing. After
640
+ drawing, ask the chart whether it actually came out right — without needing to look
641
+ at it:
642
+
643
+ ```ts
644
+ const chart = render('#chart', spec);
645
+ const report = chart.report();
646
+ if (!report.ok) {
647
+ for (const d of report.diagnostics) {
648
+ // d.code, d.severity, d.message — e.g. 'axis-label-overlap', 'legend-overflow',
649
+ // 'low-contrast-mark', 'marks-clipped', 'degenerate-axis'
650
+ }
651
+ }
652
+ ```
653
+
654
+ `report()` returns counts (`markCount`, `seriesCount`, `colorCount`) plus
655
+ `diagnostics` — clipped axis labels, a truncated legend, near-invisible colors, a
656
+ flat/degenerate axis, marks falling outside the plot. It also carries `summary`, the
657
+ deterministic NL one-liner from `summarize(spec)` — handy as alt-text or a caption when
658
+ writing a report. It's computed from the resolved model (no pixels read), so it works the
659
+ same in the browser and headless. The full loop is **generate → `validateSpec` →
660
+ `repairSpec` → render → `report()`**: if the report isn't `ok`, adjust the spec (widen the
661
+ chart, move the legend, reduce categories) and re-render. See [Render
662
+ report](./spec-reference.md#render-report) for every diagnostic code.
663
+
664
+ ### Running the loop server-side (no browser)
665
+
666
+ The whole critique loop runs in Node with [`@graphein/node`](https://www.npmjs.com/package/@graphein/node)
667
+ — no browser, no JSDOM. It renders a spec to a PNG **and** returns the same report,
668
+ so an agent can generate, validate, render, and critique a chart entirely on the
669
+ server (CI, report emails, PDF assets):
670
+
671
+ ```ts
672
+ import { renderChart } from '@graphein/node';
673
+ const { png, report } = renderChart(spec, { width: 900, height: 480, dpr: 2 });
674
+ if (!report.ok) {
675
+ // adjust the spec from report.diagnostics and re-render — same codes as the browser
676
+ }
677
+ await fs.writeFile('chart.png', png);
678
+ ```
679
+
680
+ It supports every canvas-backed chart (line, area, bar, scatter, box, pie, heatmap,
681
+ sankey, choropleth, combo, histogram, funnel, treemap, gauge, bullet, calendarHeatmap).
682
+ DOM-only visuals (kpi/table/matrix/slicers/dashboard) throw. Core's dependency-free
683
+ `renderToContext(target, spec)` paints onto any 2D context if you bring your own canvas
684
+ (`OffscreenCanvas`, `node-canvas`, …).
685
+
686
+ ### Running the loop as an MCP tool
687
+
688
+ If you're an agent talking to an MCP client, you don't need to import anything — the
689
+ [`graphein-mcp`](https://www.npmjs.com/package/graphein-mcp) server exposes this whole
690
+ loop as tools and **serves the schema and these guides as resources**, so the API is
691
+ delivered to you at runtime. Point the client at it:
692
+
693
+ ```jsonc
694
+ { "mcpServers": { "graphein": { "command": "npx", "args": ["-y", "graphein-mcp"] } } }
695
+ ```
696
+
697
+ Then:
698
+
699
+ - **`render_chart`** — validate → repair → render → returns the **PNG image** plus the
700
+ vision-free critique (report diagnostics, lint warnings, repairs applied). One call.
701
+ - **`validate_chart`** / **`repair_chart`** — structural errors with JSON-Patch fixes, or
702
+ the auto-corrected spec.
703
+ - **`summarize_chart`** — the deterministic NL one-liner.
704
+ - Resources **`graphein://agent-guide`**, **`graphein://schema`**,
705
+ **`graphein://spec-reference`** — read these instead of guessing the API.
706
+
707
+ Same loop, same diagnostic codes — just delivered over MCP instead of an import.
708
+
709
+ ## Lifecycle
710
+
711
+ ```ts
712
+ const chart = render('#chart', spec);
713
+ chart.update(nextSpec); // new data/config, same container
714
+ chart.resize(); // re-measure after a layout change
715
+ chart.destroy(); // remove on teardown (SPA route change, etc.)
716
+ ```
717
+
718
+ See the full field‑by‑field [Spec Reference](./spec-reference.md) and the
719
+ machine‑readable [JSON Schema](./chart-spec.schema.json).