Files
astrolabe/docs/exploration/lessons-roadmap.md
T

14 KiB
Raw Blame History

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:

  1. spaghetti — six lines, one color scale; unreadable but honest baseline.
  2. + point selection on the legendparams: [{select: {type: "point", fields: ["product"], on: legend-binding}}]; nothing reads it yet.
  3. + highlightopacity: {condition: {param, value: 1}, value: 0.2}: the chosen line pops, the rest stay as context. Note: the y-scale did not move.
  4. + 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.
  5. 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:

  1. the spreadsheet wall — wide data charted naively: one bar per team, months inaccessible to color/facet. The failure is the hook.
  2. + foldfold: ["jan","feb","mar"] → key/value rows; suddenly month is an encoding channel like any other.
  3. tidy names — fold's as: ["month","spend"]; real field names, temporal parse.
  4. + pivot for row math — the funnel: long rows (step, count) pivoted to columns so calculate: datum.purchase / datum.visit can produce a conversion rate per cohort.
  5. 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:

  1. encoding aggregate — plain sum bar chart; fine, but a dead end for percent-of.
  2. transform aggregate — the same chart via transform: [{aggregate}]; identical pixels, different pipeline position — now downstream transforms can read the result.
  3. + joinaggregate — the star of the lesson: totals attached to every row (rows kept, unlike aggregate), then calculate percent.
  4. order matters — move a filter before vs after the joinaggregate; percentages of the filtered subset vs of the whole. Same transforms, opposite meanings.
  5. polished — percent-of-total bars with a window rank 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:

  1. bars, sorted — includes the sort: "-x" idiom in passing.
  2. + 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.
  3. + placementalign, dx, baseline: labels just past the bar end.
  4. + formattedformat/formatType, and a calculate for compact units (e.g. "1.2k").
  5. 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:

  1. 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.
  2. + honest parsing — the fixes shown together: utcyearmonthdate timeUnits (stay in UTC end-to-end) vs. explicit local parsing; pick one side and stay on it.
  3. + timeUnit bucketingyearmonth collapses days to months in the chart, no transform needed; contrast with timeUnit as a transform when the bucket must feed later steps.
  4. + axis formattingaxis.format vs axis.formatType, and why tick labels lie when format and timeUnit disagree.
  5. 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:

  1. concat, two legends — the bug as the baseline: same field, two views, two legends, possibly two color assignments.
  2. + resolve sharedresolve: {scale: {color: "shared"}}: one legend, one truth.
  3. manual pinningscale: {domain: [...], range: [...]} per view as the other route (what linked-views does — call it out); when explicit domains beat resolve.
  4. layer's inverse problem — layered dual-metric where sharing is the bug; resolve: {scale: {y: "independent"}}, and the honest warning about dual axes.
  5. 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:

  1. the slider that does nothingshow the failure live: a param bound to a slider, referenced where step wants a number; compiles, renders, slider moves, bars don't. The strongest inoculation the format can deliver.
  2. the working knob — overview histogram with an interval brush.
  3. + extent — detail histogram whose bin: {extent: {param: "brush"}, maxbins: 20} re-bins inside the brushed range: coarse overview, fine detail.
  4. 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:

  1. the encoding facetencoding.facet / row / column: one extra line, small multiples with shared scales ("honest comparison for free" — link the landing's showcase claim).
  2. wrapped + sortedcolumns: 3, and sorting facets by a data value (sort on the facet field def) — the "my panels are alphabetical but I want by total" fix.
  3. the operator formfacet: {...}, spec: {...}: same output, but now the child can be a layered spec — the form you need the moment panels contain more than one mark.
  4. repeat, not facet — the metrics case: repeat: {column: [fields]} + {repeat: "column"} field references; facet cannot do this (it splits by values, not by fields).
  5. polished — headers styled (header vs axis titles), spacing, independent y where metrics differ in unit (resolve callback to lesson 6).

Sharp edges (the caveat goldmine — candidates, pick 23 for the block and push the rest into stage notes):

  • Facet children can't take width/height: "container" — faceted charts size by per-panel width/height and ignore fit-to-container (the app's own fit modes fall back to pad; 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.
  • header vs axis: 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).