Learn: rework binning and linked-views lessons

This commit is contained in:
2026-07-04 19:43:31 +03:00
parent 42717fe4f7
commit 0928912c4d
3 changed files with 221 additions and 25 deletions
+5 -1
View File
@@ -42,7 +42,11 @@ Inside a `:::progression`, each `##` heading is a stage: heading → tab label,
the following fenced `vega-lite` block → spec. A `:::data` block names datasets once for the the following fenced `vega-lite` block → spec. A `:::data` block names datasets once for the
whole lesson; specs reference them with `{ "data": { "name": … } }` and `injectDatasets` whole lesson; specs reference them with `{ "data": { "name": … } }` and `injectDatasets`
merges the rows in at render time — so a shared dataset isn't repeated per stage, and the merges the rows in at render time — so a shared dataset isn't repeated per stage, and the
source pane keeps a stage's grammar legible instead of burying it under data. source pane keeps a stage's grammar legible instead of burying it under data. Every lesson
uses the `:::data` form (never per-stage inline rows), targets a misconception rather than
a chart type, and closes with a "take it further" prose beat that leans on the per-stage
"Open in Astrolabe" links — the roster and per-lesson briefs live in
`docs/exploration/lessons-roadmap.md`.
## The pipeline ## The pipeline
+52 -15
View File
@@ -5,10 +5,18 @@ tagline: How one word turns a noisy bar chart into a distribution — and where
--- ---
Counting a continuous field is the most common chart that's _almost_ right. The naive Counting a continuous field is the most common chart that's _almost_ right. The naive
version compiles, renders, and quietly lies about your data. Walk the four stages below — version compiles, renders, and quietly lies about your data. Walk the stages below — each
each tab is one small edit — and watch a picket fence become a distribution, then a finished tab is one small edit — and watch a picket fence become a distribution, then a finished
chart. chart.
:::data
{
"orders": [
{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}
]
}
:::
:::progression :::progression
## raw counts ## raw counts
@@ -20,12 +28,12 @@ fence, not a distribution. It looks almost right, which is exactly what makes it
```vega-lite ```vega-lite
{ {
"$schema": "https://vega.github.io/schema/vega-lite/v6.json", "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"mark": "bar", "mark": "bar",
"encoding": { "encoding": {
"x": { "field": "minutes", "type": "quantitative" }, "x": { "field": "minutes", "type": "quantitative" },
"y": { "aggregate": "count" } "y": { "aggregate": "count" }
}, }
"data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
} }
``` ```
@@ -37,12 +45,12 @@ counts each bucket — a real histogram from a one-word edit.
```vega-lite ```vega-lite
{ {
"$schema": "https://vega.github.io/schema/vega-lite/v6.json", "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"mark": "bar", "mark": "bar",
"encoding": { "encoding": {
"x": { "field": "minutes", "type": "quantitative", "bin": true }, "x": { "field": "minutes", "type": "quantitative", "bin": true },
"y": { "aggregate": "count" } "y": { "aggregate": "count" }
}, }
"data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
} }
``` ```
@@ -54,24 +62,51 @@ Take control of the resolution: `bin: { step: 10 }` forces clean 10-minute bucke
```vega-lite ```vega-lite
{ {
"$schema": "https://vega.github.io/schema/vega-lite/v6.json", "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"mark": "bar", "mark": "bar",
"encoding": { "encoding": {
"x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" }, "x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" },
"y": { "aggregate": "count", "title": "Orders" } "y": { "aggregate": "count", "title": "Orders" }
}, }
"data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] } }
```
## + a mean line
The first act of _composition_: the spec becomes a `layer` of two marks sharing the same
data and x-axis — the bars, and a `rule` at the mean. Each layer keeps its own mark and
encoding; the shared scale is what makes them one chart. This one jump is most of what
"layering" means.
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"layer": [
{
"mark": "bar",
"encoding": {
"x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" },
"y": { "aggregate": "count", "title": "Orders" }
}
},
{
"mark": { "type": "rule", "color": "#d6336c", "size": 2 },
"encoding": { "x": { "field": "minutes", "aggregate": "mean" } }
}
]
} }
``` ```
## polished ## polished
The last 20%: round the bar tops, add a tooltip that reports each bucket's range and count, The last 20% is legibility, not structure: round the bar tops, and add a tooltip that
and _layer_ a mean line over the bars. Composition is where polish lives — one spec, two reports each bucket's range and count.
marks sharing an axis.
```vega-lite ```vega-lite
{ {
"$schema": "https://vega.github.io/schema/vega-lite/v6.json", "$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"data": { "name": "orders" },
"layer": [ "layer": [
{ {
"mark": { "type": "bar", "cornerRadiusEnd": 2 }, "mark": { "type": "bar", "cornerRadiusEnd": 2 },
@@ -88,8 +123,7 @@ marks sharing an axis.
"mark": { "type": "rule", "color": "#d6336c", "size": 2 }, "mark": { "type": "rule", "color": "#d6336c", "size": 2 },
"encoding": { "x": { "field": "minutes", "aggregate": "mean" } } "encoding": { "x": { "field": "minutes", "aggregate": "mean" } }
} }
], ]
"data": { "values": [{"minutes":12},{"minutes":14},{"minutes":15},{"minutes":16},{"minutes":18},{"minutes":19},{"minutes":20},{"minutes":21},{"minutes":22},{"minutes":23},{"minutes":24},{"minutes":25},{"minutes":26},{"minutes":28},{"minutes":30},{"minutes":31},{"minutes":33},{"minutes":35},{"minutes":37},{"minutes":40},{"minutes":43},{"minutes":46},{"minutes":50},{"minutes":55},{"minutes":61},{"minutes":68},{"minutes":77},{"minutes":86}] }
} }
``` ```
@@ -99,6 +133,9 @@ marks sharing an axis.
**The sharp edge.** Tempted to wire a slider to the bin width? It won't work. In Vega-Lite, **The sharp edge.** Tempted to wire a slider to the bin width? It won't work. In Vega-Lite,
`bin.step` and `maxbins` are fixed numbers — no parameter or expression drives them, so a `bin.step` and `maxbins` are fixed numbers — no parameter or expression drives them, so a
bound slider compiles fine and then does nothing. The one binning property you _can_ make bound slider compiles fine and then does nothing. The one binning property you _can_ make
interactive is `extent`: brush an interval selection to re-bin a chosen range. That's a interactive is `extent`: brush an interval selection to re-bin a chosen range.
lesson of its own.
::: :::
Take it further: open the last stage in Astrolabe (the link under the chart) and try a
`step` of 5 against 20 — watch the story the histogram tells change with the resolution.
Then swap the rule's `mean` for `median` and see which one your outliers drag.
+164 -9
View File
@@ -12,8 +12,8 @@ re-aggregate by hand for every window you got curious about.
So we build three views over one interval selection. Brush a span of the run on the top So we build three views over one interval selection. Brush a span of the run on the top
chart and the other two recompute over exactly that window — the cumulative gap between the chart and the other two recompute over exactly that window — the cumulative gap between the
arms, and their share split. Five edits, and all three stay in sync through a single arms, and their share split. A handful of small edits, and everything stays in sync
`param`. through a single `param`.
:::data :::data
{ {
@@ -522,14 +522,165 @@ you'll drag across, because a selection lives on the view whose marks define it.
} }
``` ```
## + a linked gap ## + filter by the brush
Append a second view that reads the selection. `filter: {param: brush}` keeps only the Append a second view that reads the selection `filter: {param: brush}` is the entire
brushed minutes; then it pivots A and B into columns, re-accumulates each, and plots link. Everything downstream of that filter re-runs over only the brushed rows, so this
A B. Now dragging the band on the top chart rebases this one — brush the first half-hour copy of the cumulative lines rebases to whatever window you drag: brush the first
and the early gap shows; brush the tail and it flattens. "Is the imbalance localised in half-hour and the early race shows; brush the tail and both lines restart from zero
time?" gets answered by dragging instead of re-querying. With nothing brushed the selection there. With nothing brushed the selection is empty, which by default matches _every_
is empty, which by default means _every_ row, so it opens on the full run. row — that's why it opens on the full run.
```vega-lite
{
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
"vconcat": [
{
"data": {
"name": "ev"
},
"transform": [
{
"sort": [
{
"field": "minute"
}
],
"window": [
{
"op": "sum",
"field": "n",
"as": "cum"
}
],
"groupby": [
"arm"
],
"frame": [
null,
0
]
}
],
"params": [
{
"name": "brush",
"select": {
"type": "interval",
"encodings": [
"x"
]
}
}
],
"mark": "line",
"encoding": {
"x": {
"field": "minute",
"type": "temporal",
"title": "Time (UTC)",
"axis": {
"format": "%H:%M"
}
},
"y": {
"field": "cum",
"type": "quantitative",
"title": "Cumulative assignments"
},
"color": {
"field": "arm",
"type": "nominal",
"scale": {
"domain": [
"A",
"B"
],
"range": [
"#3a7ca5",
"#e0913a"
]
},
"title": "Branch"
}
}
},
{
"data": {
"name": "ev"
},
"transform": [
{
"filter": {
"param": "brush"
}
},
{
"sort": [
{
"field": "minute"
}
],
"window": [
{
"op": "sum",
"field": "n",
"as": "cum"
}
],
"groupby": [
"arm"
],
"frame": [
null,
0
]
}
],
"mark": "line",
"encoding": {
"x": {
"field": "minute",
"type": "temporal",
"title": "Time (UTC)",
"axis": {
"format": "%H:%M"
}
},
"y": {
"field": "cum",
"type": "quantitative",
"title": "Rebased in the window"
},
"color": {
"field": "arm",
"type": "nominal",
"scale": {
"domain": [
"A",
"B"
],
"range": [
"#3a7ca5",
"#e0913a"
]
},
"legend": null
}
}
}
]
}
```
## + the gap math
Two rebased lines still make you subtract by eye. To plot A B directly, the rows have
to change _shape_: `pivot` turns the A and B rows into columns of one row per minute,
two `calculate`s patch the minutes where an arm is missing, and a two-op `window`
re-accumulates each column before the final subtraction. Four transforms in service of
one line — reshaping long rows into wide columns is a craft of its own, but the link to
the brush is unchanged: the same `filter` still heads the pipeline.
```vega-lite ```vega-lite
{ {
@@ -1183,3 +1334,7 @@ brush}` inherits Vega-Lite's default `empty: "all"`, so until you drag something
matches every row — which is why the dashboard opens on the full run. To make it open blank matches every row — which is why the dashboard opens on the full run. To make it open blank
instead, set `empty: "none"` on the selection. instead, set `empty: "none"` on the selection.
::: :::
Take it further: open the last stage in Astrolabe (the link under the chart) and try both
traps for yourself — move the `params` block onto the gap view and feel the dashboard go
dead, then put it back and set `empty: "none"` to see the blank-until-brushed variant.