Docs: lesson roadmap; knip learn entry; drop dead export

This commit is contained in:
2026-07-04 19:43:44 +03:00
parent 0928912c4d
commit 4925268168
4 changed files with 685 additions and 1 deletions
+272
View File
@@ -0,0 +1,272 @@
# 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).