From c19857b0a7d138d96d424b2a1f32040e5f13ff9e Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Thu, 25 Jun 2026 12:02:39 +0300 Subject: [PATCH] =?UTF-8?q?Learn:=20a=20/learn/=20deep-dive=20section=20?= =?UTF-8?q?=E2=80=94=20markdown=20lessons=20as=20before/after=20spec=20pro?= =?UTF-8?q?gressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 15 +- CLAUDE.md | 5 +- docs/architecture/00-overview.md | 1 + docs/architecture/11-learning-section.md | 54 +++++++ learn/index.html | 19 +++ package-lock.json | 21 ++- package.json | 1 + src/core/json-format.test.ts | 22 ++- src/core/json-format.ts | 23 ++- src/core/lesson-parse.test.ts | 127 +++++++++++++++ src/core/lesson-parse.ts | 194 ++++++++++++++++++++++ src/core/spec-diff.test.ts | 49 ++++++ src/core/spec-diff.ts | 54 +++++++ src/landing/Landing.tsx | 2 + src/learn/LearnPage.module.css | 197 +++++++++++++++++++++++ src/learn/LearnPage.tsx | 140 ++++++++++++++++ src/learn/Markdown.tsx | 14 ++ src/learn/SpecProgression.module.css | 131 +++++++++++++++ src/learn/SpecProgression.tsx | 118 ++++++++++++++ src/learn/lessons/binning.md | 104 ++++++++++++ src/learn/main.tsx | 10 ++ vite.config.ts | 3 + 22 files changed, 1291 insertions(+), 13 deletions(-) create mode 100644 docs/architecture/11-learning-section.md create mode 100644 learn/index.html create mode 100644 src/core/lesson-parse.test.ts create mode 100644 src/core/lesson-parse.ts create mode 100644 src/core/spec-diff.test.ts create mode 100644 src/core/spec-diff.ts create mode 100644 src/learn/LearnPage.module.css create mode 100644 src/learn/LearnPage.tsx create mode 100644 src/learn/Markdown.tsx create mode 100644 src/learn/SpecProgression.module.css create mode 100644 src/learn/SpecProgression.tsx create mode 100644 src/learn/lessons/binning.md create mode 100644 src/learn/main.tsx diff --git a/AGENTS.md b/AGENTS.md index 0923d39..f4230da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,9 @@ the spec_; do not port legacy code. view-state routing is unchanged by the base path). The landing reuses `src/core` and the `chart-renderer` service only — never stores, orchestration, modals, or components — and lazy-loads Vega, so `/` stays light. The PWA service worker and manifest are scoped to - `/app/`, leaving the landing uncontrolled and always-fresh. + `/app/`, leaving the landing uncontrolled and always-fresh. **`/learn/` is a second such + entry** (`src/learn/`) — the markdown-authored deep-dive section, under the same rules + (see architecture 11). See [`docs/architecture/`](docs/architecture/00-overview.md) for the patterns behind each layer (state, persistence, modals, routing, rendering, inference, relationships) and @@ -57,9 +59,11 @@ external repo is needed to work from them. ``` index.html # Landing entry (served at /) app/index.html # App entry (served at /app/) +learn/index.html # Learning-section entry (served at /learn/) src/ ├── main.tsx # App bootstrap (font wiring, startup, render) ├── landing/ # Marketing landing at / — standalone page; reuses core + chart-renderer +├── learn/ # /learn/ deep-dive — markdown lessons + the SpecProgression engine (arch 11) ├── core/ # Portable spec engine (no browser/React/Monaco) ├── app/ │ ├── components/ # React UI (CSS Modules co-located) @@ -71,7 +75,7 @@ src/ styles/ # Global CSS (tokens, base) docs/ ├── spec/ # Authoritative behavioral specification (00–10) — the WHAT -├── architecture/ # Architecture playbook (00–10) — the HOW (self-contained) +├── architecture/ # Architecture playbook (00–11) — the HOW (self-contained) │ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog ├── exploration/ # Point-in-time records (research, reviews, scope memos) — not maintained ├── IMPLEMENTATION-PLAN.md # Milestone sequence (M0–M6) @@ -151,6 +155,13 @@ High coverage on `src/core/` (parsing, detection, profiling, reference resolutio transforms, import normalization). Lighter on components. Extract testable logic out of components into core/stores where practical. +Don't test static presentational components — copy, markup, and links with no logic behind +them. Content assertions ("renders the heading X", "this link exists") are change-detectors: +they break on intentional copy edits and catch no real bug. Test the logic a component +carries — platform branches, state transitions, config-path writes, render serialization — +not the strings it renders; if that logic is worth guarding, lift it into core/stores and +test it there. + Component tests (happy-dom) share a harness shape: `createRoot` + `act` with `IS_REACT_ACT_ENVIRONMENT = true` set at module level, stores reset in `beforeEach`, and `vi.mock('../services/chart-renderer', …)` for anything that embeds a chart (vega-embed is diff --git a/CLAUDE.md b/CLAUDE.md index a6ff6a7..b437ad6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,9 @@ See @AGENTS.md for project overview, architecture rules, and the AI developer pr - **[docs/spec/](docs/spec/)** — authoritative behavioral specification (sections 00–10): the **what**. This is the contract; implement to it. - **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook - (00–10): the **how** (state, persistence, modals, routing, rendering, inference, - relationships, vega-editor techniques, visual design, interaction & feedback). + (00–11): the **how** (state, persistence, modals, routing, rendering, inference, + relationships, vega-editor techniques, visual design, interaction & feedback, learning + section). Self-contained — no external repo needed. Companion: **[visual-specimen.html](docs/architecture/visual-specimen.html)** — token sandbox + reusable-primitive catalog (open in a browser). diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md index 88e517b..1d3b7dc 100644 --- a/docs/architecture/00-overview.md +++ b/docs/architecture/00-overview.md @@ -47,6 +47,7 @@ about user-facing widgets. At that overlap, one rule keeps them from drifting: | 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. | | 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). | | 10 | [Interaction & Feedback](10-interaction-and-feedback.md) | The _interaction_ contract: the feedback-channel decision table, latency/feedback budgets, the non-happy-path triad, the recovery & data-safety contract, the keyboard/focus contract, and the resolved widget patterns (window splitter, toolbar, segmented controls, selectable lists, search, sort, empty states, modals). Cites `spec/` for behavior; owns the _how_. | +| 11 | [Learning Section](11-learning-section.md) | The `/learn/` deep-dive: a marketing-surface Vite entry reusing core + the landing chart embed; markdown-authored lessons (`import.meta.glob`) parsed into an ordered block model; the authoring/engine split (pure parser in core; `marked` only in `src/learn`). | ## The non-negotiable layering (every doc assumes this) diff --git a/docs/architecture/11-learning-section.md b/docs/architecture/11-learning-section.md new file mode 100644 index 0000000..d3b8640 --- /dev/null +++ b/docs/architecture/11-learning-section.md @@ -0,0 +1,54 @@ +# 11 — The Learning Section (`/learn/`) + +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. + +## A marketing surface, like the landing + +`/learn/` is a third Vite entry (`learn/index.html` → `src/learn/`) alongside the landing +(`/`) and the app (`/app/`), under the same marketing-surface rules: + +- Reuses **`src/core` and the landing's `LandingChart`** only — never stores, modals, + orchestration, or app components. Vega is lazy-loaded through `LandingChart`, so the entry + 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. + +## Lessons are markdown; the renderer is general + +A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.glob` — so +**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) | + +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 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). + +## Rules + +- **The engine consumes plain data, so the authoring surface is swappable.** + `SpecProgression`, `spec-diff`, and `formatSpec` take parsed blocks and specs and know + nothing about markdown — the parser is the only thing coupled to the `.md` format. The + authoring format can change without touching the widget. +- **Parsing, diff, and formatting are pure and live in `core`** (tested hardest); rendering + and the markdown library live in `src/learn`. **Core stays dependency-free** — `marked` is + imported only by `src/learn/Markdown`. +- **`dangerouslySetInnerHTML` renders first-party lesson files** (repo content authored by + 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. diff --git a/learn/index.html b/learn/index.html new file mode 100644 index 0000000..dfe149f --- /dev/null +++ b/learn/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + Vega-Lite, deeper — Astrolabe + + +
+ + + diff --git a/package-lock.json b/package-lock.json index 620a6ba..ad2bde0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@fontsource/space-mono": "^5.2.9", "@fontsource/spectral": "^5.2.8", "json-stringify-pretty-compact": "^4.0.0", + "marked": "^18.0.5", "monaco-editor": "^0.54.0", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -6386,15 +6387,15 @@ } }, "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/math-intrinsics": { @@ -6456,6 +6457,18 @@ "marked": "14.0.0" } }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index 22243ac..9db9eac 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@fontsource/space-mono": "^5.2.9", "@fontsource/spectral": "^5.2.8", "json-stringify-pretty-compact": "^4.0.0", + "marked": "^18.0.5", "monaco-editor": "^0.54.0", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/src/core/json-format.test.ts b/src/core/json-format.test.ts index 3fd6128..2675fd9 100644 --- a/src/core/json-format.test.ts +++ b/src/core/json-format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatJson } from './json-format'; +import { formatJson, formatSpec } from './json-format'; describe('formatJson', () => { it('pretty-prints a valid object with the default indent', () => { @@ -43,3 +43,23 @@ describe('formatJson', () => { expect(formatJson('true')).toBe('true'); }); }); + +describe('formatSpec', () => { + it('pretty-prints an object in the same house style as formatJson', () => { + expect(formatSpec({ a: 1, b: 2 })).toBe('{"a": 1, "b": 2}'); + }); + + it('agrees with formatJson for the equivalent text', () => { + const obj = { mark: 'bar', encoding: { x: { field: 'a', type: 'nominal' } } }; + expect(formatSpec(obj)).toBe(formatJson(JSON.stringify(obj))); + }); + + it('preserves the object’s own key order (no canonical sort)', () => { + // Insertion order is the contract — a lesson author orders keys for teaching. + expect(formatSpec({ z: 1, a: 2 })).toBe('{"z": 1, "a": 2}'); + }); + + it('respects indent and line-length options', () => { + expect(formatSpec({ a: 1, b: 2 }, { maxLength: 5 })).toBe('{\n "a": 1,\n "b": 2\n}'); + }); +}); diff --git a/src/core/json-format.ts b/src/core/json-format.ts index b69ad31..5473c3d 100644 --- a/src/core/json-format.ts +++ b/src/core/json-format.ts @@ -17,6 +17,24 @@ const DEFAULT_MAX_LINE = 80; /** Default indent (spaces) — matches the editor's default tab size (spec §07). */ const DEFAULT_INDENT = 2; +/** + * Pretty-print a spec *object* (not text) in the same Vega house style as + * {@link formatJson}. Object-in callers — the lesson source panes, anything that + * holds a spec as a parsed value — use this so their rendered JSON matches the + * editor's formatting exactly. Key order is the object's own insertion order, + * deliberately: where consecutive specs are small edits of one another (a lesson's + * 80%→100% chain), a stable order is what keeps a line-diff between them legible. + */ +export function formatSpec( + value: unknown, + opts: { indent?: number; maxLength?: number } = {}, +): string { + return stringify(value, { + indent: opts.indent ?? DEFAULT_INDENT, + maxLength: opts.maxLength ?? DEFAULT_MAX_LINE, + }); +} + /** * Reformat `text` as consistently-indented JSON, or return `null` when it is not * valid JSON. Returning `null` (rather than throwing) lets callers skip a no-op @@ -34,8 +52,5 @@ export function formatJson( } catch { return null; } - return stringify(parsed, { - indent: opts.indent ?? DEFAULT_INDENT, - maxLength: opts.maxLength ?? DEFAULT_MAX_LINE, - }); + return formatSpec(parsed, opts); } diff --git a/src/core/lesson-parse.test.ts b/src/core/lesson-parse.test.ts new file mode 100644 index 0000000..7cfdd47 --- /dev/null +++ b/src/core/lesson-parse.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { parseLesson, type LessonBlock } from './lesson-parse'; + +const LESSON = `--- +slug: binning +title: From bars to a real histogram +tagline: One word turns a noisy bar chart into a distribution. +--- + +Counting a continuous field is *almost* right. + +:::progression +## raw counts +A bar per distinct value — a picket fence. + +\`\`\`vega-lite +{ "mark": "bar", "encoding": { "x": { "field": "v" } } } +\`\`\` + +## + bin +\`bin: true\` is the whole fix. + +\`\`\`vega-lite +{ "mark": "bar", "encoding": { "x": { "field": "v", "bin": true } } } +\`\`\` +::: + +Then a standalone chart: + +\`\`\`vega-lite +{ "mark": "point" } +\`\`\` + +:::sharp-edge +Tempted to wire a slider to bin width? It won't work. +::: +`; + +const types = (blocks: LessonBlock[]) => blocks.map((b) => b.type); + +describe('parseLesson — block model', () => { + it('reads frontmatter as lesson metadata', () => { + const lesson = parseLesson(LESSON); + expect(lesson.slug).toBe('binning'); + expect(lesson.title).toBe('From bars to a real histogram'); + expect(lesson.tagline).toBe('One word turns a noisy bar chart into a distribution.'); + }); + + it('emits blocks in document order', () => { + expect(types(parseLesson(LESSON).blocks)).toEqual([ + 'prose', + 'progression', + 'prose', + 'chart', + 'callout', + ]); + }); + + it('parses progression stages: label, note, parsed spec', () => { + const block = parseLesson(LESSON).blocks[1]; + if (block.type !== 'progression') throw new Error('expected progression'); + const [raw, binned] = block.stages; + expect(raw.label).toBe('raw counts'); + expect(raw.note).toBe('A bar per distinct value — a picket fence.'); + expect(raw.spec).toEqual({ mark: 'bar', encoding: { x: { field: 'v' } } }); + expect((binned.spec.encoding as { x: { bin?: unknown } }).x.bin).toBe(true); + }); + + it('parses a standalone chart block and a callout variant', () => { + const { blocks } = parseLesson(LESSON); + const chart = blocks[3]; + const callout = blocks[4]; + expect(chart.type === 'chart' && chart.spec).toEqual({ mark: 'point' }); + expect(callout.type === 'callout' && callout.variant).toBe('sharp-edge'); + expect(callout.type === 'callout' && callout.markdown).toMatch(/slider to bin width/); + }); + + it('handles a prose-only lesson', () => { + const proseOnly = `---\nslug: x\ntitle: t\ntagline: g\n---\nJust words.`; + expect(types(parseLesson(proseOnly).blocks)).toEqual(['prose']); + }); + + it('supports multiple progressions in one lesson', () => { + const two = `---\nslug: x\ntitle: t\ntagline: g\n--- +:::progression +## a +\`\`\`vega-lite +{ "mark": "bar" } +\`\`\` +::: +:::progression +## b +\`\`\`vega-lite +{ "mark": "line" } +\`\`\` +:::`; + expect(types(parseLesson(two).blocks)).toEqual(['progression', 'progression']); + }); + + it('throws on missing frontmatter', () => { + expect(() => parseLesson('## a')).toThrow(/frontmatter/); + }); + + it('throws on a missing required field', () => { + expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/); + }); + + 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/); + }); + + it('throws on an invalid standalone chart spec', () => { + const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n\`\`\`vega-lite\n[1,2]\n\`\`\``; + expect(() => parseLesson(bad)).toThrow(/chart: spec must be a JSON object/); + }); + + it('normalizes CRLF line endings', () => { + expect(types(parseLesson(LESSON.replace(/\n/g, '\r\n')).blocks)).toEqual([ + 'prose', + 'progression', + 'prose', + 'chart', + 'callout', + ]); + }); +}); diff --git a/src/core/lesson-parse.ts b/src/core/lesson-parse.ts new file mode 100644 index 0000000..8f87824 --- /dev/null +++ b/src/core/lesson-parse.ts @@ -0,0 +1,194 @@ +/** + * Parser for markdown lesson files (the deep-dive learning section). A lesson is + * authored as a `.md` — prose is native markdown, specs are fenced JSON — and this + * turns one into the ordered **blocks** the page renders in document order. + * + * A lesson is a free-form document, not a fixed template: prose, interactive + * progressions, standalone charts, and callouts in any arrangement. The author + * only ever writes markdown, fenced specs, and `:::directives` — never JSX. + * + * Pure and portable (string in → data out; no DOM, Vega, or React): the + * structural parse and the spec `JSON.parse` live here and are tested hardest. + * Rendering the prose markdown to HTML is the UI layer's job (`src/learn`), kept + * out of core so core stays dependency-free. + * + * Format: + * + * --- + * slug: binning + * title: From bars to a real histogram + * tagline: One word turns a noisy bar chart into a distribution. + * --- + * + * Free intro prose (markdown)… + * + * :::progression + * ## raw counts + * Note prose for this stage (markdown)… + * ```vega-lite + * { …spec… } + * ``` + * ## + bin + * … + * ::: + * + * More prose, then a standalone chart: + * + * ```vega-lite + * { …one-off chart… } + * ``` + * + * :::sharp-edge + * 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. + */ + +export interface ProgressionStage { + /** Tab label — the `##` heading text. */ + label: string; + /** Markdown prose shown under the chart for this stage. */ + note: string; + /** The stage's Vega-Lite spec, parsed from its fenced block. */ + spec: Record; +} + +export interface ProseBlock { + type: 'prose'; + markdown: string; +} +export interface ChartBlock { + type: 'chart'; + spec: Record; +} +export interface ProgressionBlock { + type: 'progression'; + stages: ProgressionStage[]; +} +export interface CalloutBlock { + type: 'callout'; + /** The directive name, e.g. `sharp-edge` — the UI styles by variant. */ + variant: string; + markdown: string; +} +export type LessonBlock = ProseBlock | ChartBlock | ProgressionBlock | CalloutBlock; + +export interface ParsedLesson { + slug: string; + title: string; + tagline: string; + /** The lesson body, in document order. */ + blocks: LessonBlock[]; +} + +const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?/; +// A top-level region: a `:::name … :::` directive (which may itself contain +// vega-lite fences) OR a standalone vega-lite fence. The directive alternative is +// tried first and its lazy body runs to the next `\n:::`, so a progression's inner +// fences are consumed by its span rather than matched as standalone charts. +const REGION = /:::([a-z-]+)\n([\s\S]*?)\n:::|```vega-lite\n([\s\S]*?)\n```/g; +const STAGE_FENCE = /```vega-lite\n([\s\S]*?)\n```/; + +export function parseLesson(source: string): ParsedLesson { + const text = source.replace(/\r\n/g, '\n'); + + const fm = FRONTMATTER.exec(text); + if (!fm) throw new Error('lesson: missing `---` frontmatter block'); + const meta = parseFrontmatter(fm[1]); + const slug = required(meta, 'slug'); + const title = required(meta, 'title'); + const tagline = required(meta, 'tagline'); + + return { slug, title, tagline, blocks: parseBlocks(text.slice(fm[0].length), slug) }; +} + +function parseBlocks(body: string, slug: string): LessonBlock[] { + const blocks: LessonBlock[] = []; + const pushProse = (text: string): void => { + const trimmed = text.trim(); + if (trimmed) blocks.push({ type: 'prose', markdown: trimmed }); + }; + + REGION.lastIndex = 0; + let cursor = 0; + let m: RegExpExecArray | null; + while ((m = REGION.exec(body)) !== null) { + pushProse(body.slice(cursor, m.index)); + 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() }, + ); + } else { + blocks.push({ type: 'chart', spec: parseSpec(m[3], slug, 'chart') }); + } + cursor = m.index + m[0].length; + } + pushProse(body.slice(cursor)); + + if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`); + return blocks; +} + +function parseStages(inner: string, slug: string): ProgressionStage[] { + const parts = inner.split(/^## +/m); + parts.shift(); // text before the first `##` (blank by convention) + if (parts.length === 0) { + throw new Error(`lesson "${slug}": a :::progression has no \`##\` stages`); + } + return parts.map((part) => parseStage(part, slug)); +} + +function parseStage(part: string, slug: string): ProgressionStage { + const nl = part.indexOf('\n'); + const label = (nl === -1 ? part : part.slice(0, nl)).trim(); + const rest = nl === -1 ? '' : part.slice(nl + 1); + + const block = STAGE_FENCE.exec(rest); + if (!block) throw new Error(`lesson "${slug}", stage "${label}": no \`\`\`vega-lite block`); + + const spec = parseSpec(block[1], slug, `stage "${label}"`); + const note = (rest.slice(0, block.index) + rest.slice(block.index + block[0].length)).trim(); + return { label, note, spec }; +} + +function parseSpec(json: string, slug: string, where: string): Record { + let spec: unknown; + try { + spec = JSON.parse(json); + } catch (e) { + throw new Error(`lesson "${slug}", ${where}: invalid JSON — ${(e as Error).message}`, { + cause: e, + }); + } + if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) { + throw new Error(`lesson "${slug}", ${where}: spec must be a JSON object`); + } + return spec as Record; +} + +function parseFrontmatter(raw: string): Map { + const map = new Map(); + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + const i = line.indexOf(':'); + if (i === -1) throw new Error(`lesson frontmatter: expected \`key: value\`, got "${line}"`); + map.set(line.slice(0, i).trim(), line.slice(i + 1).trim()); + } + return map; +} + +function required(meta: Map, key: string): string { + const v = meta.get(key); + if (!v) throw new Error(`lesson frontmatter: missing "${key}"`); + return v; +} diff --git a/src/core/spec-diff.test.ts b/src/core/spec-diff.test.ts new file mode 100644 index 0000000..7a9119c --- /dev/null +++ b/src/core/spec-diff.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { changedLines } from './spec-diff'; + +const set = (...n: number[]) => new Set(n); + +describe('changedLines', () => { + it('marks nothing when the text is identical', () => { + const text = '{\n "mark": "bar"\n}'; + expect(changedLines(text, text)).toEqual(new Set()); + }); + + it('marks a line inserted in the middle', () => { + const prev = 'a\nb\nc'; + const next = 'a\nb\nNEW\nc'; + expect(changedLines(prev, next)).toEqual(set(2)); + }); + + it('marks a replaced line (old dropped, new flagged)', () => { + const prev = 'a\nb\nc'; + const next = 'a\nB!\nc'; + expect(changedLines(prev, next)).toEqual(set(1)); + }); + + it('flags a genuinely-new duplicate line, not the pre-existing one', () => { + // Vega specs repeat lines; a naive membership test would mishighlight here. + const prev = '"type": "quantitative"'; + const next = '"type": "quantitative"\n"type": "quantitative"'; + expect(changedLines(prev, next).size).toBe(1); + }); + + it('marks an appended block of lines', () => { + const prev = '{\n "mark": "bar"\n}'; + const next = '{\n "mark": "bar",\n "params": [\n { "name": "step" }\n ]\n}'; + // The closing brace line is unchanged content but shifts position; the params + // lines and the comma-suffixed mark line are the changes. + const changed = changedLines(prev, next); + expect(changed.has(2)).toBe(true); // "params": [ + expect(changed.has(3)).toBe(true); // { "name": "step" } + expect(changed.has(0)).toBe(false); // opening brace unchanged + }); + + it('treats an empty previous text as an all-new baseline', () => { + expect(changedLines('', 'a\nb')).toEqual(set(0, 1)); + }); + + it('marks everything when all lines change', () => { + expect(changedLines('a\nb', 'x\ny')).toEqual(set(0, 1)); + }); +}); diff --git a/src/core/spec-diff.ts b/src/core/spec-diff.ts new file mode 100644 index 0000000..93c5609 --- /dev/null +++ b/src/core/spec-diff.ts @@ -0,0 +1,54 @@ +/** + * Line-level diff for the lesson source panes (the deep-dive learning section). + * + * A lesson walks a spec from an 80%-naive version to a polished one as a chain of + * small edits; each step shows the new spec with *the lines this step changed* + * highlighted, so a reader sees exactly what the edit did. Given the previous + * step's formatted text and the current step's, {@link changedLines} reports which + * line indices of the *current* text are new or changed. + * + * Pure: operates on already-formatted text (see {@link formatSpec}), no Vega, no + * DOM. Standard longest-common-subsequence over lines — every current line not + * part of the LCS with the previous text is "changed". This is why it beats a + * naive "line not present in the previous text" test: Vega specs repeat lines + * (`"type": "quantitative"` many times over), and only the LCS classifies a + * genuinely-new occurrence as changed while leaving the unchanged duplicates be. + */ + +/** + * Indices (0-based, into `nextText`'s lines) that are new or changed relative to + * `prevText`. Identical inputs yield an empty set; an empty `prevText` (a first + * step with nothing before it) marks every line — callers that render a baseline + * step simply skip the highlight. + */ +export function changedLines(prevText: string, nextText: string): Set { + const a = prevText.length ? prevText.split('\n') : []; + const b = nextText.length ? nextText.split('\n') : []; + const m = a.length; + const n = b.length; + + // dp[i][j] = length of the LCS of a[i..] and b[j..]. Filled bottom-up so the + // forward walk below can pick the branch that preserves the longer subsequence. + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const changed = new Set(); + let i = 0; + let j = 0; + while (j < n) { + if (i < m && a[i] === b[j]) { + i++; // line common to both — unchanged + j++; + } else if (i < m && dp[i + 1][j] >= dp[i][j + 1]) { + i++; // previous line dropped — not a change in `next` + } else { + changed.add(j); // new/changed line in `next` + j++; + } + } + return changed; +} diff --git a/src/landing/Landing.tsx b/src/landing/Landing.tsx index e695474..d9d3b43 100644 --- a/src/landing/Landing.tsx +++ b/src/landing/Landing.tsx @@ -29,6 +29,8 @@ import { DEMO_CUSTOM_THEMES } from './demo-themes'; import { LandingChart } from './LandingChart'; import styles from './Landing.module.css'; +// TODO: import { UiTheme } from '@core/theme' instead of redeclaring it — the +// learn entry already uses the canonical one. type UiTheme = 'light' | 'dark'; // Minimal JSON syntax highlighter for the read-only spec view: keys, strings, diff --git a/src/learn/LearnPage.module.css b/src/learn/LearnPage.module.css new file mode 100644 index 0000000..cc64378 --- /dev/null +++ b/src/learn/LearnPage.module.css @@ -0,0 +1,197 @@ +.page { + min-height: 100dvh; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); +} + +/* ---- header ---- */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-5); + height: var(--header-height); + padding: 0 var(--space-6); + border-bottom: 1px solid var(--border); +} +.brand { + font-weight: 600; + font-size: 15px; + color: var(--text); + text-decoration: none; +} +.nav { + display: flex; + align-items: center; + gap: var(--space-4); +} +.kicker { + font-size: 12px; + color: var(--text-secondary); + letter-spacing: 0.02em; +} +.ghost { + height: var(--control-height); + padding: 0 var(--space-4); + font: inherit; + font-size: 13px; + color: var(--text); + background: transparent; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + cursor: pointer; +} +.appLink { + height: var(--control-height); + display: inline-flex; + align-items: center; + padding: 0 var(--space-5); + font-size: 13px; + color: var(--accent-contrast); + background: var(--accent); + border-radius: var(--radius); + text-decoration: none; +} + +/* ---- article ---- */ +.main { + flex: 1 1 auto; + display: flex; + justify-content: center; + padding: var(--space-8) var(--space-6); +} +.article { + width: 100%; + max-width: 880px; +} +.kickerLine { + margin: 0 0 var(--space-2); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent); +} +.title { + margin: 0 0 var(--space-3); + font-size: 30px; + line-height: 1.15; + font-weight: 600; +} +.tagline { + margin: 0 0 var(--space-6); + font-size: 17px; + color: var(--text-secondary); +} +/* ---- prose blocks ---- */ +.prose { + margin: var(--space-6) 0 0; + font-size: 15px; + line-height: 1.6; + max-width: 64ch; +} +.prose p { + margin: 0 0 var(--space-4); +} +.prose p:last-child { + margin-bottom: 0; +} +.prose ul, +.prose ol { + margin: 0 0 var(--space-4); + padding-left: var(--space-6); +} +.prose code { + font-family: var(--font-mono); + font-size: 0.9em; +} +.prose a { + color: var(--accent); +} + +/* ---- interactive blocks (progression, standalone chart) ---- */ +.block { + margin: var(--space-7) 0 0; +} +.chart { + width: 100%; + min-height: 180px; +} + +/* ---- callouts ---- */ +.callout, +.sharpEdge { + margin: var(--space-6) 0 0; + padding: var(--space-5); + font-size: 14px; + line-height: 1.6; + border: 1px solid var(--border); + border-left-width: 3px; + border-radius: var(--radius); + background: var(--layer-01); +} +/* The sharp-edge variant warns — a "this will bite you" callout. */ +.sharpEdge { + border-color: var(--support-warning); +} +.callout p, +.sharpEdge p { + margin: 0; +} +.callout code, +.sharpEdge code { + font-family: var(--font-mono); + font-size: 0.9em; +} + +/* ---- CTA ---- */ +.cta { + margin: var(--space-7) 0 0; + padding: var(--space-6); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--accent-soft); +} +.cta p { + margin: 0; + font-size: 15px; +} +.ctaButton { + display: inline-flex; + align-items: center; + height: var(--control-height-lg); + padding: 0 var(--space-6); + font-size: 14px; + font-weight: 500; + color: var(--accent-contrast); + background: var(--accent); + border-radius: var(--radius); + text-decoration: none; +} + +/* ---- footer ---- */ +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-5); + padding: var(--space-6); + border-top: 1px solid var(--border); + font-size: 13px; + color: var(--text-secondary); +} +.footer a { + color: var(--text-secondary); +} + +@media (max-width: 720px) { + .title { + font-size: 24px; + } +} diff --git a/src/learn/LearnPage.tsx b/src/learn/LearnPage.tsx new file mode 100644 index 0000000..84901b8 --- /dev/null +++ b/src/learn/LearnPage.tsx @@ -0,0 +1,140 @@ +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/Markdown.tsx b/src/learn/Markdown.tsx new file mode 100644 index 0000000..ec1485b --- /dev/null +++ b/src/learn/Markdown.tsx @@ -0,0 +1,14 @@ +import { marked } from 'marked'; +import type { ReactNode } from 'react'; + +/** + * Render first-party lesson markdown to HTML. + * + * The source is always a repo `.md` file authored by us (parsed by + * `@core/lesson-parse`), never user input — so `dangerouslySetInnerHTML` here is + * not an XSS surface. `marked` runs synchronously with default options. + */ +export function Markdown({ source, className }: { source: string; className?: string }): ReactNode { + const html = marked.parse(source, { async: false }); + return
; +} diff --git a/src/learn/SpecProgression.module.css b/src/learn/SpecProgression.module.css new file mode 100644 index 0000000..0c922a4 --- /dev/null +++ b/src/learn/SpecProgression.module.css @@ -0,0 +1,131 @@ +.progression { + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: var(--layer-01); +} + +/* ---- stage tabs ---- */ +.tabs { + display: flex; + flex-wrap: wrap; + gap: 0; + border-bottom: 1px solid var(--border); + background: var(--bg); +} +.tab { + display: inline-flex; + align-items: center; + gap: var(--space-2); + height: var(--control-height); + padding: 0 var(--space-4); + font: inherit; + font-size: 13px; + color: var(--text-secondary); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + white-space: nowrap; +} +.tab:hover { + color: var(--text); + background: var(--control-hover-fill); +} +.tab:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -2px; +} +.tabActive { + color: var(--text); + border-bottom-color: var(--accent); +} +.tabNum { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 11px; + border-radius: 50%; + background: var(--field); + color: var(--text-secondary); +} +.tabActive .tabNum { + background: var(--accent); + color: var(--accent-contrast); +} + +/* ---- the two-pane stage ---- */ +.panes { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: stretch; +} +.source { + margin: 0; + padding: var(--space-4) var(--space-5); + max-height: 420px; + overflow: auto; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + color: var(--text); + background: var(--field); + border-right: 1px solid var(--border); +} +.source:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -2px; +} +.line { + display: block; + white-space: pre; + padding: 0 var(--space-3); + margin: 0 calc(-1 * var(--space-3)); +} +/* The changed lines for this stage — what its edit did. */ +.added { + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent); +} +/* A block (not a centering flexbox): the chart host must inherit a definite + width from the grid column, or Vega's width:"container" fit collapses to ~0 + (the §2 trap in docs/embedding-vega-lite.md). min-width:0 lets the column + shrink below the chart's natural content width. */ +.chartPane { + min-width: 0; + padding: var(--space-5); +} +.chart { + width: 100%; + min-height: 180px; +} + +.note { + padding: var(--space-4) var(--space-5); + border-top: 1px solid var(--border); + font-size: 14px; + color: var(--text-secondary); + background: var(--bg); +} +.note p { + margin: 0; +} +.note code { + font-family: var(--font-mono); + font-size: 0.9em; + color: var(--text); +} + +/* Stack the panes on narrow screens. */ +@media (max-width: 720px) { + .panes { + grid-template-columns: 1fr; + } + .source { + border-right: none; + border-bottom: 1px solid var(--border); + max-height: 280px; + } +} diff --git a/src/learn/SpecProgression.tsx b/src/learn/SpecProgression.tsx new file mode 100644 index 0000000..b825be5 --- /dev/null +++ b/src/learn/SpecProgression.tsx @@ -0,0 +1,118 @@ +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 { formatSpec } from '@core/json-format'; +import { changedLines } from '@core/spec-diff'; +import { LandingChart } from '../landing/LandingChart'; +import { Markdown } from './Markdown'; +import styles from './SpecProgression.module.css'; + +/** + * The deep-dive viewer: tabs across the stages of a spec's 80%→100% progression, + * with the stage's source (changed lines highlighted vs. the previous stage) on + * the left and its live chart on the right. The chart reuses the landing's + * `LandingChart` — the one canonical chart-embed — so Vega stays lazy-loaded and + * every render-lifecycle fix lives in one place. + * + * Stages are a fixed, ordered authored list that never reorders, so the array + * index is their identity (React key + tab/panel id wiring). + * + * Keyboard: an APG tablist with roving tabindex, Arrow/Home/End navigation, and + * automatic activation (the panel is instant, so moving focus moves the stage). + */ +export function SpecProgression({ + stages, + config, +}: { + stages: ProgressionStage[]; + config: Config; +}): ReactNode { + const [active, setActive] = useState(0); + const tablistRef = useRef(null); + const baseId = useId(); + + const { lines, highlighted } = useMemo(() => { + const text = formatSpec(stages[active].spec); + const prev = active > 0 ? formatSpec(stages[active - 1].spec) : ''; + return { + lines: text.split('\n'), + highlighted: active > 0 ? changedLines(prev, text) : new Set(), + }; + }, [stages, active]); + + function focusTab(index: number): void { + setActive(index); + const tabs = tablistRef.current?.querySelectorAll('[role="tab"]'); + tabs?.[index]?.focus(); + } + + function onKeyDown(e: KeyboardEvent): void { + const last = stages.length - 1; + let next: number; + if (e.key === 'ArrowRight') next = active === last ? 0 : active + 1; + else if (e.key === 'ArrowLeft') next = active === 0 ? last : active - 1; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = last; + else return; + e.preventDefault(); + focusTab(next); + } + + const activeStage = stages[active]; + + return ( +
+
+ {stages.map((stage, i) => ( + + ))} +
+ +
+
+
+            
+              {lines.map((line, i) => (
+                
+                  {line || ' '}
+                
+              ))}
+            
+          
+
+ +
+
+ +
+
+ ); +} diff --git a/src/learn/lessons/binning.md b/src/learn/lessons/binning.md new file mode 100644 index 0000000..691af18 --- /dev/null +++ b/src/learn/lessons/binning.md @@ -0,0 +1,104 @@ +--- +slug: binning +title: From bars to a real histogram +tagline: How one word turns a noisy bar chart into a distribution — and where binning stops short. +--- + +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 — +each tab is one small edit — and watch a picket fence become a distribution, then a finished +chart. + +:::progression + +## raw counts + +A bar chart of a continuous field gives you _one bar per distinct value_ — a spiky picket +fence, not a distribution. It looks almost right, which is exactly what makes it the classic +80%. + +```vega-lite +{ + "$schema": "https://vega.github.io/schema/vega-lite/v6.json", + "mark": "bar", + "encoding": { + "x": { "field": "minutes", "type": "quantitative" }, + "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}] } +} +``` + +## + bin + +`bin: true` is the whole fix. Vega-Lite chooses ~10 "nice" boundaries, buckets the rows, and +counts each bucket — a real histogram from a one-word edit. + +```vega-lite +{ + "$schema": "https://vega.github.io/schema/vega-lite/v6.json", + "mark": "bar", + "encoding": { + "x": { "field": "minutes", "type": "quantitative", "bin": true }, + "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}] } +} +``` + +## + tuned bins + +Take control of the resolution: `bin: { step: 10 }` forces clean 10-minute buckets (or set +`maxbins` for a target count). Real titles turn axes from raw field names into a sentence. + +```vega-lite +{ + "$schema": "https://vega.github.io/schema/vega-lite/v6.json", + "mark": "bar", + "encoding": { + "x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" }, + "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}] } +} +``` + +## polished + +The last 20%: round the bar tops, add a tooltip that reports each bucket's range and count, +and _layer_ a mean line over the bars. Composition is where polish lives — one spec, two +marks sharing an axis. + +```vega-lite +{ + "$schema": "https://vega.github.io/schema/vega-lite/v6.json", + "layer": [ + { + "mark": { "type": "bar", "cornerRadiusEnd": 2 }, + "encoding": { + "x": { "field": "minutes", "type": "quantitative", "bin": { "step": 10 }, "title": "Delivery time (min)" }, + "y": { "aggregate": "count", "title": "Orders" }, + "tooltip": [ + { "field": "minutes", "bin": { "step": 10 }, "title": "Range (min)" }, + { "aggregate": "count", "title": "Orders" } + ] + } + }, + { + "mark": { "type": "rule", "color": "#d6336c", "size": 2 }, + "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}] } +} +``` + +::: + +:::sharp-edge +**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 +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 +lesson of its own. +::: diff --git a/src/learn/main.tsx b/src/learn/main.tsx new file mode 100644 index 0000000..c4223d4 --- /dev/null +++ b/src/learn/main.tsx @@ -0,0 +1,10 @@ +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 '../../styles/base.css'; +import '../../styles/chart-fonts.css'; + +createRoot(document.getElementById('root')!).render(); diff --git a/vite.config.ts b/vite.config.ts index 7227cda..0411925 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,9 +23,12 @@ export default defineConfig({ // Multi-page: the marketing landing is served at `/` (index.html → the // 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. 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)), }, output: { // Split the heavy vendors so the chunk graph stays legible and the PWA