diff --git a/.gitignore b/.gitignore index ff31088..7616113 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ coverage # M1.5 visual verification screenshots (local only) .m15-screenshots/ + +# Generated per-lesson page shells (learn//index.html) — produced from the +# lesson .md frontmatter by scripts/learn-pages.ts on every dev/build. +learn/*/ diff --git a/docs/architecture/11-learning-section.md b/docs/architecture/11-learning-section.md index d3b8640..e7f8cba 100644 --- a/docs/architecture/11-learning-section.md +++ b/docs/architecture/11-learning-section.md @@ -2,7 +2,10 @@ An interactive deep-dive into Vega-Lite, served at `/learn/`: a marketing surface separate from the app, where each lesson walks a spec from an 80%-naive version to a polished one to -teach the grammar's dormant power and funnel readers into the app. +teach the grammar's dormant power and funnel readers into the app. It is pitched past the +basics — fundamentals are left to the official Vega-Lite docs (linked from the index), so +the section leads with advanced cases (interaction, composition, transforms) rather than +fundamentals. ## A marketing surface, like the landing @@ -14,6 +17,12 @@ teach the grammar's dormant power and funnel readers into the app. stays light. - **Out of PWA scope.** The service worker is scoped to `/app/`, so the learning pages stay uncontrolled, always-fresh, and indexable — the point for organic-reach content. +- **An index plus a page per lesson.** `/learn/` is the index (a card per lesson); each + lesson is its own URL `/learn//` — a separate indexable document with its + frontmatter-derived ``/`<meta>`. The per-lesson HTML shells are generated from the + lesson frontmatter by `scripts/learn-pages.ts` (run from `vite.config` on every dev/build, + so "drop a file" still holds; the shells are git-ignored). The single `src/learn` entry + renders the index or one lesson from `location.pathname`. ## Lessons are markdown; the renderer is general @@ -21,23 +30,31 @@ A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.g **adding a lesson is dropping a file**, with no registry to edit. A lesson is a _free-form document of ordered blocks_, not a fixed template: -| Authored as | Block | Rendered by | -| ---------------------------------------------------- | ------------- | --------------------------- | -| plain markdown | `prose` | `Markdown` | -| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` | -| a bare fenced `vega-lite` block | `chart` | `LandingChart` | -| `:::name … :::` | `callout` | `Markdown` (styled by name) | +| Authored as | Block | Rendered by | +| ---------------------------------------------------- | ------------- | ----------------------------- | +| plain markdown | `prose` | `Markdown` | +| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` | +| a bare fenced `vega-lite` block | `chart` | `LandingChart` | +| `:::name … :::` | `callout` | `Markdown` (styled by name) | +| `:::data` wrapping a `{ name: rows }` JSON object | — metadata | injected into specs at render | Inside a `:::progression`, each `##` heading is a stage: heading → tab label, prose → note, -the following fenced `vega-lite` block → spec. Inline data repeats per fenced block — there -is no shared-data construct. +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` +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. ## The pipeline -`lessons/*.md` → `import.meta.glob` (in `LearnPage`) → `parseLesson` (`core/lesson-parse`) → -`LessonBlock[]` → `LearnPage` block dispatch → `Markdown` | `SpecProgression` | `LandingChart`. -`SpecProgression` renders each stage's spec with `formatSpec` (`core/json-format`) and -highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS line-diff). +`lessons/*.md` → `import.meta.glob` + `parseLesson` (`src/learn/lessons.ts`, using +`core/lesson-parse`) → `LESSONS`. The `src/learn` entry reads the path: `/learn/` → +`LearnIndex`, `/learn/<slug>/` → `LessonView`, both inside `LearnLayout` (shared +header/footer/theme). `LessonView` dispatches each block → `Markdown` | `SpecProgression` | +`LandingChart`. `SpecProgression` renders each stage's spec with `formatSpec` +(`core/json-format`) and highlights what the stage changed with `changedLines` +(`core/spec-diff`, an LCS line-diff). `parseLesson` also returns `datasets` (the `:::data` +blocks); `LessonView`/`SpecProgression` call `injectDatasets` so only the _rendered_ spec +carries the rows — the displayed-and-diffed spec keeps its by-name reference. ## Rules @@ -52,3 +69,7 @@ highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS us), never user input — not an XSS surface. - Lesson specs are fenced JSON parsed with `JSON.parse`; the source pane re-formats them with `formatSpec` so the shown JSON matches the editor's house style. +- **Lesson charts render through `LandingChart` with `fitMode: 'width'`** — which sets + `width: "container"` and drops fixed heights on every view, and container width only works + for a single or layered view, not side-by-side. A multi-view lesson is therefore a + `vconcat` (a stacked column), not an `hconcat` dashboard, which would fight the sizing. diff --git a/scripts/learn-pages.ts b/scripts/learn-pages.ts new file mode 100644 index 0000000..869811b --- /dev/null +++ b/scripts/learn-pages.ts @@ -0,0 +1,93 @@ +/** + * Generate one static `learn/<slug>/index.html` shell per lesson, so each lesson is + * its own indexable URL (`/learn/<slug>/`) carrying lesson-specific `<title>` and + * `<meta description>`. The shared `src/learn` entry renders the right view from the + * path. Driven by the lesson `.md` frontmatter — adding a lesson stays "drop a + * file" — and run from `vite.config.ts` at load (dev and build) so the shells stay + * in sync; the generated dirs are git-ignored. + * + * Plain content-shells only (per-URL metadata, client-rendered body). Prerendering + * the prose into the HTML would help non-JS crawlers but is deliberately deferred. + */ +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const ROOT = new URL('../', import.meta.url); +const LESSONS_DIR = fileURLToPath(new URL('src/learn/lessons/', ROOT)); +const LEARN_DIR = fileURLToPath(new URL('learn/', ROOT)); + +interface LessonMeta { + slug: string; + title: string; + tagline: string; +} + +// A minimal frontmatter reader, separate from `core/lesson-parse`'s `parseLesson`: +// this runs at vite-config load time, before the `@core` alias is registered, and +// only needs the three header fields (not the parsed body). The two must agree on +// the frontmatter shape — both read `slug`/`title`/`tagline` from `--- … ---`. +/** Pull `slug`/`title`/`tagline` from a lesson's `--- … ---` frontmatter. */ +function frontmatter(md: string): LessonMeta | null { + const block = md.match(/^---\n([\s\S]*?)\n---/); + if (!block) return null; + const field = (key: string): string | undefined => + block[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1].trim(); + const slug = field('slug'); + const title = field('title'); + const tagline = field('tagline'); + return slug && title && tagline ? { slug, title, tagline } : null; +} + +/** Lesson metadata for every `src/learn/lessons/*.md`, sorted by slug. */ +function readLessons(): LessonMeta[] { + return readdirSync(LESSONS_DIR) + .filter((f) => f.endsWith('.md')) + .map((f) => frontmatter(readFileSync(LESSONS_DIR + f, 'utf8'))) + .filter((l): l is LessonMeta => l !== null) + .sort((a, b) => a.slug.localeCompare(b.slug)); +} + +/** Rollup inputs (one per lesson) → the generated `learn/<slug>/index.html` shells. */ +export function lessonInputs(): Record<string, string> { + const input: Record<string, string> = {}; + for (const { slug } of readLessons()) input[`learn-${slug}`] = `${LEARN_DIR}${slug}/index.html`; + return input; +} + +const escapeHtml = (s: string): string => + s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + +function shell({ title, tagline }: LessonMeta): string { + return `<!doctype html> +<html lang="en" data-theme="light"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" /> + <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="description" content="${escapeHtml(tagline)}" /> + <title>${escapeHtml(title)} — Astrolabe + + +
+ + + +`; +} + +/** Write a shell per lesson and prune dirs for lessons that no longer exist. */ +export function generateLearnPages(): void { + const lessons = readLessons(); + const wanted = new Set(lessons.map((l) => l.slug)); + for (const lesson of lessons) { + mkdirSync(`${LEARN_DIR}${lesson.slug}/`, { recursive: true }); + writeFileSync(`${LEARN_DIR}${lesson.slug}/index.html`, shell(lesson)); + } + for (const entry of readdirSync(LEARN_DIR, { withFileTypes: true })) { + if (entry.isDirectory() && !wanted.has(entry.name)) { + rmSync(`${LEARN_DIR}${entry.name}`, { recursive: true, force: true }); + } + } +} diff --git a/src/core/lesson-parse.test.ts b/src/core/lesson-parse.test.ts index 7cfdd47..93e8783 100644 --- a/src/core/lesson-parse.test.ts +++ b/src/core/lesson-parse.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseLesson, type LessonBlock } from './lesson-parse'; +import { injectDatasets, parseLesson, type LessonBlock } from './lesson-parse'; const LESSON = `--- slug: binning @@ -105,6 +105,12 @@ describe('parseLesson — block model', () => { expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/); }); + it('throws on a slug outside [a-z0-9-] (router/shell contract)', () => { + expect(() => parseLesson(`---\nslug: Bad_Slug\ntitle: t\ntagline: g\n---\nhi`)).toThrow( + /slug must be lowercase/, + ); + }); + it('throws with stage context on invalid JSON', () => { const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::progression\n## a\n\`\`\`vega-lite\n{ bad }\n\`\`\`\n:::`; expect(() => parseLesson(bad)).toThrow(/stage "a": invalid JSON/); @@ -124,4 +130,76 @@ describe('parseLesson — block model', () => { 'callout', ]); }); + + it('defaults to no datasets when there is no :::data block', () => { + expect(parseLesson(LESSON).datasets).toEqual({}); + }); +}); + +describe('parseLesson — :::data blocks', () => { + const withData = `---\nslug: x\ntitle: t\ntagline: g\n--- +:::data +{ "ev": [{ "arm": "A", "n": 1 }, { "arm": "B", "n": 2 }] } +::: + +A chart that references it by name: + +\`\`\`vega-lite +{ "data": { "name": "ev" }, "mark": "bar" } +\`\`\``; + + it('collects named rows into lesson.datasets, not as a block', () => { + const lesson = parseLesson(withData); + expect(lesson.datasets).toEqual({ + ev: [ + { arm: 'A', n: 1 }, + { arm: 'B', n: 2 }, + ], + }); + // The data directive is metadata — only the prose + chart are blocks. + expect(types(lesson.blocks)).toEqual(['prose', 'chart']); + }); + + it('merges multiple :::data blocks', () => { + const two = `---\nslug: x\ntitle: t\ntagline: g\n--- +:::data +{ "a": [{ "v": 1 }] } +::: +:::data +{ "b": [{ "v": 2 }] } +::: +Words.`; + expect(parseLesson(two).datasets).toEqual({ a: [{ v: 1 }], b: [{ v: 2 }] }); + }); + + it('throws on a non-object :::data body', () => { + const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n[1, 2]\n:::`; + expect(() => parseLesson(bad)).toThrow(/:::data: body must be a JSON object/); + }); + + it('throws when a dataset value is not an array', () => { + const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n{ "ev": 5 }\n:::\nhi`; + expect(() => parseLesson(bad)).toThrow(/:::data "ev": value must be an array/); + }); +}); + +describe('injectDatasets', () => { + const datasets = { ev: [{ n: 1 }] }; + + it('merges lesson datasets into a clone, leaving the source spec untouched', () => { + const spec = { data: { name: 'ev' }, mark: 'bar' }; + const out = injectDatasets(spec, datasets); + expect(out).toEqual({ data: { name: 'ev' }, mark: 'bar', datasets: { ev: [{ n: 1 }] } }); + expect(spec).not.toHaveProperty('datasets'); + }); + + it("lets a spec's own datasets win on a name clash", () => { + const spec = { datasets: { ev: [{ n: 99 }] }, mark: 'bar' }; + expect(injectDatasets(spec, datasets).datasets).toEqual({ ev: [{ n: 99 }] }); + }); + + it('returns the spec unchanged when there are no datasets', () => { + const spec = { mark: 'bar' }; + expect(injectDatasets(spec, {})).toBe(spec); + }); }); diff --git a/src/core/lesson-parse.ts b/src/core/lesson-parse.ts index 8f87824..38fa88e 100644 --- a/src/core/lesson-parse.ts +++ b/src/core/lesson-parse.ts @@ -22,11 +22,15 @@ * * Free intro prose (markdown)… * + * :::data + * { "ev": [ {…row…}, {…row…} ] } + * ::: + * * :::progression * ## raw counts * Note prose for this stage (markdown)… * ```vega-lite - * { …spec… } + * { "data": { "name": "ev" }, …spec… } * ``` * ## + bin * … @@ -42,10 +46,13 @@ * A callout (markdown)… * ::: * - * A `:::progression` block holds `##`-delimited stages (heading → tab label, prose - * → note, fenced `vega-lite` → spec). A bare fenced `vega-lite` block is a - * standalone chart. Any other `:::name` block is a callout. Everything else is - * prose, emitted in the order it appears. + * A `:::data` block names datasets once for the whole lesson (body: a JSON object + * of `name → rows`); specs reference them by `{ "name": … }` and the rows are + * injected at render time, so they aren't repeated per stage. A `:::progression` + * block holds `##`-delimited stages (heading → tab label, prose → note, fenced + * `vega-lite` → spec). A bare fenced `vega-lite` block is a standalone chart. Any + * other `:::name` block is a callout. Everything else is prose, emitted in the + * order it appears. */ export interface ProgressionStage { @@ -81,6 +88,12 @@ export interface ParsedLesson { slug: string; title: string; tagline: string; + /** + * Lesson-level named datasets, collected from `:::data` blocks. Specs reference + * a dataset by `{ "data": { "name": … } }`; {@link injectDatasets} merges these + * in at render time so the rows aren't repeated in every fenced spec. + */ + datasets: Record; /** The lesson body, in document order. */ blocks: LessonBlock[]; } @@ -100,14 +113,25 @@ export function parseLesson(source: string): ParsedLesson { if (!fm) throw new Error('lesson: missing `---` frontmatter block'); const meta = parseFrontmatter(fm[1]); const slug = required(meta, 'slug'); + // The slug is the lesson's URL segment (`/learn//`) and the route key; keep it + // to the charset the router and the page-shell generator both assume, so a typo fails + // loudly here rather than silently serving the wrong page. + if (!/^[a-z0-9-]+$/.test(slug)) { + throw new Error(`lesson "${slug}": slug must be lowercase letters, digits, and hyphens`); + } const title = required(meta, 'title'); const tagline = required(meta, 'tagline'); - return { slug, title, tagline, blocks: parseBlocks(text.slice(fm[0].length), slug) }; + const { blocks, datasets } = parseBlocks(text.slice(fm[0].length), slug); + return { slug, title, tagline, datasets, blocks }; } -function parseBlocks(body: string, slug: string): LessonBlock[] { +function parseBlocks( + body: string, + slug: string, +): { blocks: LessonBlock[]; datasets: Record } { const blocks: LessonBlock[] = []; + const datasets: Record = {}; const pushProse = (text: string): void => { const trimmed = text.trim(); if (trimmed) blocks.push({ type: 'prose', markdown: trimmed }); @@ -121,13 +145,15 @@ function parseBlocks(body: string, slug: string): LessonBlock[] { if (m[1] !== undefined) { const variant = m[1]; const inner = m[2]; - // TODO: if lessons multiply, add a `:::data` directive whose values stages - // inherit — today a lesson's inline data is repeated in every fenced spec. - blocks.push( - variant === 'progression' - ? { type: 'progression', stages: parseStages(inner, slug) } - : { type: 'callout', variant, markdown: inner.trim() }, - ); + if (variant === 'progression') { + blocks.push({ type: 'progression', stages: parseStages(inner, slug) }); + } else if (variant === 'data') { + // Lesson metadata, not a rendered block: fold the named rows into the + // lesson's dataset map for render-time injection. + Object.assign(datasets, parseDatasets(inner, slug)); + } else { + blocks.push({ type: 'callout', variant, markdown: inner.trim() }); + } } else { blocks.push({ type: 'chart', spec: parseSpec(m[3], slug, 'chart') }); } @@ -136,7 +162,47 @@ function parseBlocks(body: string, slug: string): LessonBlock[] { pushProse(body.slice(cursor)); if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`); - return blocks; + return { blocks, datasets }; +} + +/** Parse a `:::data` body — a JSON object of `name → rows[]` — into named datasets. */ +function parseDatasets(inner: string, slug: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(inner.trim()); + } catch (e) { + throw new Error(`lesson "${slug}", :::data: invalid JSON — ${(e as Error).message}`, { + cause: e, + }); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`lesson "${slug}", :::data: body must be a JSON object of { name: rows }`); + } + const out: Record = {}; + for (const [name, rows] of Object.entries(parsed)) { + if (!Array.isArray(rows)) { + throw new Error(`lesson "${slug}", :::data "${name}": value must be an array of rows`); + } + out[name] = rows; + } + return out; +} + +/** + * Merge lesson-level named datasets into a spec for *rendering* — the authored + * spec (shown in the source pane and diffed) keeps its `{ "data": { "name": … } }` + * reference and never carries the rows. Returns a clone with the spec's own + * `datasets` winning on a name clash; mirrors the clone-before-embed rule the app's + * other render-time spec tweaks follow (never mutate the authored spec). A no-op + * (same reference) when there are no datasets to inject. + */ +export function injectDatasets( + spec: Record, + datasets: Record, +): Record { + if (Object.keys(datasets).length === 0) return spec; + const own = (spec.datasets as Record | undefined) ?? {}; + return { ...spec, datasets: { ...datasets, ...own } }; } function parseStages(inner: string, slug: string): ProgressionStage[] { diff --git a/src/landing/Landing.tsx b/src/landing/Landing.tsx index d8488de..12621a7 100644 --- a/src/landing/Landing.tsx +++ b/src/landing/Landing.tsx @@ -353,6 +353,9 @@ export function Landing(): ReactNode { Theming + + Deep dives + @@ -648,6 +651,7 @@ export function Landing(): ReactNode { Open the app + Deep dives Built on Vega-Lite diff --git a/src/learn/LearnPage.module.css b/src/learn/Learn.module.css similarity index 69% rename from src/learn/LearnPage.module.css rename to src/learn/Learn.module.css index cc64378..a0e910f 100644 --- a/src/learn/LearnPage.module.css +++ b/src/learn/Learn.module.css @@ -27,10 +27,13 @@ align-items: center; gap: var(--space-4); } +/* A subtle link to the section index (`/learn/`); resets the default underline to + match the header's other links (.brand, .appLink). */ .kicker { font-size: 12px; color: var(--text-secondary); letter-spacing: 0.02em; + text-decoration: none; } .ghost { height: var(--control-height); @@ -190,6 +193,86 @@ color: var(--text-secondary); } +/* ---- index (lesson list at /learn/) ---- */ +.index { + width: 100%; + max-width: 760px; +} +.indexLede { + margin: 0 0 var(--space-4); + max-width: 64ch; + font-size: 17px; + line-height: 1.5; + color: var(--text-secondary); +} +.indexAside { + margin: 0 0 var(--space-7); + max-width: 64ch; + font-size: 14px; + line-height: 1.55; + color: var(--text-secondary); +} +.indexAside a { + color: var(--accent); +} +.cards { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-5); +} +.card { + display: block; + padding: var(--space-6); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--layer-01); + color: inherit; + text-decoration: none; +} +.card:hover { + border-color: var(--border-strong); + background: var(--control-hover-fill); +} +.cardKicker { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} +.cardTitle { + margin: var(--space-2) 0; + font-size: 22px; + font-weight: 600; + color: var(--text); +} +.cardTagline { + margin: 0 0 var(--space-4); + font-size: 15px; + line-height: 1.55; + color: var(--text-secondary); +} +.cardMore { + font-size: 14px; + font-weight: 500; + color: var(--accent); +} + +/* ---- back link (lesson → index) ---- */ +.backLink { + display: inline-block; + margin: 0 0 var(--space-5); + font-size: 13px; + color: var(--text-secondary); + text-decoration: none; +} +.backLink:hover { + color: var(--text); +} + @media (max-width: 720px) { .title { font-size: 24px; diff --git a/src/learn/LearnIndex.tsx b/src/learn/LearnIndex.tsx new file mode 100644 index 0000000..f0becd9 --- /dev/null +++ b/src/learn/LearnIndex.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from 'react'; +import type { ParsedLesson } from '@core/lesson-parse'; +import styles from './Learn.module.css'; + +/** + * The `/learn/` index: the section intro plus a card per lesson linking to its own + * page (`/learn//`). Lessons arrive from the markdown glob (see `lessons.ts`); + * each card's URL matches a generated shell. + */ +export function LearnIndex({ lessons }: { lessons: ParsedLesson[] }): ReactNode { + return ( +
+

Deep dives

+

Vega-Lite, deeper

+

+ Advanced Vega-Lite, worked end to end: linked selections, composition, and the transforms + that do the real work. Each page starts from a spec that already renders and takes it + somewhere the reference docs stop short of. +

+

+ New to Vega-Lite? The{' '} + + official docs + {' '} + and{' '} + + getting-started tutorial + {' '} + cover the basics — these pages pick up after that. +

+ +
+ ); +} diff --git a/src/learn/LearnLayout.tsx b/src/learn/LearnLayout.tsx new file mode 100644 index 0000000..a8f6ac7 --- /dev/null +++ b/src/learn/LearnLayout.tsx @@ -0,0 +1,60 @@ +import { useMemo, useState, type ReactNode } from 'react'; +import { chartConfigForSelection } from '@core/vega-themes'; +import type { UiTheme } from '@core/theme'; +import styles from './Learn.module.css'; + +type ChartConfig = ReturnType; + +/** + * Shared chrome for the learning section (header, footer, light/dark theme) wrapping + * either the lesson index or one lesson. The theme state lives here because both the + * header toggle and the chart config depend on it; it's handed to the page via a + * render prop so a lesson's charts re-theme with the toggle. + * + * Theme handling mirrors the landing (light/dark via `data-theme` on the root), + * duplicated rather than shared because the two entries are independent pages. + */ +export function LearnLayout({ + children, +}: { + children: (config: ChartConfig) => ReactNode; +}): ReactNode { + const [theme, setTheme] = useState('light'); + const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]); + + // TODO: this light/dark toggle is duplicated in landing/Landing.tsx — extract a + // shared `useUiTheme()` hook into a home both marketing entries can import. + function toggleTheme(): void { + const next: UiTheme = theme === 'dark' ? 'light' : 'dark'; + setTheme(next); + document.documentElement.dataset.theme = next; + } + + return ( +
+
+ + Astrolabe + + +
+ +
{children(config)}
+ + +
+ ); +} diff --git a/src/learn/LearnPage.tsx b/src/learn/LearnPage.tsx deleted file mode 100644 index 84901b8..0000000 --- a/src/learn/LearnPage.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useMemo, useState, type ReactNode } from 'react'; -import { chartConfigForSelection } from '@core/vega-themes'; -import type { UiTheme } from '@core/theme'; -import { parseLesson, type LessonBlock, type ParsedLesson } from '@core/lesson-parse'; -import { SpecProgression } from './SpecProgression'; -import { LandingChart } from '../landing/LandingChart'; -import { Markdown } from './Markdown'; -import styles from './LearnPage.module.css'; - -// Lessons are markdown files: drop a `.md` in lessons/ and it's picked up here — -// no registry to edit. Loaded raw at build (Vite glob) and parsed by the pure -// core parser into ordered blocks; the prose renders to HTML in . -const LESSON_SOURCES = import.meta.glob('./lessons/*.md', { - query: '?raw', - import: 'default', - eager: true, -}); - -const LESSONS: ParsedLesson[] = Object.values(LESSON_SOURCES).map(parseLesson); - -/** - * The deep-dive learning section — a marketing surface at `/learn/`, separate - * from the app, reusing only core + the landing's chart embed. One lesson for - * now; the glob above means more arrive by adding files. - * - * Theme handling mirrors the landing (light/dark via `data-theme` on the root). - * It's duplicated rather than shared because the two entries are independent - * pages; a `useUiTheme` hook could unify them if a third surface appears. - */ -export function LearnPage(): ReactNode { - const [theme, setTheme] = useState('light'); - const config = useMemo(() => chartConfigForSelection('astrolabe', theme), [theme]); - - // TODO: this light/dark toggle is now duplicated verbatim in landing/Landing.tsx — - // extract a shared `useUiTheme()` hook into a home both marketing entries can import - // (not src/app/hooks, which is app-only). - function toggleTheme(): void { - const next: UiTheme = theme === 'dark' ? 'light' : 'dark'; - setTheme(next); - document.documentElement.dataset.theme = next; - } - - return ( -
-
- - Astrolabe - - -
- -
- {LESSONS.map((lesson) => ( - - ))} -
- - -
- ); -} - -type ChartConfig = ReturnType; - -function LessonArticle({ - lesson, - config, -}: { - lesson: ParsedLesson; - config: ChartConfig; -}): ReactNode { - return ( -
-

Deep dive

-

{lesson.title}

-

{lesson.tagline}

- - {lesson.blocks.map((block, i) => ( - - ))} - -
-

- Open this spec in Astrolabe and keep tweaking — edit the JSON, swap the data, theme it. -

- - Open in Astrolabe → - -
-
- ); -} - -/** Render one lesson block by type — the document is assembled in this order. */ -function LessonBlockView({ - block, - config, -}: { - block: LessonBlock; - config: ChartConfig; -}): ReactNode { - switch (block.type) { - case 'prose': - return ; - case 'progression': - return ( -
- -
- ); - case 'chart': - return ( -
- -
- ); - case 'callout': - return ( - - ); - default: { - const exhaustive: never = block; - return exhaustive; - } - } -} diff --git a/src/learn/LessonView.tsx b/src/learn/LessonView.tsx new file mode 100644 index 0000000..26d75c5 --- /dev/null +++ b/src/learn/LessonView.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from 'react'; +import { injectDatasets, type LessonBlock, type ParsedLesson } from '@core/lesson-parse'; +import { chartConfigForSelection } from '@core/vega-themes'; +import { SpecProgression } from './SpecProgression'; +import { LandingChart } from '../landing/LandingChart'; +import { Markdown } from './Markdown'; +import styles from './Learn.module.css'; + +type ChartConfig = ReturnType; + +/** One lesson, rendered from its parsed blocks in document order, at `/learn//`. */ +export function LessonView({ + lesson, + config, +}: { + lesson: ParsedLesson; + config: ChartConfig; +}): ReactNode { + return ( + + ); +} + +/** Render one lesson block by type — the document is assembled in this order. */ +function LessonBlockView({ + block, + datasets, + config, +}: { + block: LessonBlock; + datasets: Record; + config: ChartConfig; +}): ReactNode { + switch (block.type) { + case 'prose': + return ; + case 'progression': + return ( +
+ +
+ ); + case 'chart': + return ( +
+ +
+ ); + case 'callout': + return ( + + ); + default: { + const exhaustive: never = block; + return exhaustive; + } + } +} diff --git a/src/learn/SpecProgression.tsx b/src/learn/SpecProgression.tsx index b825be5..9cf1454 100644 --- a/src/learn/SpecProgression.tsx +++ b/src/learn/SpecProgression.tsx @@ -1,6 +1,6 @@ import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; import type { Config } from 'vega-lite'; -import type { ProgressionStage } from '@core/lesson-parse'; +import { injectDatasets, type ProgressionStage } from '@core/lesson-parse'; import { formatSpec } from '@core/json-format'; import { changedLines } from '@core/spec-diff'; import { LandingChart } from '../landing/LandingChart'; @@ -14,6 +14,10 @@ import styles from './SpecProgression.module.css'; * `LandingChart` — the one canonical chart-embed — so Vega stays lazy-loaded and * every render-lifecycle fix lives in one place. * + * The source pane and diff show the *authored* spec (data referenced by name); + * only the rendered chart gets the lesson's `datasets` injected, so a shared + * dataset isn't repeated in every stage's visible source. + * * Stages are a fixed, ordered authored list that never reorders, so the array * index is their identity (React key + tab/panel id wiring). * @@ -22,9 +26,11 @@ import styles from './SpecProgression.module.css'; */ export function SpecProgression({ stages, + datasets, config, }: { stages: ProgressionStage[]; + datasets: Record; config: Config; }): ReactNode { const [active, setActive] = useState(0); @@ -108,7 +114,11 @@ export function SpecProgression({
- +
diff --git a/src/learn/lessons.ts b/src/learn/lessons.ts new file mode 100644 index 0000000..e1affda --- /dev/null +++ b/src/learn/lessons.ts @@ -0,0 +1,17 @@ +import { parseLesson, type ParsedLesson } from '@core/lesson-parse'; + +// Lessons are markdown files: drop a `.md` in lessons/ and it's picked up here — +// no registry to edit. Loaded raw at build (Vite glob) and parsed by the pure core +// parser into ordered blocks. The per-lesson HTML shells (one indexable URL each) +// are generated from the same files in scripts/learn-pages.ts; both read the lesson +// frontmatter, so the routes and the rendered set stay in agreement. +const SOURCES = import.meta.glob('./lessons/*.md', { + query: '?raw', + import: 'default', + eager: true, +}); + +/** Every parsed lesson, ordered by file path (stable, matches the route generator). */ +export const LESSONS: ParsedLesson[] = Object.entries(SOURCES) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, source]) => parseLesson(source)); diff --git a/src/learn/lessons/linked-views.md b/src/learn/lessons/linked-views.md new file mode 100644 index 0000000..68e61f5 --- /dev/null +++ b/src/learn/lessons/linked-views.md @@ -0,0 +1,1185 @@ +--- +slug: linked-views +title: Brush one chart, drive the rest +tagline: An A/B run, three linked views, one interval selection — how a brush in one chart recomputes the others, and the one place it has to live. +--- + +An A/B test only means something if the randomiser behind it split traffic evenly. The +standard check is a chart of cumulative assignments per arm: two lines that ought to climb +together. When they drift apart, the sharper question is _when_ — was the split skewed all +along, or only early, while the bucketing settled? A static chart can't say; you'd +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 +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 +`param`. + +:::data +{ +"ev": [ +{"minute":"2026-06-25T07:19","arm":"B","n":2}, +{"minute":"2026-06-25T07:20","arm":"A","n":1}, +{"minute":"2026-06-25T07:21","arm":"B","n":2}, +{"minute":"2026-06-25T07:22","arm":"A","n":1}, +{"minute":"2026-06-25T07:23","arm":"A","n":1}, +{"minute":"2026-06-25T07:24","arm":"A","n":2}, +{"minute":"2026-06-25T07:24","arm":"B","n":1}, +{"minute":"2026-06-25T07:25","arm":"A","n":1}, +{"minute":"2026-06-25T07:26","arm":"A","n":2}, +{"minute":"2026-06-25T07:27","arm":"A","n":2}, +{"minute":"2026-06-25T07:28","arm":"B","n":1}, +{"minute":"2026-06-25T07:30","arm":"A","n":3}, +{"minute":"2026-06-25T07:32","arm":"A","n":2}, +{"minute":"2026-06-25T07:33","arm":"A","n":1}, +{"minute":"2026-06-25T07:33","arm":"B","n":1}, +{"minute":"2026-06-25T07:37","arm":"A","n":2}, +{"minute":"2026-06-25T07:38","arm":"A","n":2}, +{"minute":"2026-06-25T07:40","arm":"A","n":1}, +{"minute":"2026-06-25T07:41","arm":"A","n":1}, +{"minute":"2026-06-25T07:43","arm":"B","n":1}, +{"minute":"2026-06-25T07:44","arm":"B","n":1}, +{"minute":"2026-06-25T07:45","arm":"B","n":1}, +{"minute":"2026-06-25T07:46","arm":"A","n":2}, +{"minute":"2026-06-25T07:46","arm":"B","n":1}, +{"minute":"2026-06-25T07:48","arm":"A","n":1}, +{"minute":"2026-06-25T07:49","arm":"A","n":1}, +{"minute":"2026-06-25T07:52","arm":"A","n":2}, +{"minute":"2026-06-25T07:52","arm":"B","n":1}, +{"minute":"2026-06-25T07:53","arm":"B","n":1}, +{"minute":"2026-06-25T07:54","arm":"A","n":1}, +{"minute":"2026-06-25T07:54","arm":"B","n":1}, +{"minute":"2026-06-25T07:55","arm":"A","n":2}, +{"minute":"2026-06-25T07:55","arm":"B","n":1}, +{"minute":"2026-06-25T08:00","arm":"B","n":1}, +{"minute":"2026-06-25T08:02","arm":"A","n":1}, +{"minute":"2026-06-25T08:02","arm":"B","n":1}, +{"minute":"2026-06-25T08:04","arm":"A","n":3}, +{"minute":"2026-06-25T08:05","arm":"B","n":2}, +{"minute":"2026-06-25T08:06","arm":"B","n":1}, +{"minute":"2026-06-25T08:08","arm":"A","n":1}, +{"minute":"2026-06-25T08:09","arm":"A","n":3}, +{"minute":"2026-06-25T08:10","arm":"A","n":1}, +{"minute":"2026-06-25T08:13","arm":"A","n":2}, +{"minute":"2026-06-25T08:14","arm":"B","n":1}, +{"minute":"2026-06-25T08:15","arm":"B","n":1}, +{"minute":"2026-06-25T08:17","arm":"B","n":1}, +{"minute":"2026-06-25T08:18","arm":"B","n":1}, +{"minute":"2026-06-25T08:19","arm":"A","n":1}, +{"minute":"2026-06-25T08:20","arm":"A","n":1}, +{"minute":"2026-06-25T08:20","arm":"B","n":1}, +{"minute":"2026-06-25T08:21","arm":"A","n":1}, +{"minute":"2026-06-25T08:24","arm":"A","n":1}, +{"minute":"2026-06-25T08:24","arm":"B","n":1}, +{"minute":"2026-06-25T08:29","arm":"B","n":1}, +{"minute":"2026-06-25T08:31","arm":"A","n":1}, +{"minute":"2026-06-25T08:32","arm":"B","n":1}, +{"minute":"2026-06-25T08:33","arm":"A","n":1}, +{"minute":"2026-06-25T08:35","arm":"B","n":2}, +{"minute":"2026-06-25T08:37","arm":"A","n":1}, +{"minute":"2026-06-25T08:42","arm":"A","n":2}, +{"minute":"2026-06-25T08:43","arm":"B","n":1}, +{"minute":"2026-06-25T08:45","arm":"A","n":1}, +{"minute":"2026-06-25T08:45","arm":"B","n":1}, +{"minute":"2026-06-25T08:46","arm":"A","n":1}, +{"minute":"2026-06-25T08:48","arm":"A","n":1}, +{"minute":"2026-06-25T08:48","arm":"B","n":1}, +{"minute":"2026-06-25T08:50","arm":"A","n":1}, +{"minute":"2026-06-25T08:51","arm":"A","n":1}, +{"minute":"2026-06-25T08:52","arm":"A","n":1}, +{"minute":"2026-06-25T08:53","arm":"A","n":1}, +{"minute":"2026-06-25T08:53","arm":"B","n":2}, +{"minute":"2026-06-25T08:55","arm":"A","n":1}, +{"minute":"2026-06-25T08:56","arm":"A","n":2}, +{"minute":"2026-06-25T08:57","arm":"A","n":1}, +{"minute":"2026-06-25T08:59","arm":"A","n":2}, +{"minute":"2026-06-25T09:00","arm":"A","n":1}, +{"minute":"2026-06-25T09:00","arm":"B","n":1}, +{"minute":"2026-06-25T09:01","arm":"A","n":1}, +{"minute":"2026-06-25T09:02","arm":"A","n":1}, +{"minute":"2026-06-25T09:03","arm":"A","n":1}, +{"minute":"2026-06-25T09:03","arm":"B","n":1}, +{"minute":"2026-06-25T09:04","arm":"A","n":1}, +{"minute":"2026-06-25T09:04","arm":"B","n":1}, +{"minute":"2026-06-25T09:05","arm":"A","n":2}, +{"minute":"2026-06-25T09:06","arm":"A","n":1}, +{"minute":"2026-06-25T09:08","arm":"A","n":1}, +{"minute":"2026-06-25T09:11","arm":"B","n":1}, +{"minute":"2026-06-25T09:12","arm":"A","n":1}, +{"minute":"2026-06-25T09:12","arm":"B","n":1}, +{"minute":"2026-06-25T09:14","arm":"B","n":1}, +{"minute":"2026-06-25T09:15","arm":"A","n":2}, +{"minute":"2026-06-25T09:15","arm":"B","n":2}, +{"minute":"2026-06-25T09:16","arm":"A","n":1}, +{"minute":"2026-06-25T09:17","arm":"B","n":1}, +{"minute":"2026-06-25T09:18","arm":"A","n":2}, +{"minute":"2026-06-25T09:19","arm":"B","n":1}, +{"minute":"2026-06-25T09:20","arm":"A","n":2}, +{"minute":"2026-06-25T09:20","arm":"B","n":1}, +{"minute":"2026-06-25T09:21","arm":"A","n":1}, +{"minute":"2026-06-25T09:23","arm":"B","n":4}, +{"minute":"2026-06-25T09:24","arm":"B","n":1}, +{"minute":"2026-06-25T09:27","arm":"B","n":1}, +{"minute":"2026-06-25T09:30","arm":"B","n":1}, +{"minute":"2026-06-25T09:31","arm":"A","n":1}, +{"minute":"2026-06-25T09:34","arm":"B","n":1}, +{"minute":"2026-06-25T09:35","arm":"A","n":3}, +{"minute":"2026-06-25T09:37","arm":"B","n":2}, +{"minute":"2026-06-25T09:39","arm":"B","n":1}, +{"minute":"2026-06-25T09:41","arm":"A","n":1}, +{"minute":"2026-06-25T09:42","arm":"A","n":2}, +{"minute":"2026-06-25T09:42","arm":"B","n":1}, +{"minute":"2026-06-25T09:43","arm":"A","n":1}, +{"minute":"2026-06-25T09:44","arm":"A","n":1}, +{"minute":"2026-06-25T09:44","arm":"B","n":2}, +{"minute":"2026-06-25T09:46","arm":"B","n":1}, +{"minute":"2026-06-25T09:47","arm":"B","n":1}, +{"minute":"2026-06-25T09:49","arm":"A","n":1}, +{"minute":"2026-06-25T09:50","arm":"A","n":1}, +{"minute":"2026-06-25T09:51","arm":"B","n":1}, +{"minute":"2026-06-25T09:52","arm":"A","n":1}, +{"minute":"2026-06-25T09:55","arm":"A","n":2}, +{"minute":"2026-06-25T09:55","arm":"B","n":1}, +{"minute":"2026-06-25T09:56","arm":"B","n":2}, +{"minute":"2026-06-25T09:57","arm":"A","n":1}, +{"minute":"2026-06-25T10:00","arm":"A","n":1}, +{"minute":"2026-06-25T10:03","arm":"B","n":1}, +{"minute":"2026-06-25T10:04","arm":"B","n":3}, +{"minute":"2026-06-25T10:05","arm":"A","n":1}, +{"minute":"2026-06-25T10:05","arm":"B","n":1}, +{"minute":"2026-06-25T10:07","arm":"A","n":1}, +{"minute":"2026-06-25T10:08","arm":"A","n":1}, +{"minute":"2026-06-25T10:08","arm":"B","n":2}, +{"minute":"2026-06-25T10:09","arm":"B","n":1}, +{"minute":"2026-06-25T10:10","arm":"A","n":2}, +{"minute":"2026-06-25T10:11","arm":"B","n":3}, +{"minute":"2026-06-25T10:12","arm":"A","n":1}, +{"minute":"2026-06-25T10:12","arm":"B","n":1}, +{"minute":"2026-06-25T10:13","arm":"A","n":2}, +{"minute":"2026-06-25T10:13","arm":"B","n":1}, +{"minute":"2026-06-25T10:15","arm":"B","n":1}, +{"minute":"2026-06-25T10:16","arm":"A","n":1}, +{"minute":"2026-06-25T10:17","arm":"A","n":1}, +{"minute":"2026-06-25T10:19","arm":"A","n":1}, +{"minute":"2026-06-25T10:21","arm":"B","n":1}, +{"minute":"2026-06-25T10:22","arm":"B","n":1}, +{"minute":"2026-06-25T10:23","arm":"A","n":2}, +{"minute":"2026-06-25T10:24","arm":"B","n":1}, +{"minute":"2026-06-25T10:25","arm":"A","n":1}, +{"minute":"2026-06-25T10:27","arm":"B","n":1}, +{"minute":"2026-06-25T10:29","arm":"A","n":1}, +{"minute":"2026-06-25T10:29","arm":"B","n":1}, +{"minute":"2026-06-25T10:30","arm":"A","n":1}, +{"minute":"2026-06-25T10:33","arm":"A","n":1}, +{"minute":"2026-06-25T10:33","arm":"B","n":1}, +{"minute":"2026-06-25T10:34","arm":"A","n":2}, +{"minute":"2026-06-25T10:35","arm":"B","n":1}, +{"minute":"2026-06-25T10:39","arm":"B","n":1}, +{"minute":"2026-06-25T10:42","arm":"A","n":2}, +{"minute":"2026-06-25T10:43","arm":"B","n":1}, +{"minute":"2026-06-25T10:44","arm":"A","n":1}, +{"minute":"2026-06-25T10:45","arm":"A","n":1}, +{"minute":"2026-06-25T10:47","arm":"B","n":1}, +{"minute":"2026-06-25T10:49","arm":"A","n":1}, +{"minute":"2026-06-25T10:50","arm":"A","n":2}, +{"minute":"2026-06-25T10:50","arm":"B","n":1}, +{"minute":"2026-06-25T10:51","arm":"A","n":1}, +{"minute":"2026-06-25T10:52","arm":"A","n":1}, +{"minute":"2026-06-25T10:54","arm":"A","n":1}, +{"minute":"2026-06-25T10:56","arm":"B","n":2}, +{"minute":"2026-06-25T10:57","arm":"A","n":1}, +{"minute":"2026-06-25T10:57","arm":"B","n":2}, +{"minute":"2026-06-25T10:58","arm":"A","n":1}, +{"minute":"2026-06-25T10:59","arm":"A","n":1}, +{"minute":"2026-06-25T11:02","arm":"A","n":1}, +{"minute":"2026-06-25T11:02","arm":"B","n":1}, +{"minute":"2026-06-25T11:04","arm":"A","n":2}, +{"minute":"2026-06-25T11:04","arm":"B","n":1}, +{"minute":"2026-06-25T11:10","arm":"B","n":1}, +{"minute":"2026-06-25T11:11","arm":"B","n":1}, +{"minute":"2026-06-25T11:12","arm":"A","n":1}, +{"minute":"2026-06-25T11:13","arm":"A","n":1}, +{"minute":"2026-06-25T11:14","arm":"A","n":2}, +{"minute":"2026-06-25T11:14","arm":"B","n":1}, +{"minute":"2026-06-25T11:18","arm":"A","n":1}, +{"minute":"2026-06-25T11:18","arm":"B","n":1}, +{"minute":"2026-06-25T11:20","arm":"A","n":1}, +{"minute":"2026-06-25T11:20","arm":"B","n":1}, +{"minute":"2026-06-25T11:22","arm":"A","n":1}, +{"minute":"2026-06-25T11:22","arm":"B","n":1}, +{"minute":"2026-06-25T11:23","arm":"A","n":1}, +{"minute":"2026-06-25T11:23","arm":"B","n":3}, +{"minute":"2026-06-25T11:24","arm":"A","n":1}, +{"minute":"2026-06-25T11:27","arm":"A","n":2}, +{"minute":"2026-06-25T11:29","arm":"B","n":1}, +{"minute":"2026-06-25T11:30","arm":"A","n":1}, +{"minute":"2026-06-25T11:30","arm":"B","n":1}, +{"minute":"2026-06-25T11:32","arm":"A","n":1}, +{"minute":"2026-06-25T11:32","arm":"B","n":1}, +{"minute":"2026-06-25T11:33","arm":"B","n":1}, +{"minute":"2026-06-25T11:35","arm":"A","n":1}, +{"minute":"2026-06-25T11:37","arm":"B","n":1}, +{"minute":"2026-06-25T11:39","arm":"A","n":1}, +{"minute":"2026-06-25T11:39","arm":"B","n":1}, +{"minute":"2026-06-25T11:41","arm":"A","n":1}, +{"minute":"2026-06-25T11:41","arm":"B","n":1}, +{"minute":"2026-06-25T11:42","arm":"A","n":3}, +{"minute":"2026-06-25T11:44","arm":"A","n":1}, +{"minute":"2026-06-25T11:46","arm":"A","n":1}, +{"minute":"2026-06-25T11:48","arm":"B","n":1}, +{"minute":"2026-06-25T11:50","arm":"A","n":1}, +{"minute":"2026-06-25T11:51","arm":"A","n":1}, +{"minute":"2026-06-25T11:52","arm":"A","n":1}, +{"minute":"2026-06-25T11:54","arm":"A","n":1}, +{"minute":"2026-06-25T11:55","arm":"B","n":1}, +{"minute":"2026-06-25T11:58","arm":"A","n":1}, +{"minute":"2026-06-25T11:58","arm":"B","n":1}, +{"minute":"2026-06-25T11:59","arm":"A","n":1}, +{"minute":"2026-06-25T11:59","arm":"B","n":1}, +{"minute":"2026-06-25T12:00","arm":"A","n":1}, +{"minute":"2026-06-25T12:02","arm":"B","n":1}, +{"minute":"2026-06-25T12:03","arm":"A","n":1}, +{"minute":"2026-06-25T12:07","arm":"A","n":3}, +{"minute":"2026-06-25T12:08","arm":"A","n":2}, +{"minute":"2026-06-25T12:08","arm":"B","n":1}, +{"minute":"2026-06-25T12:10","arm":"A","n":1}, +{"minute":"2026-06-25T12:12","arm":"A","n":1}, +{"minute":"2026-06-25T12:12","arm":"B","n":1}, +{"minute":"2026-06-25T12:15","arm":"A","n":2}, +{"minute":"2026-06-25T12:16","arm":"A","n":4}, +{"minute":"2026-06-25T12:16","arm":"B","n":1}, +{"minute":"2026-06-25T12:17","arm":"A","n":1}, +{"minute":"2026-06-25T12:18","arm":"B","n":1}, +{"minute":"2026-06-25T12:19","arm":"A","n":3}, +{"minute":"2026-06-25T12:19","arm":"B","n":1}, +{"minute":"2026-06-25T12:21","arm":"A","n":1}, +{"minute":"2026-06-25T12:22","arm":"A","n":1}, +{"minute":"2026-06-25T12:22","arm":"B","n":1}, +{"minute":"2026-06-25T12:23","arm":"A","n":1}, +{"minute":"2026-06-25T12:24","arm":"A","n":1}, +{"minute":"2026-06-25T12:25","arm":"A","n":2}, +{"minute":"2026-06-25T12:25","arm":"B","n":1}, +{"minute":"2026-06-25T12:26","arm":"B","n":1}, +{"minute":"2026-06-25T12:27","arm":"A","n":2}, +{"minute":"2026-06-25T12:27","arm":"B","n":1}, +{"minute":"2026-06-25T12:28","arm":"A","n":1}, +{"minute":"2026-06-25T12:28","arm":"B","n":1}, +{"minute":"2026-06-25T12:29","arm":"B","n":1}, +{"minute":"2026-06-25T12:30","arm":"A","n":1}, +{"minute":"2026-06-25T12:30","arm":"B","n":1}, +{"minute":"2026-06-25T12:31","arm":"A","n":3}, +{"minute":"2026-06-25T12:33","arm":"A","n":2}, +{"minute":"2026-06-25T12:36","arm":"A","n":2}, +{"minute":"2026-06-25T12:37","arm":"B","n":1}, +{"minute":"2026-06-25T12:38","arm":"B","n":2}, +{"minute":"2026-06-25T12:39","arm":"B","n":1}, +{"minute":"2026-06-25T12:41","arm":"A","n":1}, +{"minute":"2026-06-25T12:41","arm":"B","n":1}, +{"minute":"2026-06-25T12:42","arm":"B","n":2}, +{"minute":"2026-06-25T12:44","arm":"A","n":1}, +{"minute":"2026-06-25T12:45","arm":"A","n":1}, +{"minute":"2026-06-25T12:48","arm":"B","n":1}, +{"minute":"2026-06-25T12:49","arm":"A","n":1}, +{"minute":"2026-06-25T12:51","arm":"A","n":1}, +{"minute":"2026-06-25T12:54","arm":"B","n":1}, +{"minute":"2026-06-25T12:55","arm":"A","n":1}, +{"minute":"2026-06-25T12:56","arm":"B","n":1}, +{"minute":"2026-06-25T12:57","arm":"B","n":1}, +{"minute":"2026-06-25T13:00","arm":"B","n":2}, +{"minute":"2026-06-25T13:02","arm":"A","n":1}, +{"minute":"2026-06-25T13:02","arm":"B","n":1}, +{"minute":"2026-06-25T13:04","arm":"B","n":1}, +{"minute":"2026-06-25T13:06","arm":"B","n":1}, +{"minute":"2026-06-25T13:08","arm":"B","n":1}, +{"minute":"2026-06-25T13:09","arm":"B","n":1}, +{"minute":"2026-06-25T13:11","arm":"A","n":1}, +{"minute":"2026-06-25T13:12","arm":"B","n":1}, +{"minute":"2026-06-25T13:13","arm":"A","n":1}, +{"minute":"2026-06-25T13:13","arm":"B","n":1}, +{"minute":"2026-06-25T13:14","arm":"A","n":1}, +{"minute":"2026-06-25T13:16","arm":"B","n":1}, +{"minute":"2026-06-25T13:17","arm":"B","n":1}, +{"minute":"2026-06-25T13:18","arm":"A","n":1}, +{"minute":"2026-06-25T13:19","arm":"A","n":1}, +{"minute":"2026-06-25T13:20","arm":"A","n":2}, +{"minute":"2026-06-25T13:20","arm":"B","n":1}, +{"minute":"2026-06-25T13:24","arm":"A","n":1}, +{"minute":"2026-06-25T13:25","arm":"A","n":1}, +{"minute":"2026-06-25T13:25","arm":"B","n":1}, +{"minute":"2026-06-25T13:26","arm":"B","n":3}, +{"minute":"2026-06-25T13:27","arm":"A","n":1}, +{"minute":"2026-06-25T13:28","arm":"B","n":1}, +{"minute":"2026-06-25T13:29","arm":"A","n":1}, +{"minute":"2026-06-25T13:30","arm":"A","n":1}, +{"minute":"2026-06-25T13:32","arm":"A","n":1}, +{"minute":"2026-06-25T13:33","arm":"A","n":1}, +{"minute":"2026-06-25T13:34","arm":"B","n":1}, +{"minute":"2026-06-25T13:35","arm":"A","n":1}, +{"minute":"2026-06-25T13:35","arm":"B","n":1}, +{"minute":"2026-06-25T13:37","arm":"B","n":2}, +{"minute":"2026-06-25T13:38","arm":"B","n":2}, +{"minute":"2026-06-25T13:40","arm":"A","n":1}, +{"minute":"2026-06-25T13:40","arm":"B","n":2}, +{"minute":"2026-06-25T13:41","arm":"A","n":2}, +{"minute":"2026-06-25T13:41","arm":"B","n":1}, +{"minute":"2026-06-25T13:43","arm":"A","n":2}, +{"minute":"2026-06-25T13:45","arm":"A","n":1}, +{"minute":"2026-06-25T13:45","arm":"B","n":2}, +{"minute":"2026-06-25T13:47","arm":"A","n":1}, +{"minute":"2026-06-25T13:48","arm":"A","n":1}, +{"minute":"2026-06-25T13:48","arm":"B","n":1}, +{"minute":"2026-06-25T13:49","arm":"A","n":2}, +{"minute":"2026-06-25T13:49","arm":"B","n":1}, +{"minute":"2026-06-25T13:50","arm":"A","n":1}, +{"minute":"2026-06-25T13:50","arm":"B","n":1}, +{"minute":"2026-06-25T13:52","arm":"A","n":3}, +{"minute":"2026-06-25T13:52","arm":"B","n":1}, +{"minute":"2026-06-25T13:53","arm":"B","n":1}, +{"minute":"2026-06-25T13:54","arm":"A","n":1}, +{"minute":"2026-06-25T13:54","arm":"B","n":1}, +{"minute":"2026-06-25T13:56","arm":"A","n":1}, +{"minute":"2026-06-25T13:58","arm":"B","n":1}, +{"minute":"2026-06-25T13:59","arm":"A","n":1}, +{"minute":"2026-06-25T13:59","arm":"B","n":2}, +{"minute":"2026-06-25T14:01","arm":"A","n":2}, +{"minute":"2026-06-25T14:01","arm":"B","n":1}, +{"minute":"2026-06-25T14:02","arm":"A","n":1}, +{"minute":"2026-06-25T14:02","arm":"B","n":2}, +{"minute":"2026-06-25T14:03","arm":"B","n":1}, +{"minute":"2026-06-25T14:04","arm":"A","n":4}, +{"minute":"2026-06-25T14:04","arm":"B","n":3}, +{"minute":"2026-06-25T14:05","arm":"A","n":1}, +{"minute":"2026-06-25T14:06","arm":"B","n":1}, +{"minute":"2026-06-25T14:07","arm":"B","n":1}, +{"minute":"2026-06-25T14:09","arm":"B","n":1}, +{"minute":"2026-06-25T14:10","arm":"A","n":2}, +{"minute":"2026-06-25T14:11","arm":"A","n":1}, +{"minute":"2026-06-25T14:11","arm":"B","n":1}, +{"minute":"2026-06-25T14:12","arm":"A","n":1}, +{"minute":"2026-06-25T14:13","arm":"A","n":2}, +{"minute":"2026-06-25T14:15","arm":"B","n":2} +] +} +::: + +:::progression + +## running totals + +Two cumulative lines, one per arm — a `window` sum of each minute's count, grouped by +`arm`. They should track each other; here A pulls ahead, but the chart can't tell you +whether that opened up at 07:30 or 13:00. Treat this as the first of a stack of views we'll +link together. + +```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 + ] + } + ], + "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" + } + } + } + ] +} +``` + +## + a brush + +Add an `interval` selection on the x-axis — that's the `params` entry. On its own it just +paints a draggable band; nothing reads it yet. It goes on this chart specifically, the one +you'll drag across, because a selection lives on the view whose marks define it. + +```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" + } + } + } + ] +} +``` + +## + a linked gap + +Append a second view that reads the selection. `filter: {param: brush}` keeps only the +brushed minutes; then it pivots A and B into columns, re-accumulates each, and plots +A − B. Now dragging the band on the top chart rebases this one — brush the first half-hour +and the early gap shows; brush the tail and it flattens. "Is the imbalance localised in +time?" gets answered by dragging instead of re-querying. With nothing brushed the selection +is empty, which by default means _every_ row, so 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" + } + }, + { + "pivot": "arm", + "value": "n", + "groupby": [ + "minute" + ] + }, + { + "calculate": "isValid(datum.A) ? datum.A : 0", + "as": "aN" + }, + { + "calculate": "isValid(datum.B) ? datum.B : 0", + "as": "bN" + }, + { + "sort": [ + { + "field": "minute" + } + ], + "window": [ + { + "op": "sum", + "field": "aN", + "as": "cumA" + }, + { + "op": "sum", + "field": "bN", + "as": "cumB" + } + ], + "frame": [ + null, + 0 + ] + }, + { + "calculate": "datum.cumA - datum.cumB", + "as": "diff" + } + ], + "mark": "line", + "encoding": { + "x": { + "field": "minute", + "type": "temporal", + "title": "Time (UTC)", + "axis": { + "format": "%H:%M" + } + }, + "y": { + "field": "diff", + "type": "quantitative", + "title": "Cumulative A − B" + } + } + } + ] +} +``` + +## + the share split + +A third view, driven by the same brush: one bar with `stack: normalize`, so it reads as +each arm's share of the window instead of raw counts. Sum of `n`, split by colour. Now one +drag moves all three. + +```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" + } + }, + { + "pivot": "arm", + "value": "n", + "groupby": [ + "minute" + ] + }, + { + "calculate": "isValid(datum.A) ? datum.A : 0", + "as": "aN" + }, + { + "calculate": "isValid(datum.B) ? datum.B : 0", + "as": "bN" + }, + { + "sort": [ + { + "field": "minute" + } + ], + "window": [ + { + "op": "sum", + "field": "aN", + "as": "cumA" + }, + { + "op": "sum", + "field": "bN", + "as": "cumB" + } + ], + "frame": [ + null, + 0 + ] + }, + { + "calculate": "datum.cumA - datum.cumB", + "as": "diff" + } + ], + "mark": "line", + "encoding": { + "x": { + "field": "minute", + "type": "temporal", + "title": "Time (UTC)", + "axis": { + "format": "%H:%M" + } + }, + "y": { + "field": "diff", + "type": "quantitative", + "title": "Cumulative A − B" + } + } + }, + { + "data": { + "name": "ev" + }, + "transform": [ + { + "filter": { + "param": "brush" + } + } + ], + "mark": "bar", + "encoding": { + "x": { + "aggregate": "sum", + "field": "n", + "type": "quantitative", + "stack": "normalize", + "title": "Share of assignments", + "axis": { + "format": "%" + } + }, + "color": { + "field": "arm", + "type": "nominal", + "scale": { + "domain": [ + "A", + "B" + ], + "range": [ + "#3a7ca5", + "#e0913a" + ] + }, + "legend": null + } + } + } + ] +} +``` + +## polished + +The last 20%. Cumulative counts are step functions, so `step-after` interpolation draws +them honestly rather than sloping between samples. The gap view gains a dashed zero rule — +above it, A leads; below it, B does — plus real titles and tooltips. Composition did the +work; this stage just makes it legible. + +```vega-lite +{ + "$schema": "https://vega.github.io/schema/vega-lite/v6.json", + "spacing": 24, + "vconcat": [ + { + "title": { + "text": "Cumulative assignments", + "subtitle": "Both arms should climb at the same rate" + }, + "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": { + "type": "line", + "interpolate": "step-after", + "strokeWidth": 2 + }, + "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" + }, + "tooltip": [ + { + "field": "minute", + "type": "temporal", + "title": "time", + "format": "%H:%M" + }, + { + "field": "arm", + "type": "nominal", + "title": "branch" + }, + { + "field": "cum", + "type": "quantitative", + "title": "cumulative" + } + ] + } + }, + { + "title": { + "text": "Cumulative gap · A − B", + "subtitle": "Above zero, A is ahead · rebased over the brush" + }, + "data": { + "name": "ev" + }, + "transform": [ + { + "filter": { + "param": "brush" + } + }, + { + "pivot": "arm", + "value": "n", + "groupby": [ + "minute" + ] + }, + { + "calculate": "isValid(datum.A) ? datum.A : 0", + "as": "aN" + }, + { + "calculate": "isValid(datum.B) ? datum.B : 0", + "as": "bN" + }, + { + "sort": [ + { + "field": "minute" + } + ], + "window": [ + { + "op": "sum", + "field": "aN", + "as": "cumA" + }, + { + "op": "sum", + "field": "bN", + "as": "cumB" + } + ], + "frame": [ + null, + 0 + ] + }, + { + "calculate": "datum.cumA - datum.cumB", + "as": "diff" + } + ], + "layer": [ + { + "mark": { + "type": "rule", + "strokeDash": [ + 3, + 3 + ], + "color": "#b0b6bf" + }, + "encoding": { + "y": { + "datum": 0 + } + } + }, + { + "mark": { + "type": "line", + "interpolate": "step-after", + "strokeWidth": 2, + "color": "#27506b" + }, + "encoding": { + "x": { + "field": "minute", + "type": "temporal", + "title": "Time (UTC)", + "axis": { + "format": "%H:%M" + } + }, + "y": { + "field": "diff", + "type": "quantitative", + "title": "Cumulative A − B" + }, + "tooltip": [ + { + "field": "minute", + "type": "temporal", + "title": "time", + "format": "%H:%M" + }, + { + "field": "cumA", + "type": "quantitative", + "title": "A" + }, + { + "field": "cumB", + "type": "quantitative", + "title": "B" + }, + { + "field": "diff", + "type": "quantitative", + "title": "A − B" + } + ] + } + } + ] + }, + { + "title": { + "text": "Branch share in the window", + "subtitle": "A vs B as a fraction of the brushed span" + }, + "data": { + "name": "ev" + }, + "transform": [ + { + "filter": { + "param": "brush" + } + } + ], + "mark": { + "type": "bar", + "cornerRadius": 2 + }, + "encoding": { + "x": { + "aggregate": "sum", + "field": "n", + "type": "quantitative", + "stack": "normalize", + "title": "Share of assignments", + "axis": { + "format": "%", + "values": [ + 0, + 0.25, + 0.5, + 0.75, + 1 + ] + } + }, + "color": { + "field": "arm", + "type": "nominal", + "scale": { + "domain": [ + "A", + "B" + ], + "range": [ + "#3a7ca5", + "#e0913a" + ] + }, + "legend": null + }, + "tooltip": [ + { + "field": "arm", + "type": "nominal", + "title": "branch" + }, + { + "aggregate": "sum", + "field": "n", + "type": "quantitative", + "title": "assignments" + } + ] + } + } + ] +} +``` + +::: + +:::sharp-edge +**The sharp edge.** The selection has to live on the view you actually brush — the one +whose marks you drag across. Declare `params` on the gap or share view and you're brushing a +chart that only filters, so nothing moves. The other trap is the empty state: `{param: +brush}` inherits Vega-Lite's default `empty: "all"`, so until you drag something the filter +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. +::: diff --git a/src/learn/main.tsx b/src/learn/main.tsx index c4223d4..9957445 100644 --- a/src/learn/main.tsx +++ b/src/learn/main.tsx @@ -1,10 +1,25 @@ import { createRoot } from 'react-dom/client'; -import { LearnPage } from './LearnPage'; -// Same base styles as the landing — design tokens and self-hosted IBM Plex — so -// the section shares the app's visual language. Like the landing, this entry -// wires no store, no persistence, and no service worker: the learning pages stay -// uncontrolled and always-fresh (the SW is scoped to /app/), and indexable. +import { LearnLayout } from './LearnLayout'; +import { LearnIndex } from './LearnIndex'; +import { LessonView } from './LessonView'; +import { LESSONS } from './lessons'; +// Same base styles as the landing — design tokens and self-hosted IBM Plex. Like +// the landing, this entry wires no store, no persistence, and no service worker: +// the learning pages stay uncontrolled and always-fresh (the SW is scoped to +// /app/), and indexable. import '../../styles/base.css'; import '../../styles/chart-fonts.css'; -createRoot(document.getElementById('root')!).render(); +// Each lesson is its own URL — `/learn//`, a generated static shell; the index +// is `/learn/`. One entry renders the right view from the path. An unknown slug +// falls back to the index. +const slug = /\/learn\/([a-z0-9-]+)\/?$/.exec(location.pathname)?.[1]; +const lesson = slug ? LESSONS.find((l) => l.slug === slug) : undefined; + +createRoot(document.getElementById('root')!).render( + + {(config) => + lesson ? : + } + , +); diff --git a/vite.config.ts b/vite.config.ts index 0411925..f422260 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,11 +3,18 @@ import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { generateLearnPages, lessonInputs } from './scripts/learn-pages'; const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string; }; +// Write a static `learn//index.html` shell per lesson (from the lesson `.md` +// frontmatter) before the inputs below reference them. Regenerated whenever Vite +// (re)loads its config — every dev start and build — so a newly added lesson needs a +// dev restart to get its page; the generated dirs are git-ignored. +generateLearnPages(); + export default defineConfig({ resolve: { alias: { @@ -24,11 +31,14 @@ export default defineConfig({ // lightweight `src/landing` entry); the app at `/app/` (app/index.html → // src/main.tsx). The app is hash-routed, so it runs unchanged under /app/. // `/learn/` is the deep-dive learning section — another light marketing - // entry (src/learn), reusing core + the landing's chart embed only. + // entry (src/learn), reusing core + the landing's chart embed only. Its index + // is `learn/index.html`; each lesson is its own page (`learn//`) from a + // generated shell, added to the inputs here via `lessonInputs()`. input: { main: fileURLToPath(new URL('./index.html', import.meta.url)), app: fileURLToPath(new URL('./app/index.html', import.meta.url)), learn: fileURLToPath(new URL('./learn/index.html', import.meta.url)), + ...lessonInputs(), }, output: { // Split the heavy vendors so the chunk graph stays legible and the PWA