14 KiB
Learn Section — Lesson Roadmap
Point-in-time planning memo, 2026-07-04. Detailed briefs for the next lessons, written to
be authorable one at a time (draft → maintainer edit, like the why-astrolabe workflow).
Format for every lesson: the existing progression machinery — hook, :::data, staged
specs with diff highlighting, :::sharp-edge, a "take it further" closer that uses the
per-stage "Open in Astrolabe" links.
Positioning
The Vega-Lite docs are an example gallery plus a property reference — hundreds of finished specs. Lessons that showcase charts compete with that and lose. What the docs don't have, and where middle+ users plateau, is the invisible machinery and its failure modes: where data actually flows, how selections resolve, why scales unify or don't. Every lesson below is organized around a misconception, not a chart type. The progression format (the path from almost-right to right) and the sharp edges (documented failure modes) are the moat; keep both in every lesson.
Tracks & ordering
Two informal tracks once the roster grows past ~4 lessons (add a level field to lesson
frontmatter; the index groups by it — "foundations" and "deeper", not course-like
numbering):
- Foundations: binning, labels-on-bars, long-vs-wide, data-flow.
- Deeper: linked-views, highlight-vs-filter, faceting, resolution, time, interactive-binning.
Cross-link map (a lesson references another only once it exists): linked-views' "gap math" stage → long-vs-wide; long-vs-wide → data-flow; highlight-vs-filter ← linked-views' closer; interactive-binning ← binning's sharp edge; resolution ← faceting's scale caveats.
The sharp-edge blocks are accumulating into a corpus nothing else on the Vega-Lite internet has; once there are ~8, an "edges" index page (auto-collected from lesson frontmatter or the parsed callouts) becomes a destination of its own.
1. highlight-vs-filter — the two answers to a selection
Thesis / misconception: a selection can drive a view two fundamentally different ways — highlight (conditional encoding: context preserved, scales still) or filter (rows removed: everything recomputes, scales included). Most people know one and reach for it everywhere; the choice is the actual design decision.
Scenario: six product lines' weekly revenue — a spaghetti chart where the reader cares about one line at a time.
Stages:
- spaghetti — six lines, one color scale; unreadable but honest baseline.
- + point selection on the legend —
params: [{select: {type: "point", fields: ["product"], on: legend-binding}}]; nothing reads it yet. - + highlight —
opacity: {condition: {param, value: 1}, value: 0.2}: the chosen line pops, the rest stay as context. Note: the y-scale did not move. - + the filter variant — same selection, second view (or swapped response) with
transform: [{filter: {param}}]: the y-axis re-fits the chosen line. Note the trade explicitly: filtering loses the comparison but gains resolution. - polished — both responses side by side over one selection; titles that name the difference ("in context" / "re-scaled").
Sharp edges: empty default makes condition + filter behave differently before any
click (highlight: everything full-opacity; filter: everything shown) — set empty: "none" deliberately. Point selections toggle on re-click (shift-click accumulates);
that's toggle and it surprises people.
Take it further: change the condition channel from opacity to color/size; try
select: {type: "point", on: "pointerover"} for hover-highlight.
2. long-vs-wide — reshaping rows for the chart you want
Thesis / misconception: "my data is in columns" is a data-shape problem, not a
chart problem. Vega-Lite wants long rows for encoding channels (fold gets you there);
row-wise arithmetic wants columns (pivot gets you back). SQL framing for the
analyst audience: fold ≈ UNPIVOT, pivot ≈ crosstab/GROUP BY columns.
Scenario: a spreadsheet-shaped budget — one row per team, columns jan feb mar —
then a two-step signup funnel where the metric is a ratio between rows.
Stages:
- the spreadsheet wall — wide data charted naively: one bar per team, months inaccessible to color/facet. The failure is the hook.
- + fold —
fold: ["jan","feb","mar"]→ key/value rows; suddenly month is an encoding channel like any other. - tidy names — fold's
as: ["month","spend"]; real field names, temporal parse. - + pivot for row math — the funnel: long rows (step, count) pivoted to columns so
calculate: datum.purchase / datum.visitcan produce a conversion rate per cohort. - polished — both charts labelled; the note states the rule of thumb: encode long, compute wide.
Sharp edges: pivot drops rows with missing keys silently — the isValid patching
dance (exactly what linked-views' gap stage does; link back). Fold keeps other columns
duplicated per folded row — aggregate afterwards or double-count.
Retro-link: linked-views' "+ the gap math" note gains a pointer here once shipped.
3. data-flow — where your data actually flows
Thesis / misconception: transforms run in array order, and encoding-level
aggregate/bin run after the transform array — so "why is my filter not working"
is usually "your filter runs at a different point in the pipeline than you think".
Scenario: percent-of-total by category — the one chart that needs the pipeline understood, because it needs a total alongside rows, not instead of them.
Stages:
- encoding aggregate — plain
sumbar chart; fine, but a dead end for percent-of. - transform aggregate — the same chart via
transform: [{aggregate}]; identical pixels, different pipeline position — now downstream transforms can read the result. - + joinaggregate — the star of the lesson: totals attached to every row (rows kept,
unlike
aggregate), thencalculatepercent. - order matters — move a
filterbefore vs after the joinaggregate; percentages of the filtered subset vs of the whole. Same transforms, opposite meanings. - polished — percent-of-total bars with a
windowrank ordering.
Sharp edges: window without sort is row-order-dependent (works in the example,
breaks on real data); frame: [null, 0] means "start through current row" — the
cumulative default everyone copies without reading.
4. labels-on-bars — layering, taught by the most-searched task
Thesis: putting values on bars is the internet's most-asked Vega-Lite question, and
the answer is layer mechanics: a text mark sharing the bar's encodings, plus the
handful of properties that make labels sit right.
Scenario: a ranked horizontal bar chart (top categories by value) — the chart people actually want labels on.
Stages:
- bars, sorted — includes the
sort: "-x"idiom in passing. - + a text layer — same data,
mark: "text", x/y duplicated; labels land ON the bar ends, ugly but working. Note how shared encodings could be hoisted to the layer root. - + placement —
align,dx,baseline: labels just past the bar end. - + formatted —
format/formatType, and acalculatefor compact units (e.g. "1.2k"). - polished — de-emphasized axis (the labels now carry the values; drop the x-axis grid/ticks — say why: double-encoding).
Sharp edges: labels don't avoid each other or the bar end — there is no collision
avoidance in Vega-Lite; for inside-vs-outside placement use a condition on bar length.
Dual-axis via resolve: {scale: {y: "independent"}} looks adjacent but is a trap —
mention, defer detail to resolution.
5. time — the sharpest axis
Thesis / misconception: temporal data has two independent honesty problems — when
is a date (timezone parsing: the off-by-one-day bug) and what is a time bucket
(timeUnit vs binning vs exact timestamps).
Scenario: daily signups spanning a year, authored as date-only strings — the exact shape that triggers the UTC/local trap.
Stages:
- the off-by-one — date-only strings (
"2026-03-01") charted naively; for viewers west of Greenwich every point sits on the previous day. Explain: JS parses date-only strings as UTC midnight, then Vega-Lite renders in local time. - + honest parsing — the fixes shown together:
utcyearmonthdatetimeUnits (stay in UTC end-to-end) vs. explicit local parsing; pick one side and stay on it. - + timeUnit bucketing —
yearmonthcollapses days to months in the chart, no transform needed; contrast withtimeUnitas a transform when the bucket must feed later steps. - + axis formatting —
axis.formatvsaxis.formatType, and why tick labels lie when format and timeUnit disagree. - polished — a monthly chart that renders identically in Kyiv and California.
Sharp edges: the date-only/datetime parsing asymmetry (date-only → UTC, with-time →
local) is the single most-reported "bug" that isn't one; timeUnit without utc prefix
re-buckets per-viewer-timezone — dashboards that disagree between offices.
6. resolution — one legend or two
Thesis / misconception: who shares scales by default differs by composition kind —
layer: shared; facet: shared; concat/repeat: independent — and resolve is the knob.
Most "my colors don't match between panels" bugs are this table, unknown.
Scenario: the same two-metric dashboard composed three ways (layer, concat, facet), watching the color scale and legends merge or split.
Stages:
- concat, two legends — the bug as the baseline: same field, two views, two legends, possibly two color assignments.
- + resolve shared —
resolve: {scale: {color: "shared"}}: one legend, one truth. - manual pinning —
scale: {domain: [...], range: [...]}per view as the other route (what linked-views does — call it out); when explicit domains beat resolve. - layer's inverse problem — layered dual-metric where sharing is the bug;
resolve: {scale: {y: "independent"}}, and the honest warning about dual axes. - polished — the corrected dashboard with a note naming the default-by-kind table.
Sharp edges: axis resolution is separate from scale resolution (shared scale can
still draw two axes); legends for condition-driven encodings don't exist (conditions
have no legend — a recurring surprise after the highlight lesson).
7. interactive-binning — the promised binning sequel
Thesis: which knobs of a spec are live (parameterizable) and which are
compile-time. Binning is the teaching case: step/maxbins are frozen; extent is
live.
Scenario: the delivery-times histogram from the binning lesson, upgraded to an overview-detail pair.
Stages:
- the slider that does nothing — show the failure live: a
parambound to a slider, referenced wherestepwants a number; compiles, renders, slider moves, bars don't. The strongest inoculation the format can deliver. - the working knob — overview histogram with an interval brush.
- + extent — detail histogram whose
bin: {extent: {param: "brush"}, maxbins: 20}re-bins inside the brushed range: coarse overview, fine detail. - polished — labeled pair, brush styling, tooltips.
Sharp edges: recap step/maxbins immutability (now demonstrated, not asserted);
extent re-bins but does not filter — pair it with a filter: {param} or the detail
view still draws out-of-range rows at the edges.
Retro-link: binning's sharp edge gains "see the sequel" once this ships.
8. faceting — the goldmine: small multiples and their caveats
Thesis / misconception: there are three ways to repeat a chart — the facet
channel/operator (split by a field's values), repeat (split by different fields),
and hand-built concat — and most frustration comes from using one where another is
meant, then fighting its constraints.
Scenario: sales by region — first split by region (facet), then the same chart repeated across metrics (revenue, units, margin — repeat), showing why facet can't do the latter.
Stages:
- the encoding facet —
encoding.facet/row/column: one extra line, small multiples with shared scales ("honest comparison for free" — link the landing's showcase claim). - wrapped + sorted —
columns: 3, and sorting facets by a data value (sorton the facet field def) — the "my panels are alphabetical but I want by total" fix. - the operator form —
facet: {...}, spec: {...}: same output, but now the child can be a layered spec — the form you need the moment panels contain more than one mark. - repeat, not facet — the metrics case:
repeat: {column: [fields]}+{repeat: "column"}field references; facet cannot do this (it splits by values, not by fields). - polished — headers styled (
headervs axis titles), spacing, independent y where metrics differ in unit (resolvecallback to lesson 6).
Sharp edges (the caveat goldmine — candidates, pick 2–3 for the block and push the rest into stage notes):
- Facet children can't take
width/height: "container"— faceted charts size by per-panelwidth/heightand ignore fit-to-container (the app's own fit modes fall back topad; arch 05 records the engine-side truth). - A facet spec can't be layered over (facet must be outermost; layer inside the child, never outside).
- Selections in facets resolve per-panel by default — a brush in one panel doesn't
select in the others until
resolve: "union"/"global"on the param. headervsaxis: facet labels live on headers; styling them via axis config silently does nothing.
Authoring workflow
One lesson per session-slice: I draft the full markdown (scenario data included, specs verified rendering via the dev server before review), the maintainer edits voice and pedagogy, then it ships with its retro-links applied to earlier lessons. Order proposed: highlight-vs-filter → long-vs-wide → faceting → time → labels → data-flow → resolution → interactive-binning (interest-first: interactivity and faceting are the section's strongest differentiation; data-flow and resolution are load-bearing but drier, better once the section has gravity).