mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Learn: a /learn/ deep-dive section — markdown lessons as before/after spec progressions
This commit is contained in:
@@ -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
|
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
|
`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
|
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
|
See [`docs/architecture/`](docs/architecture/00-overview.md) for the patterns behind each
|
||||||
layer (state, persistence, modals, routing, rendering, inference, relationships) and
|
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 /)
|
index.html # Landing entry (served at /)
|
||||||
app/index.html # App entry (served at /app/)
|
app/index.html # App entry (served at /app/)
|
||||||
|
learn/index.html # Learning-section entry (served at /learn/)
|
||||||
src/
|
src/
|
||||||
├── main.tsx # App bootstrap (font wiring, startup, render)
|
├── main.tsx # App bootstrap (font wiring, startup, render)
|
||||||
├── landing/ # Marketing landing at / — standalone page; reuses core + chart-renderer
|
├── 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)
|
├── core/ # Portable spec engine (no browser/React/Monaco)
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── components/ # React UI (CSS Modules co-located)
|
│ ├── components/ # React UI (CSS Modules co-located)
|
||||||
@@ -71,7 +75,7 @@ src/
|
|||||||
styles/ # Global CSS (tokens, base)
|
styles/ # Global CSS (tokens, base)
|
||||||
docs/
|
docs/
|
||||||
├── spec/ # Authoritative behavioral specification (00–10) — the WHAT
|
├── 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
|
│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog
|
||||||
├── exploration/ # Point-in-time records (research, reviews, scope memos) — not maintained
|
├── exploration/ # Point-in-time records (research, reviews, scope memos) — not maintained
|
||||||
├── IMPLEMENTATION-PLAN.md # Milestone sequence (M0–M6)
|
├── 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
|
transforms, import normalization). Lighter on components. Extract testable logic out of
|
||||||
components into core/stores where practical.
|
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
|
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
|
`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
|
`vi.mock('../services/chart-renderer', …)` for anything that embeds a chart (vega-embed is
|
||||||
|
|||||||
@@ -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):
|
- **[docs/spec/](docs/spec/)** — authoritative behavioral specification (sections 00–10):
|
||||||
the **what**. This is the contract; implement to it.
|
the **what**. This is the contract; implement to it.
|
||||||
- **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook
|
- **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook
|
||||||
(00–10): the **how** (state, persistence, modals, routing, rendering, inference,
|
(00–11): the **how** (state, persistence, modals, routing, rendering, inference,
|
||||||
relationships, vega-editor techniques, visual design, interaction & feedback).
|
relationships, vega-editor techniques, visual design, interaction & feedback, learning
|
||||||
|
section).
|
||||||
Self-contained — no external repo needed. Companion:
|
Self-contained — no external repo needed. Companion:
|
||||||
**[visual-specimen.html](docs/architecture/visual-specimen.html)** — token sandbox +
|
**[visual-specimen.html](docs/architecture/visual-specimen.html)** — token sandbox +
|
||||||
reusable-primitive catalog (open in a browser).
|
reusable-primitive catalog (open in a browser).
|
||||||
|
|||||||
@@ -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. |
|
| 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). |
|
| 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_. |
|
| 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)
|
## The non-negotiable layering (every doc assumes this)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!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="Interactive deep dives into Vega-Lite — the hidden capabilities and how they combine. For people past the basics who want the full power of the spec."
|
||||||
|
/>
|
||||||
|
<title>Vega-Lite, deeper — Astrolabe</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/learn/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+17
-4
@@ -22,6 +22,7 @@
|
|||||||
"@fontsource/space-mono": "^5.2.9",
|
"@fontsource/space-mono": "^5.2.9",
|
||||||
"@fontsource/spectral": "^5.2.8",
|
"@fontsource/spectral": "^5.2.8",
|
||||||
"json-stringify-pretty-compact": "^4.0.0",
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"marked": "^18.0.5",
|
||||||
"monaco-editor": "^0.54.0",
|
"monaco-editor": "^0.54.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -6386,15 +6387,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "14.0.0",
|
"version": "18.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
@@ -6456,6 +6457,18 @@
|
|||||||
"marked": "14.0.0"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"@fontsource/space-mono": "^5.2.9",
|
"@fontsource/space-mono": "^5.2.9",
|
||||||
"@fontsource/spectral": "^5.2.8",
|
"@fontsource/spectral": "^5.2.8",
|
||||||
"json-stringify-pretty-compact": "^4.0.0",
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"marked": "^18.0.5",
|
||||||
"monaco-editor": "^0.54.0",
|
"monaco-editor": "^0.54.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatJson } from './json-format';
|
import { formatJson, formatSpec } from './json-format';
|
||||||
|
|
||||||
describe('formatJson', () => {
|
describe('formatJson', () => {
|
||||||
it('pretty-prints a valid object with the default indent', () => {
|
it('pretty-prints a valid object with the default indent', () => {
|
||||||
@@ -43,3 +43,23 @@ describe('formatJson', () => {
|
|||||||
expect(formatJson('true')).toBe('true');
|
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}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+19
-4
@@ -17,6 +17,24 @@ const DEFAULT_MAX_LINE = 80;
|
|||||||
/** Default indent (spaces) — matches the editor's default tab size (spec §07). */
|
/** Default indent (spaces) — matches the editor's default tab size (spec §07). */
|
||||||
const DEFAULT_INDENT = 2;
|
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
|
* 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
|
* valid JSON. Returning `null` (rather than throwing) lets callers skip a no-op
|
||||||
@@ -34,8 +52,5 @@ export function formatJson(
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return stringify(parsed, {
|
return formatSpec(parsed, opts);
|
||||||
indent: opts.indent ?? DEFAULT_INDENT,
|
|
||||||
maxLength: opts.maxLength ?? DEFAULT_MAX_LINE,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProseBlock {
|
||||||
|
type: 'prose';
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
export interface ChartBlock {
|
||||||
|
type: 'chart';
|
||||||
|
spec: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(raw: string): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
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<string, string>, key: string): string {
|
||||||
|
const v = meta.get(key);
|
||||||
|
if (!v) throw new Error(`lesson frontmatter: missing "${key}"`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number> {
|
||||||
|
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<number>(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<number>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import { DEMO_CUSTOM_THEMES } from './demo-themes';
|
|||||||
import { LandingChart } from './LandingChart';
|
import { LandingChart } from './LandingChart';
|
||||||
import styles from './Landing.module.css';
|
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';
|
type UiTheme = 'light' | 'dark';
|
||||||
|
|
||||||
// Minimal JSON syntax highlighter for the read-only spec view: keys, strings,
|
// Minimal JSON syntax highlighter for the read-only spec view: keys, strings,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <Markdown>.
|
||||||
|
const LESSON_SOURCES = import.meta.glob<string>('./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<UiTheme>('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 (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<a className={styles.brand} href="/">
|
||||||
|
Astrolabe
|
||||||
|
</a>
|
||||||
|
<nav className={styles.nav}>
|
||||||
|
<span className={styles.kicker}>Vega-Lite, deeper</span>
|
||||||
|
<button className={styles.ghost} type="button" onClick={toggleTheme}>
|
||||||
|
{theme === 'dark' ? 'Light' : 'Dark'}
|
||||||
|
</button>
|
||||||
|
<a className={styles.appLink} href="/app/">
|
||||||
|
Open the app
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
{LESSONS.map((lesson) => (
|
||||||
|
<LessonArticle key={lesson.slug} lesson={lesson} config={config} />
|
||||||
|
))}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<span>An interactive deep dive into the parts of Vega-Lite the docs gloss over.</span>
|
||||||
|
<a href="/">← Back to Astrolabe</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartConfig = ReturnType<typeof chartConfigForSelection>;
|
||||||
|
|
||||||
|
function LessonArticle({
|
||||||
|
lesson,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
lesson: ParsedLesson;
|
||||||
|
config: ChartConfig;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<article className={styles.article}>
|
||||||
|
<p className={styles.kickerLine}>Deep dive</p>
|
||||||
|
<h1 className={styles.title}>{lesson.title}</h1>
|
||||||
|
<p className={styles.tagline}>{lesson.tagline}</p>
|
||||||
|
|
||||||
|
{lesson.blocks.map((block, i) => (
|
||||||
|
<LessonBlockView key={i} block={block} config={config} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className={styles.cta}>
|
||||||
|
<p>
|
||||||
|
Open this spec in Astrolabe and keep tweaking — edit the JSON, swap the data, theme it.
|
||||||
|
</p>
|
||||||
|
<a className={styles.ctaButton} href="/app/">
|
||||||
|
Open in Astrolabe →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 <Markdown className={styles.prose} source={block.markdown} />;
|
||||||
|
case 'progression':
|
||||||
|
return (
|
||||||
|
<div className={styles.block}>
|
||||||
|
<SpecProgression stages={block.stages} config={config} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'chart':
|
||||||
|
return (
|
||||||
|
<div className={styles.block}>
|
||||||
|
<LandingChart spec={block.spec} config={config} className={styles.chart} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'callout':
|
||||||
|
return (
|
||||||
|
<Markdown
|
||||||
|
className={block.variant === 'sharp-edge' ? styles.sharpEdge : styles.callout}
|
||||||
|
source={block.markdown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = block;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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<number>(),
|
||||||
|
};
|
||||||
|
}, [stages, active]);
|
||||||
|
|
||||||
|
function focusTab(index: number): void {
|
||||||
|
setActive(index);
|
||||||
|
const tabs = tablistRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]');
|
||||||
|
tabs?.[index]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent<HTMLDivElement>): 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 (
|
||||||
|
<div className={styles.progression}>
|
||||||
|
<div
|
||||||
|
className={styles.tabs}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Lesson stages"
|
||||||
|
ref={tablistRef}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{stages.map((stage, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
id={`${baseId}-tab-${i}`}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={i === active}
|
||||||
|
aria-controls={`${baseId}-panel`}
|
||||||
|
tabIndex={i === active ? 0 : -1}
|
||||||
|
className={`${styles.tab} ${i === active ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActive(i)}
|
||||||
|
>
|
||||||
|
<span className={styles.tabNum}>{i + 1}</span>
|
||||||
|
<span>{stage.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id={`${baseId}-panel`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`${baseId}-tab-${active}`}
|
||||||
|
className={styles.panel}
|
||||||
|
>
|
||||||
|
<div className={styles.panes}>
|
||||||
|
<pre className={styles.source} aria-label="Spec source" tabIndex={0}>
|
||||||
|
<code>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<span
|
||||||
|
// Lines have no stable identity; index is the intended key here.
|
||||||
|
key={i}
|
||||||
|
className={`${styles.line} ${highlighted.has(i) ? styles.added : ''}`}
|
||||||
|
>
|
||||||
|
{line || ' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
<div className={styles.chartPane}>
|
||||||
|
<LandingChart spec={activeStage.spec} config={config} className={styles.chart} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Markdown className={styles.note} source={activeStage.note} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
:::
|
||||||
@@ -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(<LearnPage />);
|
||||||
@@ -23,9 +23,12 @@ export default defineConfig({
|
|||||||
// Multi-page: the marketing landing is served at `/` (index.html → the
|
// Multi-page: the marketing landing is served at `/` (index.html → the
|
||||||
// lightweight `src/landing` entry); the app at `/app/` (app/index.html →
|
// 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/.
|
// 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: {
|
input: {
|
||||||
main: fileURLToPath(new URL('./index.html', import.meta.url)),
|
main: fileURLToPath(new URL('./index.html', import.meta.url)),
|
||||||
app: fileURLToPath(new URL('./app/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: {
|
output: {
|
||||||
// Split the heavy vendors so the chunk graph stays legible and the PWA
|
// Split the heavy vendors so the chunk graph stays legible and the PWA
|
||||||
|
|||||||
Reference in New Issue
Block a user