From cfc147173dde13b17c25e8da1179fddafa8c0294 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 03:43:04 -0800 Subject: [PATCH 1/2] Add animated thumbnails to the catalog, starting with the Trees family Each tree page names a curated example in `preview`; src/render/preview.ts runs it and strips every step to bare shapes (no text) inside one fixed frame. The build writes dist/previews.json and the dev server makes it on request, so nothing generated is committed. The catalog shows the final frame left of each title and loops through the steps on hover or keyboard focus, never under reduced motion. Page stylesheets now load on the catalog too, so red-black nodes keep their colours. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015Hhm4gYmBnZQozfDqkFdPy --- CHANGES.md | 4 ++ CLAUDE.md | 3 ++ scripts/prerender.mjs | 14 ++++-- src/engine/types.ts | 12 +++++ src/render/preview.test.ts | 41 ++++++++++++++++ src/render/preview.ts | 88 ++++++++++++++++++++++++++++++++++ src/ui/Catalog.svelte | 30 +++++++++++- src/ui/PreviewThumb.svelte | 84 ++++++++++++++++++++++++++++++++ src/viz/avl/index.ts | 1 + src/viz/b-plus-tree/index.ts | 1 + src/viz/b-tree/index.ts | 1 + src/viz/binary-tree/index.ts | 1 + src/viz/bst/index.ts | 1 + src/viz/red-black/index.ts | 1 + src/viz/skip-list/index.ts | 1 + src/viz/splay/index.ts | 1 + src/viz/treap/index.ts | 1 + src/viz/two-four-tree/index.ts | 1 + tests/e2e/catalog.spec.ts | 55 +++++++++++++++++++++ vite.config.ts | 27 ++++++++++- 20 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 src/render/preview.test.ts create mode 100644 src/render/preview.ts create mode 100644 src/ui/PreviewThumb.svelte create mode 100644 tests/e2e/catalog.spec.ts diff --git a/CHANGES.md b/CHANGES.md index ae4f092..5f03795 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -173,3 +173,7 @@ 2026-11-07 [note] Session session_01JyJzMXcc2unSAwN88hsew5 took over at 08:00 and moved the three relay routines; Netlify auto-publishing is unlocked again (the live site still served a build from early 5 November) 2026-11-07 [decision] Small drawings on laptops: Yusuf chose option 3 (a taller desktop stage first, then views that lay out for the stage); stage 305 to 387 px, Options in the side column, view(step, settings, stage) used by the 26 graph pages + +2026-11-07 [decision] Catalog thumbnails: Yusuf chose animated SVG drawn from data over GIF (all themes, about 1.5 KB a page), left of each title, play on hover or focus, text stripped; pilot family Trees + +2026-11-07 [code] Trees thumbnails: VizDef.preview names an example; src/render/preview.ts strips it to shapes in one frame; prerender writes dist/previews.json and the dev server makes it on request diff --git a/CLAUDE.md b/CLAUDE.md index 679a197..4e3f70b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,9 @@ everything on every push; Netlify builds and deploys `main` (`netlify.toml`). - Every visualization has its own page, `//`, written by `scripts/prerender.mjs` after `vite build` (title, description, preview tags, canonical URL). The hash holds only state: `#ex=&embed=1&lesson=
`. Old `/#/?...` links redirect. +- Catalog thumbnails: `preview` in the `VizDef` names a curated example (and a step level, and + shape ids to hide); `src/render/preview.ts` strips it to bare shapes and `scripts/prerender.mjs` + writes `dist/previews.json`. Pilot: the Trees family (`src/render/preview.test.ts`). - The app loads each visualization on demand (`src/viz/registry.ts`); tests and prerender use `src/viz/all.ts`. - No analytics or tracking. No external fonts or CDNs. diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index c5fe323..f56747a 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -1,6 +1,7 @@ // After `vite build`: write dist//index.html for every visualization, so each page has its -// own title, description and link preview, plus a sitemap and robots.txt. The app itself is the -// same on every page; it reads the page's path to decide what to show. +// own title, description and link preview, plus a sitemap, robots.txt and the catalog thumbnails +// (previews.json). The app itself is the same on every page; it reads the page's path to decide +// what to show. import fs from 'node:fs'; import path from 'node:path'; import { createServer } from 'vite'; @@ -44,6 +45,11 @@ try { fs.writeFileSync(path.join(dist, def.id, 'index.html'), html); } + // Catalog thumbnails: the steps of one example per page, stripped to bare shapes. + const { makePreviews } = await server.ssrLoadModule('/src/render/preview.ts'); + const previews = makePreviews(vizzes); + fs.writeFileSync(path.join(dist, 'previews.json'), JSON.stringify(previews)); + const urls = [`${SITE_URL}/`, ...vizzes.map((d) => `${SITE_URL}/${d.id}/`)]; fs.writeFileSync( path.join(dist, 'sitemap.xml'), @@ -55,7 +61,9 @@ try { path.join(dist, 'robots.txt'), `User-agent: *\nAllow: /\nSitemap: ${SITE_URL}/sitemap.xml\n`, ); - console.log(`prerendered ${vizzes.length} visualization pages`); + console.log( + `prerendered ${vizzes.length} visualization pages, ${Object.keys(previews).length} previews`, + ); } finally { await server.close(); } diff --git a/src/engine/types.ts b/src/engine/types.ts index 5ec8b23..447be9a 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -223,6 +223,16 @@ export interface StageSize { h: number; } +/** The catalog thumbnail: one curated example played as bare shapes (no text). */ +export interface PreviewSpec { + /** Id of the curated example to play; default the first. */ + example?: string; + /** Finest step level to include; default 1 (milestones only). */ + level?: Level; + /** Shape id prefixes to leave out, such as the box that holds an operation's argument. */ + hide?: string[]; +} + export interface VizDef { id: string; title: string; @@ -254,6 +264,8 @@ export interface VizDef { code?: CodeTabs; /** Generators for seeded practice sets (the Practice tab). */ practice?: PracticeKind[]; + /** The animated thumbnail next to the page's title in the catalog. */ + preview?: PreviewSpec; } // Erased form used by generic UI code. diff --git a/src/render/preview.test.ts b/src/render/preview.test.ts new file mode 100644 index 0000000..61a3a7b --- /dev/null +++ b/src/render/preview.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { vizzes } from '../viz/all'; +import { families } from '../viz/catalog'; +import { makePreview, MAX_FRAMES } from './preview'; + +// The pilot family: every page in it has a thumbnail. Later families join this list. +const WITH_PREVIEWS = ['Trees']; + +describe('catalog previews', () => { + it('every page in the families with thumbnails asks for one', () => { + const ids = families.filter((f) => WITH_PREVIEWS.includes(f.name)).flatMap((f) => f.items); + for (const { id } of ids) { + expect(vizzes.find((d) => d.id === id)?.preview, id).toBeDefined(); + } + }); + + for (const def of vizzes.filter((d) => d.preview)) { + it(`${def.id}: bare shapes that move, inside one fixed frame`, () => { + const p = makePreview(def)!; + expect(p.frames.length).toBeGreaterThanOrEqual(4); + expect(p.frames.length).toBeLessThanOrEqual(MAX_FRAMES); + const b = p.bounds; + const inside = (x: number, y: number) => + x >= b.x - 0.5 && x <= b.x + b.w + 0.5 && y >= b.y - 0.5 && y <= b.y + b.h + 0.5; + let changes = 0; + p.frames.forEach((shapes, i) => { + expect(shapes.length, `frame ${i}`).toBeGreaterThan(0); + expect(new Set(shapes.map((s) => s.id)).size, `frame ${i} ids`).toBe(shapes.length); + for (const s of shapes) { + expect(s.kind).not.toBe('text'); + if (s.kind === 'arrow') expect(inside(s.x1, s.y1) && inside(s.x2, s.y2), s.id).toBe(true); + else if (s.kind === 'polygon') expect(s.points.every((q) => inside(q.x, q.y))).toBe(true); + else expect(inside(s.x, s.y), s.id).toBe(true); + } + if (i > 0 && JSON.stringify(shapes) !== JSON.stringify(p.frames[i - 1])) changes++; + }); + // A thumbnail that barely changes does not show the idea. + expect(changes).toBeGreaterThanOrEqual(3); + }); + } +}); diff --git a/src/render/preview.ts b/src/render/preview.ts new file mode 100644 index 0000000..b06b473 --- /dev/null +++ b/src/render/preview.ts @@ -0,0 +1,88 @@ +import { normalizeSettings } from '../engine/example'; +import { Timeline } from '../engine/timeline'; +import type { AnyVizDef } from '../engine/types'; +import { shapesBounds, type Bounds, type Shape } from './scene'; + +/** + * A small looping animation for the catalog: the steps of one curated example, stripped to bare + * shapes. Text is dropped (it is unreadable at thumbnail size) and one fixed frame holds every + * step, so the camera stays still while the structure grows and moves. + */ +export interface Preview { + bounds: Bounds; + frames: Shape[][]; +} + +/** The most frames a preview keeps; longer examples are sampled evenly, keeping the last. */ +export const MAX_FRAMES = 24; + +const round = (v: number) => Math.round(v * 10) / 10; + +/** Drop text, round coordinates (the JSON is served to every visitor), keep identity and role. */ +function strip(shapes: Shape[], hide: string[]): Shape[] { + const out: Shape[] = []; + for (const s of shapes) { + if (s.kind === 'text' || (s.opacity ?? 1) <= 0) continue; + if (hide.some((h) => s.id.startsWith(h))) continue; + const c = { ...s } as Record; + for (const k of ['x', 'y', 'w', 'h', 'r', 'x1', 'y1', 'x2', 'y2']) { + if (typeof c[k] === 'number') c[k] = round(c[k] as number); + } + if (s.kind === 'polygon') c.points = s.points.map((q) => ({ x: round(q.x), y: round(q.y) })); + if (s.enterFrom) c.enterFrom = { x: round(s.enterFrom.x), y: round(s.enterFrom.y) }; + out.push(c as unknown as Shape); + } + return out; +} + +function sample(items: T[], n: number): T[] { + if (items.length <= n) return items; + return Array.from( + { length: n }, + (_, i) => items[Math.round((i * (items.length - 1)) / (n - 1))]!, + ); +} + +/** The preview a page asks for with `preview`, or null if it asks for none. */ +export function makePreview(def: AnyVizDef): Preview | null { + const spec = def.preview; + if (!spec) return null; + const ex = spec.example ? def.examples.find((e) => e.id === spec.example) : def.examples[0]; + if (!ex) throw new Error(`${def.id}: preview example "${spec.example}" not found`); + const settings = normalizeSettings(def.settings, def.defaults, ex.settings); + const tl = new Timeline(def, settings, ex.seed, ex.ops); + const level = spec.level ?? 1; + const steps = []; + for (let i = 0; i < tl.length; i++) { + const step = tl.at(i).step; + if (step.level <= level) steps.push(step); + } + const frames = sample(steps, MAX_FRAMES) + .map((step) => strip(def.view(step, settings).shapes, spec.hide ?? [])) + // Keep the drawing on screen from the first frame: an empty start shows nothing. + .filter((shapes, i, all) => shapes.length > 0 || i === all.length - 1); + const boxes = frames.map((f) => shapesBounds(f, 0)).filter((b): b is Bounds => b !== null); + if (boxes.length === 0) throw new Error(`${def.id}: preview draws nothing`); + const x1 = Math.min(...boxes.map((b) => b.x)); + const y1 = Math.min(...boxes.map((b) => b.y)); + const x2 = Math.max(...boxes.map((b) => b.x + b.w)); + const y2 = Math.max(...boxes.map((b) => b.y + b.h)); + const pad = Math.max(x2 - x1, y2 - y1) * 0.04; + const bounds = { + x: round(x1 - pad), + y: round(y1 - pad), + w: round(x2 - x1 + 2 * pad), + h: round(y2 - y1 + 2 * pad), + }; + return { bounds, frames }; +} + +/** Previews of every page that asks for one, keyed by page id. */ +export function makePreviews(defs: AnyVizDef[]): Record { + const out: Record = {}; + for (const def of defs) { + const p = makePreview(def); + if (p) out[def.id] = p; + } + return out; +} diff --git a/src/ui/Catalog.svelte b/src/ui/Catalog.svelte index 2a61816..233b3fe 100644 --- a/src/ui/Catalog.svelte +++ b/src/ui/Catalog.svelte @@ -1,12 +1,27 @@ + + + + diff --git a/src/viz/avl/index.ts b/src/viz/avl/index.ts index 5456701..e2a914c 100644 --- a/src/viz/avl/index.ts +++ b/src/viz/avl/index.ts @@ -266,6 +266,7 @@ export const avl: VizDef = { deleteLevelChild, deletePredecessor, ] as unknown as SavedExample[], + preview: { example: 'sorted-input', hide: ['arg-box'] }, }; /** diff --git a/src/viz/b-plus-tree/index.ts b/src/viz/b-plus-tree/index.ts index 06af996..ef556bb 100644 --- a/src/viz/b-plus-tree/index.ts +++ b/src/viz/b-plus-tree/index.ts @@ -185,6 +185,7 @@ export const bPlusTree: VizDef = { merge, mergeCascade, ] as unknown as SavedExample[], + preview: { example: 'push-up', level: 2, hide: ['arg-box'] }, }; /** diff --git a/src/viz/b-tree/index.ts b/src/viz/b-tree/index.ts index c078871..adf1f30 100644 --- a/src/viz/b-tree/index.ts +++ b/src/viz/b-tree/index.ts @@ -177,6 +177,7 @@ export const bTree: VizDef = { mergeDown, mergeShrinks, ] as unknown as SavedExample[], + preview: { example: 'ascending', hide: ['arg-box'] }, }; /** diff --git a/src/viz/binary-tree/index.ts b/src/viz/binary-tree/index.ts index 0f90342..62ac23e 100644 --- a/src/viz/binary-tree/index.ts +++ b/src/viz/binary-tree/index.ts @@ -183,6 +183,7 @@ export const binaryTree: VizDef = { skewedDepth, widestLevel, ] as unknown as SavedExample[], + preview: { example: 'four-orders' }, }; /** diff --git a/src/viz/bst/index.ts b/src/viz/bst/index.ts index 9c9e0a1..0d843ba 100644 --- a/src/viz/bst/index.ts +++ b/src/viz/bst/index.ts @@ -278,6 +278,7 @@ export const bst: VizDef = { duplicates, keyValue, ] as unknown as SavedExample[], + preview: { example: 'balanced-order', level: 2, hide: ['arg-box'] }, }; /** diff --git a/src/viz/red-black/index.ts b/src/viz/red-black/index.ts index e8a201a..3533c9c 100644 --- a/src/viz/red-black/index.ts +++ b/src/viz/red-black/index.ts @@ -205,6 +205,7 @@ export const redBlack: VizDef = { deleteCase4, deleteThreeRotations, ] as unknown as SavedExample[], + preview: { example: 'sorted-input' }, }; /** diff --git a/src/viz/skip-list/index.ts b/src/viz/skip-list/index.ts index 3c3c24d..8e74512 100644 --- a/src/viz/skip-list/index.ts +++ b/src/viz/skip-list/index.ts @@ -160,6 +160,7 @@ export const skipList: VizDef = { quarter, flatList, ] as unknown as SavedExample[], + preview: { example: 'build', level: 2, hide: ['arg-box', 'cell-arg'] }, }; /** diff --git a/src/viz/splay/index.ts b/src/viz/splay/index.ts index 127431a..5a9d392 100644 --- a/src/viz/splay/index.ts +++ b/src/viz/splay/index.ts @@ -180,6 +180,7 @@ export const splayTree: VizDef = { deleteJoin, deleteBst, ] as unknown as SavedExample[], + preview: { example: 'repeated-access', hide: ['arg-box'] }, }; /** diff --git a/src/viz/treap/index.ts b/src/viz/treap/index.ts index 00355ee..134fa83 100644 --- a/src/viz/treap/index.ts +++ b/src/viz/treap/index.ts @@ -189,6 +189,7 @@ export const treap: VizDef = { typedPriorities, maxHeap, ] as unknown as SavedExample[], + preview: { example: 'sorted-keys' }, }; /** diff --git a/src/viz/two-four-tree/index.ts b/src/viz/two-four-tree/index.ts index 0665e76..747b865 100644 --- a/src/viz/two-four-tree/index.ts +++ b/src/viz/two-four-tree/index.ts @@ -152,6 +152,7 @@ export const twoFourTree: VizDef = { fusion, fusionCascade, ] as unknown as SavedExample[], + preview: { example: 'split-cascade' }, }; /** diff --git a/tests/e2e/catalog.spec.ts b/tests/e2e/catalog.spec.ts new file mode 100644 index 0000000..fd081c0 --- /dev/null +++ b/tests/e2e/catalog.spec.ts @@ -0,0 +1,55 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; + +const trees = (page: Page) => + page.locator('section.family', { has: page.getByRole('heading', { name: 'Trees' }) }); +const thumb = (page: Page, title: string) => + trees(page).getByRole('link', { name: title, exact: true }).locator('svg.thumb'); +const drawing = (page: Page, title: string) => thumb(page, title).innerHTML(); + +test('every tree page has a thumbnail, hidden from screen readers', async ({ page }) => { + await page.goto('/'); + await expect(trees(page).locator('svg.thumb')).toHaveCount(10); + await expect(thumb(page, 'Red-black tree')).toHaveAttribute('aria-hidden', 'true'); + // The thumbnail is drawn shapes only: no text. + await expect(thumb(page, 'Binary search tree').locator('circle').first()).toBeVisible(); + await expect(thumb(page, 'Binary search tree').locator('text')).toHaveCount(0); +}); + +test('a thumbnail plays while hovered and rests on its final frame', async ({ page, isMobile }) => { + test.skip(isMobile, 'Phones have no hover'); + await page.goto('/'); + await thumb(page, 'AVL tree').waitFor(); + const rest = await drawing(page, 'AVL tree'); + await trees(page).getByRole('link', { name: 'AVL tree', exact: true }).hover(); + await expect.poll(() => drawing(page, 'AVL tree')).not.toBe(rest); + await page.mouse.move(0, 0); + await expect.poll(() => drawing(page, 'AVL tree')).toBe(rest); +}); + +test('keyboard focus plays a thumbnail too', async ({ page }) => { + await page.goto('/'); + await thumb(page, 'Treap').waitFor(); + const rest = await drawing(page, 'Treap'); + await trees(page).getByRole('link', { name: 'Treap', exact: true }).focus(); + await expect.poll(() => drawing(page, 'Treap')).not.toBe(rest); +}); + +test('thumbnails stay still when the reader asks for reduced motion', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/'); + await thumb(page, 'B-tree').waitFor(); + const rest = await drawing(page, 'B-tree'); + await trees(page).getByRole('link', { name: 'B-tree', exact: true }).focus(); + await page.waitForTimeout(1000); + expect(await drawing(page, 'B-tree')).toBe(rest); +}); + +test('the catalog with thumbnails has no axe violations', async ({ page }) => { + await page.goto('/'); + await expect(trees(page).locator('svg.thumb')).toHaveCount(10); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) + .analyze(); + expect(results.violations.map((v) => `${v.id}: ${v.nodes.length}`)).toEqual([]); +}); diff --git a/vite.config.ts b/vite.config.ts index 9d65a43..a6a51b6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,31 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, type Plugin } from 'vitest/config'; import { svelte } from '@sveltejs/vite-plugin-svelte'; +/** + * Serves the catalog thumbnails (/previews.json) in development, made fresh on every request + * from the same code the build uses (scripts/prerender.mjs writes the file into dist). + */ +function previews(): Plugin { + return { + name: 'stepwise-previews', + configureServer(server) { + server.middlewares.use('/previews.json', (_req, res, next) => { + Promise.all([ + server.ssrLoadModule('/src/viz/all.ts'), + server.ssrLoadModule('/src/render/preview.ts'), + ]) + .then(([{ vizzes }, { makePreviews }]) => { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(makePreviews(vizzes))); + }) + .catch(next); + }); + }, + }; +} + export default defineConfig({ - plugins: [svelte()], + plugins: [svelte(), previews()], // Absolute, because each visualization has its own page at //. base: '/', build: { target: 'es2022' }, From 530fb10f6aa596fb83e40a342e08f7ae4ef7054a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:04:38 -0800 Subject: [PATCH 2/2] Give every catalog page an animated thumbnail All 108 pages now name a preview example. The still picture is the fullest frame, so examples that end empty still show something. Every preview drops the input and output boxes; graph pages hide their side tables and label boxes, geometry pages their matrix brackets, so the main picture fills the box. Arrowheads are off in thumbnails: markers scale with the unscaled stroke and swamped the drawings. The catalog now loads only the still pictures (previews.json, 40 KB gzipped); a page's frames (previews/.json, at most 5 KB gzipped) are fetched the first time its thumbnail plays, so phones and readers who ask for reduced motion never fetch them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015Hhm4gYmBnZQozfDqkFdPy --- CHANGES.md | 2 + CLAUDE.md | 8 +++- scripts/prerender.mjs | 15 +++++-- src/engine/types.ts | 2 +- src/render/preview.test.ts | 15 +++---- src/render/preview.ts | 37 ++++++++++++++++- src/ui/Catalog.svelte | 14 ++++--- src/ui/PreviewThumb.svelte | 55 +++++++++++++++++++------- src/viz/a-star/index.ts | 1 + src/viz/avl/index.ts | 2 +- src/viz/b-plus-tree/index.ts | 2 +- src/viz/b-tree/index.ts | 2 +- src/viz/backtracking/index.ts | 1 + src/viz/bellman-ford/index.ts | 1 + src/viz/bfs/index.ts | 1 + src/viz/big-o-lab/index.ts | 1 + src/viz/binary-heap/index.ts | 1 + src/viz/binary-search/index.ts | 1 + src/viz/binomial-queue/index.ts | 1 + src/viz/bipartite-matching/index.ts | 1 + src/viz/bipartite/index.ts | 1 + src/viz/bitmask/index.ts | 1 + src/viz/boyer-moore/index.ts | 1 + src/viz/bridges/index.ts | 1 + src/viz/brute-force-search/index.ts | 1 + src/viz/bst/index.ts | 2 +- src/viz/bubble-sort/index.ts | 1 + src/viz/bucket-sort/index.ts | 1 + src/viz/circular-linked-list/index.ts | 1 + src/viz/cocktail-sort/index.ts | 1 + src/viz/compressed-trie/index.ts | 1 + src/viz/connected-components/index.ts | 1 + src/viz/convex-hull/index.ts | 1 + src/viz/coordinates-2d/index.ts | 1 + src/viz/coordinates-3d/index.ts | 1 + src/viz/counting-sort/index.ts | 1 + src/viz/dag-shortest-paths/index.ts | 1 + src/viz/deque-array/index.ts | 1 + src/viz/deque-list/index.ts | 1 + src/viz/dfs/index.ts | 1 + src/viz/dijkstra/index.ts | 1 + src/viz/disjoint-sets/index.ts | 1 + src/viz/doubly-linked-list/index.ts | 1 + src/viz/dp-change/index.ts | 1 + src/viz/dp-edit-distance/index.ts | 1 + src/viz/dp-fibonacci/index.ts | 1 + src/viz/dp-knapsack/index.ts | 1 + src/viz/dp-lcs/index.ts | 1 + src/viz/dynamic-array/index.ts | 1 + src/viz/factorial/index.ts | 1 + src/viz/fenwick-tree/index.ts | 1 + src/viz/fibonacci-calls/index.ts | 1 + src/viz/fibonacci-heap/index.ts | 1 + src/viz/floyd-cycle/index.ts | 1 + src/viz/floyd-warshall/index.ts | 1 + src/viz/general-matching/index.ts | 1 + src/viz/graph-representations/index.ts | 1 + src/viz/grid-bfs/index.ts | 1 + src/viz/hash-buckets/index.ts | 1 + src/viz/hash-chaining/index.ts | 1 + src/viz/hash-open/index.ts | 1 + src/viz/hash-rehash/index.ts | 1 + src/viz/heap-sort/index.ts | 1 + src/viz/huffman/index.ts | 1 + src/viz/insertion-sort/index.ts | 1 + src/viz/kmp/index.ts | 1 + src/viz/kruskal/index.ts | 1 + src/viz/leftist-heap/index.ts | 1 + src/viz/linear-search/index.ts | 1 + src/viz/linked-list/index.ts | 1 + src/viz/lru-cache/index.ts | 1 + src/viz/master-theorem/index.ts | 1 + src/viz/max-flow/index.ts | 1 + src/viz/memory-model/index.ts | 1 + src/viz/merge-sort/index.ts | 1 + src/viz/min-cost-flow/index.ts | 1 + src/viz/n-queens/index.ts | 1 + src/viz/np-reductions/index.ts | 1 + src/viz/polygon/index.ts | 1 + src/viz/prim/index.ts | 1 + src/viz/queue-array/index.ts | 1 + src/viz/queue-list/index.ts | 1 + src/viz/quick-sort/index.ts | 1 + src/viz/quickselect/index.ts | 1 + src/viz/rabin-karp/index.ts | 1 + src/viz/radix-sort/index.ts | 1 + src/viz/range-queries/index.ts | 1 + src/viz/recursion-dag/index.ts | 1 + src/viz/reverse-string/index.ts | 1 + src/viz/rotate-scale-2d/index.ts | 1 + src/viz/rotate-scale-3d/index.ts | 1 + src/viz/rotate-translate-2d/index.ts | 1 + src/viz/scc/index.ts | 1 + src/viz/segment-tree/index.ts | 1 + src/viz/selection-sort/index.ts | 1 + src/viz/shell-sort/index.ts | 1 + src/viz/skew-heap/index.ts | 1 + src/viz/skip-list/index.ts | 2 +- src/viz/sorted-array/index.ts | 1 + src/viz/splay/index.ts | 2 +- src/viz/stack-array/index.ts | 1 + src/viz/stack-list/index.ts | 1 + src/viz/steiner-tree/index.ts | 1 + src/viz/suffix-array/index.ts | 1 + src/viz/suffix-tree/index.ts | 1 + src/viz/topo-dfs/index.ts | 1 + src/viz/topo-indegree/index.ts | 1 + src/viz/trie/index.ts | 1 + src/viz/tsp/index.ts | 1 + src/viz/two-sat/index.ts | 1 + src/viz/vertex-cover/index.ts | 1 + src/viz/weighted-matching/index.ts | 1 + tests/e2e/catalog.spec.ts | 11 +++++- vite.config.ts | 17 +++++--- 114 files changed, 235 insertions(+), 51 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 5f03795..a13da8d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -177,3 +177,5 @@ 2026-11-07 [decision] Catalog thumbnails: Yusuf chose animated SVG drawn from data over GIF (all themes, about 1.5 KB a page), left of each title, play on hover or focus, text stripped; pilot family Trees 2026-11-07 [code] Trees thumbnails: VizDef.preview names an example; src/render/preview.ts strips it to shapes in one frame; prerender writes dist/previews.json and the dev server makes it on request + +2026-11-07 [code] Thumbnails for all 108 pages; posters show the fullest frame; input and output boxes always hidden; no arrowheads in thumbnails; graph and geometry pages hide side tables and matrix brackets diff --git a/CLAUDE.md b/CLAUDE.md index 4e3f70b..10e3997 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,11 @@ everything on every push; Netlify builds and deploys `main` (`netlify.toml`). - Compute every answer by running the model; never write one by hand. - Answers are a choice, a number, or a sequence. - Add `watch` operations so "Show me" can play the answer. -13. **Tests.** +13. **Thumbnail.** `preview: { example, level?, hide? }` picks the curated example the catalog + plays on hover, stripped of text. Pick one that shows the idea and ends full, not empty; use a + finer `level` when it has fewer than about 12 steps; `hide` side panels (tables, queues, call + stacks) by shape id prefix so the main picture fills the 72x44 box. Look at it on the catalog. +14. **Tests.** - `src/viz/conformance.test.ts` checks every page automatically: narration (no `{token}` or `undefined` leftovers), line ids, plain pseudocode, marks, unique shape ids, Big-O rows, presets, examples, links, and no placeholder text. @@ -123,7 +127,7 @@ everything on every push; Netlify builds and deploys `main` (`netlify.toml`). `#ex=&embed=1&lesson=
`. Old `/#/?...` links redirect. - Catalog thumbnails: `preview` in the `VizDef` names a curated example (and a step level, and shape ids to hide); `src/render/preview.ts` strips it to bare shapes and `scripts/prerender.mjs` - writes `dist/previews.json`. Pilot: the Trees family (`src/render/preview.test.ts`). + writes `dist/previews.json`. - The app loads each visualization on demand (`src/viz/registry.ts`); tests and prerender use `src/viz/all.ts`. - No analytics or tracking. No external fonts or CDNs. diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index f56747a..bcc9f0b 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -1,7 +1,7 @@ // After `vite build`: write dist//index.html for every visualization, so each page has its // own title, description and link preview, plus a sitemap, robots.txt and the catalog thumbnails -// (previews.json). The app itself is the same on every page; it reads the page's path to decide -// what to show. +// (previews.json, previews/.json). The app itself is the same on every page; it reads the +// page's path to decide what to show. import fs from 'node:fs'; import path from 'node:path'; import { createServer } from 'vite'; @@ -46,9 +46,16 @@ try { } // Catalog thumbnails: the steps of one example per page, stripped to bare shapes. - const { makePreviews } = await server.ssrLoadModule('/src/render/preview.ts'); + // previews.json holds the still pictures; previews/.json the frames, fetched on first play. + const { makePreviews, posterOf } = await server.ssrLoadModule('/src/render/preview.ts'); const previews = makePreviews(vizzes); - fs.writeFileSync(path.join(dist, 'previews.json'), JSON.stringify(previews)); + fs.mkdirSync(path.join(dist, 'previews'), { recursive: true }); + const posters = {}; + for (const [id, p] of Object.entries(previews)) { + posters[id] = posterOf(p); + fs.writeFileSync(path.join(dist, 'previews', `${id}.json`), JSON.stringify(p)); + } + fs.writeFileSync(path.join(dist, 'previews.json'), JSON.stringify(posters)); const urls = [`${SITE_URL}/`, ...vizzes.map((d) => `${SITE_URL}/${d.id}/`)]; fs.writeFileSync( diff --git a/src/engine/types.ts b/src/engine/types.ts index 447be9a..19452b6 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -229,7 +229,7 @@ export interface PreviewSpec { example?: string; /** Finest step level to include; default 1 (milestones only). */ level?: Level; - /** Shape id prefixes to leave out, such as the box that holds an operation's argument. */ + /** Shape id prefixes to leave out, besides the input and output boxes every preview drops. */ hide?: string[]; } diff --git a/src/render/preview.test.ts b/src/render/preview.test.ts index 61a3a7b..dcc3101 100644 --- a/src/render/preview.test.ts +++ b/src/render/preview.test.ts @@ -1,24 +1,19 @@ import { describe, expect, it } from 'vitest'; import { vizzes } from '../viz/all'; -import { families } from '../viz/catalog'; import { makePreview, MAX_FRAMES } from './preview'; -// The pilot family: every page in it has a thumbnail. Later families join this list. -const WITH_PREVIEWS = ['Trees']; - describe('catalog previews', () => { - it('every page in the families with thumbnails asks for one', () => { - const ids = families.filter((f) => WITH_PREVIEWS.includes(f.name)).flatMap((f) => f.items); - for (const { id } of ids) { - expect(vizzes.find((d) => d.id === id)?.preview, id).toBeDefined(); - } + it('every page asks for a thumbnail', () => { + expect(vizzes.filter((d) => !d.preview).map((d) => d.id)).toEqual([]); }); - for (const def of vizzes.filter((d) => d.preview)) { + for (const def of vizzes) { it(`${def.id}: bare shapes that move, inside one fixed frame`, () => { const p = makePreview(def)!; expect(p.frames.length).toBeGreaterThanOrEqual(4); expect(p.frames.length).toBeLessThanOrEqual(MAX_FRAMES); + expect(p.poster).toBeGreaterThanOrEqual(0); + expect(p.poster).toBeLessThan(p.frames.length); const b = p.bounds; const inside = (x: number, y: number) => x >= b.x - 0.5 && x <= b.x + b.w + 0.5 && y >= b.y - 0.5 && y <= b.y + b.h + 0.5; diff --git a/src/render/preview.ts b/src/render/preview.ts index b06b473..566c1a6 100644 --- a/src/render/preview.ts +++ b/src/render/preview.ts @@ -11,11 +11,19 @@ import { shapesBounds, type Bounds, type Shape } from './scene'; export interface Preview { bounds: Bounds; frames: Shape[][]; + /** The frame shown at rest: the fullest one (the latest of equals), not an emptied end state. */ + poster: number; } /** The most frames a preview keeps; longer examples are sampled evenly, keeping the last. */ export const MAX_FRAMES = 24; +/** + * Shape ids every preview leaves out: the boxes that hold an operation's input and output. They + * carry text, so stripped they are empty frames. + */ +export const DEFAULT_HIDE = ['arg-box', 'in-box', 'out-box', 'key-box', 'result-box']; + const round = (v: number) => Math.round(v * 10) / 10; /** Drop text, round coordinates (the JSON is served to every visitor), keep identity and role. */ @@ -58,7 +66,7 @@ export function makePreview(def: AnyVizDef): Preview | null { if (step.level <= level) steps.push(step); } const frames = sample(steps, MAX_FRAMES) - .map((step) => strip(def.view(step, settings).shapes, spec.hide ?? [])) + .map((step) => strip(def.view(step, settings).shapes, [...DEFAULT_HIDE, ...(spec.hide ?? [])])) // Keep the drawing on screen from the first frame: an empty start shows nothing. .filter((shapes, i, all) => shapes.length > 0 || i === all.length - 1); const boxes = frames.map((f) => shapesBounds(f, 0)).filter((b): b is Bounds => b !== null); @@ -74,9 +82,27 @@ export function makePreview(def: AnyVizDef): Preview | null { w: round(x2 - x1 + 2 * pad), h: round(y2 - y1 + 2 * pad), }; - return { bounds, frames }; + let poster = 0; + frames.forEach((f, i) => { + if (f.length >= frames[poster]!.length) poster = i; + }); + return { bounds, frames, poster }; +} + +/** + * What the catalog loads up front: each page's still picture. The frames come only when a + * thumbnail first plays (one file per page), so phones, which have no hover, never fetch them. + */ +export interface PreviewPoster { + bounds: Bounds; + shapes: Shape[]; } +export const posterOf = (p: Preview): PreviewPoster => ({ + bounds: p.bounds, + shapes: p.frames[p.poster]!, +}); + /** Previews of every page that asks for one, keyed by page id. */ export function makePreviews(defs: AnyVizDef[]): Record { const out: Record = {}; @@ -86,3 +112,10 @@ export function makePreviews(defs: AnyVizDef[]): Record { } return out; } + +/** The still pictures of every page, keyed by page id: /previews.json. */ +export function makePosters(defs: AnyVizDef[]): Record { + const out: Record = {}; + for (const [id, p] of Object.entries(makePreviews(defs))) out[id] = posterOf(p); + return out; +} diff --git a/src/ui/Catalog.svelte b/src/ui/Catalog.svelte index 233b3fe..ef8da62 100644 --- a/src/ui/Catalog.svelte +++ b/src/ui/Catalog.svelte @@ -1,20 +1,20 @@