14 KiB
Chart Builder — Design Research (M4)
Status: research complete; informs the M4 build (spec §06). Decision: build Tier B — "smart + guarded" (mark-first, still §06-shaped). Why this doc exists: the Chart Builder is the point where Astrolabe stops being a pass-through JSON editor and starts making chart-shaped suggestions/defaults. "Which chart, and why" becomes a decision the app owns, so we researched it deliberately before building. This is the record of what we studied and what we took from each source — the citations behind every default and guardrail in
src/core/chart-builder.ts.
1. Scope of the builder (the constraint everything maps into)
Spec §06: compose a Vega-Lite chart from a dataset with one mark ∈
{Bar, Line, Point, Area, Circle}, mapping columns to four channels (X, Y, Color,
Size), each carrying a field type ∈ {Quantitative, Nominal, Ordinal, Temporal},
plus optional pixel width/height → a complete spec saved as a snippet that
references the dataset by name. Column types are inferred upstream as
number | string | date | boolean (src/core/type-inference.ts).
No transforms (no binning, aggregation, stacking, regression), no second axis, no geo. That narrow surface is the lens through which every source below was read: "what does this canon tell us to do within Bar/Line/Point/Area/Circle and X/Y/Color/Size?"
2. The sources
Two kinds: formal CS (how recommendation engines actually rank charts) and chart-choice canon (how practitioners pick). They were chosen for being cloneable/grep-able offline (the council's working model) and authoritative for "which chart," which our other seats (Carbon/GOV.UK/APG/NN/g) don't cover.
| Source | What it is | Local path |
|---|---|---|
| Draco (uwdata) | Visualization design knowledge as ASP constraints — the formal "what makes a good chart," with hard (validity) + soft (preference) rules and weights, some learned from human perception experiments (Kim 2018, Saket 2018). | reference/draco |
| Voyager (vega) | UW IDL's recommendation/exploration tool on CompassQL — the interaction model (field shelves, auto-add, type chips) and effectiveness-ranked encoding suggestions. | reference/voyager |
| FT Visual Vocabulary (Financial Times) | A poster/taxonomy mapping what you want to show (9 data-relationship categories) → chart types. Seated in the council. | reference/chart-doctor/visual-vocabulary/ |
| Datawrapper | Practitioner chart-choice in plain language; intent-first ("the chart's main statement becomes a compass"). Seated (distilled). | reference/principles/datawrapper.md |
Theoretical basis, not seated (deliberately): Munzner, Visualization Analysis
and Design (marks & channels; the channel-effectiveness rankings — magnitude:
position → length → angle → area …; identity: spatial region → hue → shape;
expressiveness & effectiveness principles) and Wilke, Fundamentals of Data
Visualization (clauswilke/dataviz; example directory by intent + "ugly/bad/wrong"
pedagogy). They are the why beneath Draco and Voyager — Draco's soft weights are an
operationalization of exactly these Mackinlay/APT/Munzner effectiveness rankings — but
they restate the same rules the seated sources already give us, so seating them would
add overlap, not coverage. Cited here as grounding; revisit if we ever build the
intent-first "Tier C" front door, where Munzner's typology and Wilke's directory
would earn their place.
3. What we take from each source
From Draco — validity guardrails + a preference ranking (the rigorous core)
Draco models a chart as ASP facts and rejects/ranks them with hard (∞ cost) and
soft (weighted) constraints (asp/optimize.lp). We can't ship an ASP solver in a
browser, but the rules are a lookup table. The portable subset:
- Hard validity (block in the UI):
reference/draco/asp/hard.lp- Quantitative on a string/boolean column — illegal (
:6). Temporal only on a datetime column (:7). - Size encoding a Nominal field — illegal ("size implies order; nominal is
misleading",
:53). Size cannot encode negative values (:56). Size only on point/text marks (:110). - Bar/Area must include a zero baseline on the measure axis (
:103-104). - Bar needs a categorical axis — both x and y continuous on a bar is malformed
(
:97); Line/Area need both x and y, and not both discrete (:91,:94). - Same field on x and y — illegal (
:122). >20 categorical colors — illegal (:172).
- Quantitative on a string/boolean column — illegal (
- Soft preference (the weights,
asp/weights.lp+asp/soft.lp):- Channel-by-type appropriateness (lower = better): continuous data is free on x/y, costs to put on color (10) or size (1); nominal cheapest on y then x then color; ordered data expensive on size. → fill X/Y before Color/Size.
- Mark by data shape: continuous×continuous → point (line/area heavily penalized); continuous×discrete aggregated → bar; discrete×discrete → point/rect.
- Prefer time on x (
temporal_y,:147); never type a number as nominal (number_nominal, weight 10); the loudest nudge is an all-discrete chart with no measure (only_discrete, weight 30).
The hand-tuned weights.lp is the portable "common-sense" set; the learned
weights_learned.lp corroborates direction, not magnitude.
From Voyager — the interaction model + the valid-type table
getValidTypes(src/components/data-pane/field-list.tsx:140-155) — adopted almost verbatim: number→{quantitative, nominal}, integer→{quantitative, nominal}, datetime→{temporal}, string→{nominal}, boolean→{nominal}. The type toggle shows only when ≥2 valid types exist. (We extend slightly — see §4 — to also offer Ordinal, which Voyager deliberately omits,encoding.ts:131-134.)- Auto-add / "auto" mark (
models/shelf/index.ts:72-81): Voyager lets a field be added withchannel:'?'and asks CompassQL to place it byeffectiveness. The small builder analogue is a non-empty smart default (defaultBuilderConfig) so the preview is never blank. - Type chips + swap: per-field type indicator with a click-to-change popover, and a
cheap x↔y swap (Voyager's
SPEC_FIELD_MOVEis remove-both + re-add). - Out of scope (Voyager scope creep we reject): wildcard shelves, the full Related
Views gallery, faceting (row/column), and embedding CompassQL/
compassql@0.20.2itself. We hand-roll a small decision table insrc/core/instead of pulling the engine.
From FT Visual Vocabulary — the intent→chart taxonomy (and our coverage gaps)
reference/chart-doctor/visual-vocabulary/README.md (taxonomy is prose). Nine
categories; mapped to our five marks:
| FT category | What it shows | Our expression |
|---|---|---|
| Magnitude | size comparisons | Bar (x=N, y=Q; horizontal x=Q, y=N for long labels) — primary |
| Ranking | position in an ordered list | Bar, sorted by value (the sort is the feature) |
| Change over Time | trends | Line (x=T, y=Q; color=N for series); Bar/Area alternatives, single series |
| Correlation | relationship of 2+ measures | Point (x=Q, y=Q); Circle/bubble + size=Q for a third measure |
| Deviation | +/− from a reference | Bar with signed Q (diverging bar only) |
| Distribution | spread/frequency | weak: raw Point strip, or Bar of pre-binned counts (no bin transform) |
| Part-to-whole | component shares | none well — redirect to Magnitude/Bar; we can't show true proportions |
| Spatial | geography | none — exclude |
| Flow | movement between states | none — exclude |
Coverage: strong on Magnitude, Ranking, Change-over-Time, Correlation; partial on Deviation/Distribution; none on Part-to-whole/Spatial/Flow. Honest gaps, not silent degradation.
From Datawrapper — plain-language rules + intent labels
reference/principles/datawrapper.md. Corroborates the same default-mark-by-intent
table (comparison→Bar, time→Line, correlation→Point/bubble) and supplies friendlier
intent words (Developments over time / Shares / Comparison / Correlation). Bindable
rules: bar is the safe default; bar over column on small screens; line for continuous
time, columns for a few points; circles are hard to compare precisely; size encodes a
quantity; area = single total (warn on multi-series).
4. The convergent rules — what all four agree on (high-confidence)
These are not a judgment call; the formal engines and the practitioner canon land on
the same place. They are the spec for src/core/chart-builder.ts:
- Column type → valid field types (Voyager
getValidTypes; Dracohard.lp:6-7):number→{Quantitative (default), Ordinal, Nominal};date→{Temporal only};string→{Nominal (default), Ordinal};boolean→{Nominal}. Never offer Q for string/boolean, never Temporal for a non-date. (We add Ordinal where it's a defensible user assertion of order; Voyager omits it for UX simplicity — our deliberate superset.) - Default mark from the (X, Y) shape (Draco mark-by-shape; Voyager effectiveness; FT; Datawrapper): temporal × quantitative → Line; quantitative × quantitative → Point; (nominal/ordinal) × quantitative → Bar; both-discrete → Point (Bar/Line/Area are invalid with no continuous axis); single axis or unknown → Bar.
- Channel priority + Size discipline (Draco
hard.lp:53,56,110+ non-positional pref): fill X/Y before Color/Size; Color before Size. Size is only valid for Quantitative/Ordinal positive measures on Point/Circle marks — disabled for Nominal, Temporal, and negative data (not merely discouraged). - Bar/Area zero-baseline; Line exempt (Draco
hard.lp:103-104; FT; ONS/Vox sources FT links). We expose no axis-truncation control, so Vega-Lite's own defaults already give zero-baseline bars and free-baseline lines — the rule is satisfied by not adding an override, nothing to emit. - Chart-choice polish (FT; Datawrapper): sort bars when ranking; horizontal bar for long category labels; Size encodes a quantity, Color a category; Area is for a single series (warn against color-splitting into many).
5. The decision: Tier B — "smart + guarded"
Three tiers were on the table. Tier B was chosen (2026-06-05).
- Tier A — spec-literal: Bar default, four channel dropdowns, type override, smart pre-population. Matches §06 verbatim but uses almost none of the research; stays a "dumb" composer.
- Tier B — smart + guarded (chosen): Tier A + default mark from the (X, Y) type shape (not always Bar) + valid-type-only menus + inline non-blocking warnings from the Draco rules + swap-X/Y + Size disabled for Nominal/Temporal/negative. Still mark-first and §06-shaped, but genuinely intelligent. Requires a small §06 amendment (documented in the spec).
- Tier C — intent-first aid: Tier B + a "what do you want to show?" front door (FT/Datawrapper intents → recommended mark + channel layout from intent × column types). Highest "which chart & why" value; biggest UI; clearly extends §06. Deferred — if revisited, this is where Munzner's typology and Wilke's directory would be seated.
6. How it maps to implementation
The convergent rules become pure functions in src/core/chart-builder.ts
(tested in chart-builder.test.ts), consumed by the builder store/modal:
validFieldTypes(columnType)→ the type menu (rule 1);defaultFieldType= its head.defaultMark(xType, yType)→ smart default mark (rule 2); used bydefaultBuilderConfig.isChannelTypeAllowed(channel, type)→ Size discipline gate (rule 3).builderWarnings(config)→ inline non-blocking hints (rules 3–5: line/area need both axes, area + many series, two measures better as a scatter, etc.).buildChartSpec/buildSnippetSpecText→ assemble the final spec; zero-baseline is Vega-Lite-default (rule 4), so nothing is emitted for it.
7. Anti-recommendations (what a naive builder would happily produce, and we don't)
The highest-value guardrails — encodings a naive UI emits that the canon rejects:
- A categorical column on Size (Draco hard
:53) — blocked, not warned. - A truncated-axis bar — prevented by never exposing an axis override (Draco
:103). - A high-cardinality category on Color → unreadable legend (soft w=10; >20 hard).
- A Line between two raw measures instead of a scatter (Draco soft w=20) — warned.
- An all-categorical chart with no measure (Draco soft w=30, the loudest) — warned.
- A number typed Nominal (Draco soft w=10) — discouraged via default = Quantitative.
Citations are to files under /Users/oleh/code/reference/. The seated chart-choice
canon (FT clone + Datawrapper distill) lives in the council roster
(.claude/skills/council/SKILL.md); Draco/Voyager are reference clones, not council
seats — they're engineering sources, not user-facing design authorities.