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

273 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 legend_ — `params: [{select: {type: "point", fields:
["product"], on: legend-binding}}]`; nothing reads it yet.
3. _+ 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.
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. _+ fold_ — `fold: ["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. _+ placement_ — `align`, `dx`, `baseline`: labels just past the bar end.
4. _+ formatted_ — `format`/`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 bucketing_ — `yearmonth` 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 formatting_ — `axis.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 shared_ — `resolve: {scale: {color: "shared"}}`: one legend, one truth.
3. _manual pinning_ — `scale: {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 nothing_ — **show 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 facet_ — `encoding.facet` / `row` / `column`: one extra line, small
multiples with shared scales ("honest comparison for free" — link the landing's
showcase claim).
2. _wrapped + sorted_ — `columns: 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 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.
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).