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:
@@ -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}');
|
||||
});
|
||||
});
|
||||
|
||||
+19
-4
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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 />);
|
||||
Reference in New Issue
Block a user