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.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/chunk-N5KVZBDZ.js +338 -0
- package/dist/chunk-N5KVZBDZ.js.map +1 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +17 -0
- package/dist/server.js.map +1 -0
- package/package.json +69 -0
- package/resources/agent-guide.md +719 -0
- package/resources/chart-spec.schema.json +7828 -0
- package/resources/spec-reference.md +1382 -0
|
@@ -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).
|