mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
279 lines
20 KiB
Markdown
279 lines
20 KiB
Markdown
# 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`).
|
||
- **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 with `channel:'?'` and asks CompassQL to place it by `effectiveness`. 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_MOVE` is 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.2`
|
||
itself. We hand-roll a small decision table in `src/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`:
|
||
|
||
1. **Column type → valid field types** (Voyager `getValidTypes`; Draco `hard.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.)
|
||
2. **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.
|
||
3. **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).
|
||
4. **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.
|
||
5. **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 by
|
||
`defaultBuilderConfig`.
|
||
- `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.
|
||
|
||
## 8. Future enhancements (backlog)
|
||
|
||
The Tier-B build is the floor, not the ceiling. The enhancements below were surfaced by
|
||
the research. Status as of 2026-06-06.
|
||
|
||
> **Forward plan moved (2026-06-10):** the _prioritized, sequenced_ enhancement plan now
|
||
> lives in [`chart-builder-enhancement-scope.md`](./chart-builder-enhancement-scope.md),
|
||
> which merges this backlog with the [`lyra-review.md`](./lyra-review.md) §5 ideas and sets
|
||
> the **Tier-C** target. This section remains the research _record_ (the citations behind
|
||
> each item); consult the scope doc for _what to build next and in what order_.
|
||
|
||
**A · Cheap wins inside the current 5-mark / 4-channel scope**
|
||
|
||
- **A1 · Sort-on-ranking** _(done)_ — chart-level Sort control (Asc/Desc/None) sorts the
|
||
categorical axis by the measure (FT: "bars display ranks much more easily when sorted").
|
||
Appears only for a category-vs-measure pair.
|
||
- **A2 · Bar orientation** _(partly done)_ — the **Swap X/Y** control is the manual path to
|
||
a horizontal bar, and the crowded-axis hint (A3, below) now auto-suggests it for the
|
||
un-aggregated case. A general "long labels → go horizontal" suggestion on _any_ vertical
|
||
bar is still deferred (needs a label-length / cardinality signal); a blanket warning was
|
||
rejected — it would fire on every ordinary vertical bar.
|
||
- **A3 · Crowded-axis & high-cardinality warnings** _(done)_ —
|
||
- _Done:_ the **un-aggregated crowded axis** — a bar/line/area with a category axis and a
|
||
**raw** measure draws one mark (and one label) per row, so over `CROWDED_CATEGORY_ROWS`
|
||
(30) rows it warns and points to aggregating, or a horizontal bar. Row-count-based:
|
||
detects exactly the mark-count == row-count case (URL/non-tabular → `rowCount` null → skipped).
|
||
- _Done (via the profiling extension below):_ an **aggregated** axis that still has many
|
||
distinct **categories** (`CROWDED_CATEGORY_DISTINCT` = 30) and an unreadable **Color
|
||
legend** (`CROWDED_LEGEND_DISTINCT` = 12, discrete colour only) now warn from per-column
|
||
cardinality. (Number-typed-Nominal remains a possible future nudge; not yet flagged.)
|
||
- **A4 · Data-aware Size guard** _(done)_ — Size mapped to a field whose profiled numeric
|
||
**extent** goes negative warns (Draco `hard.lp:56`; size implies positive magnitude),
|
||
on top of the type-level Size discipline.
|
||
|
||
> **Done — profiling extension (the A3 / A4 enabler).** `profile.ts` now derives, in the
|
||
> **same sample pass** that feeds `inferColumnType`, a per-column **capped distinct count**
|
||
> (`DISTINCT_CAP` = 50, with a `distinctCapped` overflow flag) and a **numeric extent**
|
||
> (min/max, numeric columns only), surfaced on `DatasetProfile.columnStats` and the `Dataset`
|
||
> record. `builderWarnings(config, rowCount, columns)` consumes them for the legend/axis
|
||
> crowding hints and the negative-value Size guard. **Caveats handled:** URL / non-tabular
|
||
> data has no rows → `columnStats` is `[]` and the dependent hints skip; datasets stored
|
||
> before the field default to `[]` via `dataset-migrations` / import normalization (no forced
|
||
> re-profile on read — they pick up stats on next save). Thresholds live in `chart-builder.ts`
|
||
> constants alongside `CROWDED_CATEGORY_ROWS`.
|
||
|
||
**B · Transform-enabled coverage (new core capability + §06 extension)** _(done)_
|
||
|
||
- **B5 · Aggregation** _(done)_ — per-channel `sum` / `mean` / `median` / `min` / `max`, plus
|
||
a field-less "Count of records" measure (Voyager's `count(*)`). The priority item.
|
||
- **B6 · Binning** _(done)_ — `bin` on a quantitative field → true histograms (closes the
|
||
Distribution gap); mutually exclusive with aggregate on the same field.
|
||
- **B7 · Stacking** _(done)_ — `stack` (`zero` / `normalize`) for bar/area + a Color series →
|
||
part-to-whole (closes that gap; enables 100%-stacked).
|
||
- **Temporal granularity** _(done)_ — Vega-Lite `timeUnit` (Year / Quarter / Month / Week /
|
||
Day / Hour, plus combined units) on a Temporal field; defaults to None (raw).
|
||
- **B8 · Faceting (Row / Column → small multiples)** _(next increment, after A+B + UI land)_
|
||
— two more channels that multiply the chart into a trellis, the clean way to compare many
|
||
categories (Voyager has it; FT/Datawrapper recommend small multiples; we currently can't
|
||
express them). Still mark-first, so a Tier-B extension. **Axis alignment** is the design
|
||
crux: Vega-Lite facets default to **shared scales** (aligned axes) — keep that as the
|
||
default; expose an "independent axes" toggle (`resolve.scale`) only as an advanced option.
|
||
**Verify** faceting against the preview's `"container"` fit modes before trusting it
|
||
(per-cell sizing on facets is finicky). Sequenced as additive after the current build.
|
||
|
||
**C · Intent-first front door (Tier C)** _(deferred)_ — see §5. "What do you want to
|
||
show?" → recommend mark + channels from the FT/Datawrapper taxonomy × column types; where
|
||
Munzner + Wilke would be seated.
|
||
|
||
**D · Plumbing** — **D9** URL hash routing for the open builder (owned by **M6**, spec
|
||
§01E); **D10** a GOV.UK/NN-g copy pass over the guidance-hint wording (the M4 council
|
||
seating's residual one-off debt).
|
||
|
||
---
|
||
|
||
_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._
|