diff --git a/CHANGES.md b/CHANGES.md index ae4f092..a13da8d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -173,3 +173,9 @@ 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 + +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 679a197..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. @@ -121,6 +125,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`. - 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..bcc9f0b 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, 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,18 @@ try { fs.writeFileSync(path.join(dist, def.id, 'index.html'), html); } + // Catalog thumbnails: the steps of one example per page, stripped to bare shapes. + // 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.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( path.join(dist, 'sitemap.xml'), @@ -55,7 +68,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..19452b6 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, besides the input and output boxes every preview drops. */ + 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..dcc3101 --- /dev/null +++ b/src/render/preview.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { vizzes } from '../viz/all'; +import { makePreview, MAX_FRAMES } from './preview'; + +describe('catalog previews', () => { + it('every page asks for a thumbnail', () => { + expect(vizzes.filter((d) => !d.preview).map((d) => d.id)).toEqual([]); + }); + + 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; + 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..566c1a6 --- /dev/null +++ b/src/render/preview.ts @@ -0,0 +1,121 @@ +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 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. */ +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, [...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); + 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), + }; + 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 = {}; + for (const def of defs) { + const p = makePreview(def); + if (p) out[def.id] = p; + } + 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 2a61816..ef8da62 100644 --- a/src/ui/Catalog.svelte +++ b/src/ui/Catalog.svelte @@ -1,12 +1,27 @@ + + + + diff --git a/src/viz/a-star/index.ts b/src/viz/a-star/index.ts index 5aaabdc..31c7ae4 100644 --- a/src/viz/a-star/index.ts +++ b/src/viz/a-star/index.ts @@ -162,6 +162,7 @@ export const aStar: VizDef = { noPath, ] as unknown as SavedExample[], practice: aStarPractice(() => aStar), + preview: { example: 'manhattan', level: 2, hide: ['pq-'] }, }; /** diff --git a/src/viz/avl/index.ts b/src/viz/avl/index.ts index 5456701..ed513ec 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' }, }; /** diff --git a/src/viz/b-plus-tree/index.ts b/src/viz/b-plus-tree/index.ts index 06af996..f8d832e 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 }, }; /** diff --git a/src/viz/b-tree/index.ts b/src/viz/b-tree/index.ts index c078871..825cda2 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' }, }; /** diff --git a/src/viz/backtracking/index.ts b/src/viz/backtracking/index.ts index 79f21db..26f9f0e 100644 --- a/src/viz/backtracking/index.ts +++ b/src/viz/backtracking/index.ts @@ -183,6 +183,7 @@ export const backtracking: VizDef = { targetSum, outOfReach, ] as unknown as SavedExample[], + preview: { example: 'subsets-three', level: 2 }, }; /** diff --git a/src/viz/bellman-ford/index.ts b/src/viz/bellman-ford/index.ts index 62fa2ea..43bf603 100644 --- a/src/viz/bellman-ford/index.ts +++ b/src/viz/bellman-ford/index.ts @@ -174,6 +174,7 @@ export const bellmanFord: VizDef = { undirectedNegative, ] as unknown as SavedExample[], practice: bellmanFordPractice(() => bellmanFord), + preview: { example: 'passes', level: 2, hide: ['vn-', 'ord-', 'ps-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/bfs/index.ts b/src/viz/bfs/index.ts index 9ed32e6..2d53554 100644 --- a/src/viz/bfs/index.ts +++ b/src/viz/bfs/index.ts @@ -151,6 +151,7 @@ export const bfs: VizDef = { dense, ] as unknown as SavedExample[], practice: bfsPractice(() => bfs), + preview: { example: 'layers', level: 2, hide: ['vn-', 'q-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/big-o-lab/index.ts b/src/viz/big-o-lab/index.ts index cc30ceb..e560ee8 100644 --- a/src/viz/big-o-lab/index.ts +++ b/src/viz/big-o-lab/index.ts @@ -201,6 +201,7 @@ export const bigOLab: VizDef = { factorial, logScale, ] as unknown as SavedExample[], + preview: { example: 'small-n-crossover', level: 2 }, }; /** Counts the drivers can reach by looping without taking long. */ diff --git a/src/viz/binary-heap/index.ts b/src/viz/binary-heap/index.ts index 2a7a218..eae0793 100644 --- a/src/viz/binary-heap/index.ts +++ b/src/viz/binary-heap/index.ts @@ -245,6 +245,7 @@ export const binaryHeap: VizDef = { deleteSiftsUp, minHeapZero, ] as unknown as SavedExample[], + preview: { example: 'build-by-inserts', level: 2 }, }; /** Does the model refuse this call before it looks at the heap (a key or index it cannot read)? */ diff --git a/src/viz/binary-search/index.ts b/src/viz/binary-search/index.ts index 32e24db..08b258c 100644 --- a/src/viz/binary-search/index.ts +++ b/src/viz/binary-search/index.ts @@ -217,6 +217,7 @@ export const binarySearch: VizDef = { recursive, ] as unknown as SavedExample[], practice: binaryPractice(() => binarySearch), + preview: { example: 'found', level: 3 }, }; export default binarySearch; 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/binomial-queue/index.ts b/src/viz/binomial-queue/index.ts index 0db066c..faf57d4 100644 --- a/src/viz/binomial-queue/index.ts +++ b/src/viz/binomial-queue/index.ts @@ -186,6 +186,7 @@ export const binomialQueue: VizDef = { decreaseBubble, pointersView, ] as unknown as SavedExample[], + preview: { example: 'insert-carries', level: 2 }, }; /** diff --git a/src/viz/bipartite-matching/index.ts b/src/viz/bipartite-matching/index.ts index 7c9e6d6..542bf7e 100644 --- a/src/viz/bipartite-matching/index.ts +++ b/src/viz/bipartite-matching/index.ts @@ -199,6 +199,7 @@ export const bipartiteMatching: VizDef = { twoColours, ] as unknown as SavedExample[], practice: matchingPractice(() => bipartiteMatching), + preview: { example: 'rematch', level: 2 }, }; const lines = (s: BmState) => { diff --git a/src/viz/bipartite/index.ts b/src/viz/bipartite/index.ts index c7fc601..3fdeeba 100644 --- a/src/viz/bipartite/index.ts +++ b/src/viz/bipartite/index.ts @@ -163,6 +163,7 @@ export const bipartite: VizDef = { dfsCheck, ] as unknown as SavedExample[], practice: bipartitePractice(() => bipartite), + preview: { example: 'even-cycle', level: 2, hide: ['vn-', 'q-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/bitmask/index.ts b/src/viz/bitmask/index.ts index 79d876c..52cfe8a 100644 --- a/src/viz/bitmask/index.ts +++ b/src/viz/bitmask/index.ts @@ -191,6 +191,7 @@ export const bitmask: VizDef = { submasks, emptySet, ] as unknown as SavedExample[], + preview: { example: 'set-clear-toggle', level: 2 }, }; /** diff --git a/src/viz/boyer-moore/index.ts b/src/viz/boyer-moore/index.ts index cf3332c..0ba42d3 100644 --- a/src/viz/boyer-moore/index.ts +++ b/src/viz/boyer-moore/index.ts @@ -178,6 +178,7 @@ export const boyerMoore: VizDef, BmSettings, MatchExtra> = { everyShift, ] as unknown as SavedExample[], practice: bmPractice(() => boyerMoore), + preview: { example: 'both-rules', level: 2 }, }; export default boyerMoore; diff --git a/src/viz/bridges/index.ts b/src/viz/bridges/index.ts index 09176f6..2ec3bb3 100644 --- a/src/viz/bridges/index.ts +++ b/src/viz/bridges/index.ts @@ -154,6 +154,7 @@ export const bridges: VizDef = { twoPieces, ] as unknown as SavedExample[], practice: bridgesPractice(() => bridges), + preview: { example: 'two-triangles', level: 2, hide: ['vn-', 'cs-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/brute-force-search/index.ts b/src/viz/brute-force-search/index.ts index a7d1184..7b96f20 100644 --- a/src/viz/brute-force-search/index.ts +++ b/src/viz/brute-force-search/index.ts @@ -149,6 +149,7 @@ export const bruteForce: VizDef, BruteSettings, MatchExtra> = { tooLong, ] as unknown as SavedExample[], practice: brutePractice(() => bruteForce), + preview: { example: 'two-matches', level: 2 }, }; export default bruteForce; diff --git a/src/viz/bst/index.ts b/src/viz/bst/index.ts index 9c9e0a1..69bf14c 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 }, }; /** diff --git a/src/viz/bubble-sort/index.ts b/src/viz/bubble-sort/index.ts index 1bcfe8d..a94b12c 100644 --- a/src/viz/bubble-sort/index.ts +++ b/src/viz/bubble-sort/index.ts @@ -130,6 +130,7 @@ export const bubbleSort: VizDef = { duplicates, ] as unknown as SavedExample[], practice: bubblePractice(() => bubbleSort), + preview: { example: 'reverse', level: 2 }, }; export default bubbleSort; diff --git a/src/viz/bucket-sort/index.ts b/src/viz/bucket-sort/index.ts index 43233ca..caef02d 100644 --- a/src/viz/bucket-sort/index.ts +++ b/src/viz/bucket-sort/index.ts @@ -180,6 +180,7 @@ export const bucketSort: VizDef = { // JSON imports lose the tuple types; the conformance test validates every example. examples: [spreadOut, oneBucket, even, integers, outlier] as unknown as SavedExample[], practice: bucketPractice(() => bucketSort), + preview: { example: 'spread-out', level: 2 }, }; /** diff --git a/src/viz/circular-linked-list/index.ts b/src/viz/circular-linked-list/index.ts index f94ac21..d713fd3 100644 --- a/src/viz/circular-linked-list/index.ts +++ b/src/viz/circular-linked-list/index.ts @@ -129,6 +129,7 @@ export const circularLinkedList: VizDef = { practice: circularPractice(() => circularLinkedList), // JSON imports lose the tuple types; the conformance test validates every example. examples: [backIsFront, rotate, traverse, oneNode, search, queue] as unknown as SavedExample[], + preview: { example: 'rotate', level: 2 }, }; /** The driver's line for one operation, read off the model's last step. */ diff --git a/src/viz/cocktail-sort/index.ts b/src/viz/cocktail-sort/index.ts index e940acb..359886d 100644 --- a/src/viz/cocktail-sort/index.ts +++ b/src/viz/cocktail-sort/index.ts @@ -123,6 +123,7 @@ export const cocktailSort: VizDef = { duplicates, ] as unknown as SavedExample[], practice: cocktailPractice(() => cocktailSort), + preview: { example: 'shaker', level: 2 }, }; export default cocktailSort; diff --git a/src/viz/compressed-trie/index.ts b/src/viz/compressed-trie/index.ts index 8ccce99..8987241 100644 --- a/src/viz/compressed-trie/index.ts +++ b/src/viz/compressed-trie/index.ts @@ -173,6 +173,7 @@ export const compressedTrie: VizDef = { deletePrefixMerges, longWords, ] as unknown as SavedExample[], + preview: { example: 'long-words', level: 2 }, }; /** diff --git a/src/viz/connected-components/index.ts b/src/viz/connected-components/index.ts index c08395b..a4a0d00 100644 --- a/src/viz/connected-components/index.ts +++ b/src/viz/connected-components/index.ts @@ -165,6 +165,7 @@ export const connectedComponents: VizDef = { directedWeak, ] as unknown as SavedExample[], practice: ccPractice(() => connectedComponents), + preview: { example: 'three-pieces', level: 2, hide: ['vn-', 'q-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/convex-hull/index.ts b/src/viz/convex-hull/index.ts index 70a085b..9731222 100644 --- a/src/viz/convex-hull/index.ts +++ b/src/viz/convex-hull/index.ts @@ -254,6 +254,7 @@ export const convexHull: VizDef = { fewCorners, monotoneChain, ] as unknown as SavedExample[], + preview: { example: 'small-set', level: 2, hide: ['stk-'] }, }; /** diff --git a/src/viz/coordinates-2d/index.ts b/src/viz/coordinates-2d/index.ts index 2865132..31ff239 100644 --- a/src/viz/coordinates-2d/index.ts +++ b/src/viz/coordinates-2d/index.ts @@ -204,6 +204,7 @@ export const coordinates2d: VizDef = { homogeneous, movingTheFrame, ] as unknown as SavedExample[], + preview: { example: 'moving-the-frame', level: 3, hide: ['m-', 'mi-'] }, }; /** diff --git a/src/viz/coordinates-3d/index.ts b/src/viz/coordinates-3d/index.ts index 92989dc..ef45b0a 100644 --- a/src/viz/coordinates-3d/index.ts +++ b/src/viz/coordinates-3d/index.ts @@ -232,6 +232,7 @@ export const coordinates3d: VizDef = { leftHanded, obliqueView, ] as unknown as SavedExample[], + preview: { example: 'moving-the-frame', level: 3, hide: ['m-', 'mi-'] }, }; /** diff --git a/src/viz/counting-sort/index.ts b/src/viz/counting-sort/index.ts index fb3d5fa..1eb963f 100644 --- a/src/viz/counting-sort/index.ts +++ b/src/viz/counting-sort/index.ts @@ -112,6 +112,7 @@ export const countingSort: VizDef = { // JSON imports lose the tuple types; the conformance test validates every example. examples: [countAndPlace, stable, bigK, allEqual, reverse] as unknown as SavedExample[], practice: countingPractice(() => countingSort), + preview: { example: 'count-and-place', level: 2 }, }; export default countingSort; diff --git a/src/viz/dag-shortest-paths/index.ts b/src/viz/dag-shortest-paths/index.ts index 6e716ca..c2e8f64 100644 --- a/src/viz/dag-shortest-paths/index.ts +++ b/src/viz/dag-shortest-paths/index.ts @@ -167,6 +167,7 @@ export const dagShortestPaths: VizDef = { undirected, ] as unknown as SavedExample[], practice: dagPractice(() => dagShortestPaths), + preview: { example: 'sort-then-relax', level: 2, hide: ['vn-', 'L-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/deque-array/index.ts b/src/viz/deque-array/index.ts index 56641d7..00b8fe4 100644 --- a/src/viz/deque-array/index.ts +++ b/src/viz/deque-array/index.ts @@ -185,6 +185,7 @@ export const dequeArray: VizDef = { fullCount, underflow, ] as unknown as SavedExample[], + preview: { example: 'both-ends', level: 2 }, }; /** diff --git a/src/viz/deque-list/index.ts b/src/viz/deque-list/index.ts index 96d765c..838545e 100644 --- a/src/viz/deque-list/index.ts +++ b/src/viz/deque-list/index.ts @@ -142,6 +142,7 @@ export const dequeList: VizDef dequeList), // JSON imports lose the tuple types; the conformance test validates every example. examples: [bothEnds, firstAdd, lastRemove, removeLast, underflow] as unknown as SavedExample[], + preview: { example: 'both-ends', level: 2 }, }; /** diff --git a/src/viz/dfs/index.ts b/src/viz/dfs/index.ts index 206f0cb..b6c28e6 100644 --- a/src/viz/dfs/index.ts +++ b/src/viz/dfs/index.ts @@ -145,6 +145,7 @@ export const dfs: VizDef = { middleStart, ] as unknown as SavedExample[], practice: dfsPractice(() => dfs), + preview: { example: 'every-kind', level: 2, hide: ['vn-', 'cs-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/dijkstra/index.ts b/src/viz/dijkstra/index.ts index f139b54..b8d71a2 100644 --- a/src/viz/dijkstra/index.ts +++ b/src/viz/dijkstra/index.ts @@ -184,6 +184,7 @@ export const dijkstra: VizDef = fewestEdges, ] as unknown as SavedExample[], practice: dijkstraPractice(() => dijkstra), + preview: { example: 'closest-first', level: 2, hide: ['vn-', 'pq-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/disjoint-sets/index.ts b/src/viz/disjoint-sets/index.ts index ad502b7..07de022 100644 --- a/src/viz/disjoint-sets/index.ts +++ b/src/viz/disjoint-sets/index.ts @@ -226,6 +226,7 @@ export const disjointSets: VizDef = { sameSet, ] as unknown as SavedExample[], practice: dsPractice(() => disjointSets), + preview: { example: 'compression', level: 2 }, }; /** diff --git a/src/viz/doubly-linked-list/index.ts b/src/viz/doubly-linked-list/index.ts index 5f6b088..e531c1f 100644 --- a/src/viz/doubly-linked-list/index.ts +++ b/src/viz/doubly-linked-list/index.ts @@ -187,6 +187,7 @@ export const doublyLinkedList: VizDef = { nearerEnd, reverse, ] as unknown as SavedExample[], + preview: { example: 'reverse', level: 2 }, }; export default doublyLinkedList; diff --git a/src/viz/dp-change/index.ts b/src/viz/dp-change/index.ts index 51b0698..fc11ecb 100644 --- a/src/viz/dp-change/index.ts +++ b/src/viz/dp-change/index.ts @@ -166,6 +166,7 @@ export const dpChange: VizDef = { mostWork, ] as unknown as SavedExample[], practice: changePractice(() => dpChange), + preview: { example: 'most-work' }, }; /** diff --git a/src/viz/dp-edit-distance/index.ts b/src/viz/dp-edit-distance/index.ts index af97012..592711b 100644 --- a/src/viz/dp-edit-distance/index.ts +++ b/src/viz/dp-edit-distance/index.ts @@ -172,6 +172,7 @@ export const dpEditDistance: VizDef = { insertsOnly, ] as unknown as SavedExample[], practice: editPractice(() => dpEditDistance), + preview: { example: 'kitten', level: 2 }, }; /** diff --git a/src/viz/dp-fibonacci/index.ts b/src/viz/dp-fibonacci/index.ts index d7a5d64..f9c71e5 100644 --- a/src/viz/dp-fibonacci/index.ts +++ b/src/viz/dp-fibonacci/index.ts @@ -142,6 +142,7 @@ export const dpFibonacci: VizDef = { // JSON imports lose the tuple types; the conformance test validates every example. examples: [bottomUp, memoOrder, memoHits, baseCases, largest] as unknown as SavedExample[], practice: fibPractice(() => dpFibonacci), + preview: { example: 'bottom-up' }, }; /** diff --git a/src/viz/dp-knapsack/index.ts b/src/viz/dp-knapsack/index.ts index b4bf152..1c8a5e7 100644 --- a/src/viz/dp-knapsack/index.ts +++ b/src/viz/dp-knapsack/index.ts @@ -193,6 +193,7 @@ export const dpKnapsack: VizDef = { largest, ] as unknown as SavedExample[], practice: knapPractice(() => dpKnapsack), + preview: { example: 'four-items', level: 2 }, }; /** diff --git a/src/viz/dp-lcs/index.ts b/src/viz/dp-lcs/index.ts index 4328e3f..9431425 100644 --- a/src/viz/dp-lcs/index.ts +++ b/src/viz/dp-lcs/index.ts @@ -195,6 +195,7 @@ export const dpLcs: VizDef = { dna, ] as unknown as SavedExample[], practice: lcsPractice(() => dpLcs), + preview: { example: 'length-four', level: 2 }, }; /** diff --git a/src/viz/dynamic-array/index.ts b/src/viz/dynamic-array/index.ts index c827bbb..166a9a4 100644 --- a/src/viz/dynamic-array/index.ts +++ b/src/viz/dynamic-array/index.ts @@ -209,6 +209,7 @@ export const dynamicArray: VizDef = { thrash, badIndex, ] as unknown as SavedExample[], + preview: { example: 'doubling', level: 2 }, }; function result(last: Step): string { diff --git a/src/viz/factorial/index.ts b/src/viz/factorial/index.ts index bf1523c..cb49c64 100644 --- a/src/viz/factorial/index.ts +++ b/src/viz/factorial/index.ts @@ -158,6 +158,7 @@ export const factorial: VizDef = { negative, tooDeep, ] as unknown as SavedExample[], + preview: { example: 'down-and-back', level: 2 }, }; /** diff --git a/src/viz/fenwick-tree/index.ts b/src/viz/fenwick-tree/index.ts index a96cece..983e80a 100644 --- a/src/viz/fenwick-tree/index.ts +++ b/src/viz/fenwick-tree/index.ts @@ -158,6 +158,7 @@ export const fenwickTree: VizDef = { buildLinear, buildByAdds, ] as unknown as SavedExample[], + preview: { example: 'add-then-query', level: 2 }, }; /** diff --git a/src/viz/fibonacci-calls/index.ts b/src/viz/fibonacci-calls/index.ts index a31e76c..d9c1c03 100644 --- a/src/viz/fibonacci-calls/index.ts +++ b/src/viz/fibonacci-calls/index.ts @@ -144,6 +144,7 @@ export const fibonacciCalls: VizDef = { memoFive, memoEight, ] as unknown as SavedExample[], + preview: { example: 'fifteen-calls', level: 2 }, }; /** diff --git a/src/viz/fibonacci-heap/index.ts b/src/viz/fibonacci-heap/index.ts index 2acdcb3..c22fdfd 100644 --- a/src/viz/fibonacci-heap/index.ts +++ b/src/viz/fibonacci-heap/index.ts @@ -179,6 +179,7 @@ export const fibonacciHeap: VizDef = { deleteNode, pointersView, ] as unknown as SavedExample[], + preview: { example: 'cascading-cut' }, }; /** diff --git a/src/viz/floyd-cycle/index.ts b/src/viz/floyd-cycle/index.ts index 8b0aedf..911bd6c 100644 --- a/src/viz/floyd-cycle/index.ts +++ b/src/viz/floyd-cycle/index.ts @@ -126,6 +126,7 @@ export const floydCycle: VizDef = { practice: floydPractice(() => floydCycle), // JSON imports lose the tuple types; the conformance test validates every example. examples: [rho, noCycle, fullCycle, selfLoop, tiny, longTail] as unknown as SavedExample[], + preview: { example: 'rho', level: 2 }, }; /** diff --git a/src/viz/floyd-warshall/index.ts b/src/viz/floyd-warshall/index.ts index 696f1f4..addccb5 100644 --- a/src/viz/floyd-warshall/index.ts +++ b/src/viz/floyd-warshall/index.ts @@ -162,6 +162,7 @@ export const floydWarshall: VizDef = { nextHop, ] as unknown as SavedExample[], practice: floydPractice(() => floydWarshall), + preview: { example: 'every-round', level: 2, hide: ['P:'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/general-matching/index.ts b/src/viz/general-matching/index.ts index aea0e18..584f3de 100644 --- a/src/viz/general-matching/index.ts +++ b/src/viz/general-matching/index.ts @@ -151,6 +151,7 @@ export const generalMatching: VizDef = { // JSON imports lose the tuple types; the conformance test validates every example. examples: [twoBlossoms, throughBlossom, oddCycle, bipartite, star] as unknown as SavedExample[], practice: blossomPractice(() => generalMatching), + preview: { example: 'two-blossoms', level: 2, hide: ['vn-', 'q-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/graph-representations/index.ts b/src/viz/graph-representations/index.ts index 3985053..6654154 100644 --- a/src/viz/graph-representations/index.ts +++ b/src/viz/graph-representations/index.ts @@ -195,6 +195,7 @@ export const graphRepresentations: VizDef = { removeVertex, ] as unknown as SavedExample[], practice: repPractice(() => graphRepresentations), + preview: { example: 'dense', level: 3 }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/grid-bfs/index.ts b/src/viz/grid-bfs/index.ts index 6ca0cb9..b118c40 100644 --- a/src/viz/grid-bfs/index.ts +++ b/src/viz/grid-bfs/index.ts @@ -137,6 +137,7 @@ export const gridBfs: VizDef = { ties, ] as unknown as SavedExample[], practice: gridBfsPractice(() => gridBfs), + preview: { example: 'around-the-wall', level: 2, hide: ['q-'] }, }; /** diff --git a/src/viz/hash-buckets/index.ts b/src/viz/hash-buckets/index.ts index 1797f7f..72ca2b7 100644 --- a/src/viz/hash-buckets/index.ts +++ b/src/viz/hash-buckets/index.ts @@ -154,6 +154,7 @@ export const hashBuckets: VizDef = { evenSpread, oneBucket, ] as unknown as SavedExample[], + preview: { example: 'overflow-area', level: 2 }, }; /** diff --git a/src/viz/hash-chaining/index.ts b/src/viz/hash-chaining/index.ts index 4beee76..0b7df4d 100644 --- a/src/viz/hash-chaining/index.ts +++ b/src/viz/hash-chaining/index.ts @@ -178,6 +178,7 @@ export const hashChaining: VizDef = { keyValue, tailInsert, ] as unknown as SavedExample[], + preview: { example: 'collisions', level: 2 }, }; /** diff --git a/src/viz/hash-open/index.ts b/src/viz/hash-open/index.ts index ee30ada..dc1161b 100644 --- a/src/viz/hash-open/index.ts +++ b/src/viz/hash-open/index.ts @@ -170,6 +170,7 @@ export const hashOpen: VizDef = { fullTable, triangular, ] as unknown as SavedExample[], + preview: { example: 'primary-clustering', level: 2 }, }; /** diff --git a/src/viz/hash-rehash/index.ts b/src/viz/hash-rehash/index.ts index 01ed010..83ee1d3 100644 --- a/src/viz/hash-rehash/index.ts +++ b/src/viz/hash-rehash/index.ts @@ -154,6 +154,7 @@ export const hashRehash: VizDef = { }, // JSON imports lose the tuple types; the conformance test validates every example. examples: [growPrime, amortized, doubling, thresholdOne, halfFull] as unknown as SavedExample[], + preview: { example: 'grow-prime', level: 2 }, }; /** diff --git a/src/viz/heap-sort/index.ts b/src/viz/heap-sort/index.ts index 0ed9220..a452709 100644 --- a/src/viz/heap-sort/index.ts +++ b/src/viz/heap-sort/index.ts @@ -161,6 +161,7 @@ export const heapSort: VizDef = { allEqual, ] as unknown as SavedExample[], practice: heapSortPractice(() => heapSort), + preview: { example: 'build-bottom-up' }, }; export default heapSort; diff --git a/src/viz/huffman/index.ts b/src/viz/huffman/index.ts index 137881d..3da641b 100644 --- a/src/viz/huffman/index.ts +++ b/src/viz/huffman/index.ts @@ -211,6 +211,7 @@ export const huffman: VizDef = { sentence, decodeWalk, ] as unknown as SavedExample[], + preview: { example: 'abracadabra', level: 2 }, }; /** A symbol in the driver protocol: a space is written as _. */ diff --git a/src/viz/insertion-sort/index.ts b/src/viz/insertion-sort/index.ts index 40f33c4..46fff7c 100644 --- a/src/viz/insertion-sort/index.ts +++ b/src/viz/insertion-sort/index.ts @@ -119,6 +119,7 @@ export const insertionSort: VizDef = duplicates, ] as unknown as SavedExample[], practice: insertionPractice(() => insertionSort), + preview: { example: 'shift-right', level: 2 }, }; export default insertionSort; diff --git a/src/viz/kmp/index.ts b/src/viz/kmp/index.ts index 5ed422e..8d95e3a 100644 --- a/src/viz/kmp/index.ts +++ b/src/viz/kmp/index.ts @@ -170,6 +170,7 @@ export const kmp: VizDef, KmpSettings, MatchExtra> = { buildTable, ] as unknown as SavedExample[], practice: kmpPractice(() => kmp), + preview: { example: 'keeps-three', level: 2 }, }; export default kmp; diff --git a/src/viz/kruskal/index.ts b/src/viz/kruskal/index.ts index b5bb3b7..adf1b51 100644 --- a/src/viz/kruskal/index.ts +++ b/src/viz/kruskal/index.ts @@ -143,6 +143,7 @@ export const kruskal: VizDef = { dense, ] as unknown as SavedExample[], practice: kruskalPractice(() => kruskal), + preview: { example: 'lightest-first', level: 2, hide: ['el-', 'uf-', 'ufp-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/leftist-heap/index.ts b/src/viz/leftist-heap/index.ts index 5c57b8a..424af54 100644 --- a/src/viz/leftist-heap/index.ts +++ b/src/viz/leftist-heap/index.ts @@ -134,6 +134,7 @@ export const leftistHeap: VizDef = { descendingChain, rankConvention, ] as unknown as SavedExample[], + preview: { example: 'merge-two-heaps', level: 2 }, }; export default leftistHeap; diff --git a/src/viz/linear-search/index.ts b/src/viz/linear-search/index.ts index 9d17caa..b1eb600 100644 --- a/src/viz/linear-search/index.ts +++ b/src/viz/linear-search/index.ts @@ -139,6 +139,7 @@ export const linearSearch: VizDef = { // JSON imports lose the tuple types; the conformance test validates every example. examples: [found, notFound, firstSlot, duplicates, sentinel] as unknown as SavedExample[], practice: linearPractice(() => linearSearch), + preview: { example: 'found', level: 3 }, }; export default linearSearch; diff --git a/src/viz/linked-list/index.ts b/src/viz/linked-list/index.ts index f9a3f11..d238928 100644 --- a/src/viz/linked-list/index.ts +++ b/src/viz/linked-list/index.ts @@ -207,6 +207,7 @@ export const linkedList: VizDef = { middle, boundaries, ] as unknown as SavedExample[], + preview: { example: 'reverse', level: 2 }, }; export default linkedList; diff --git a/src/viz/lru-cache/index.ts b/src/viz/lru-cache/index.ts index e091fe3..7aa972c 100644 --- a/src/viz/lru-cache/index.ts +++ b/src/viz/lru-cache/index.ts @@ -176,6 +176,7 @@ export const lruCache: VizDef = { loopMisses, oneEntry, ] as unknown as SavedExample[], + preview: { example: 'evict-lru', level: 2 }, }; /** diff --git a/src/viz/master-theorem/index.ts b/src/viz/master-theorem/index.ts index 0c6b824..04cef6b 100644 --- a/src/viz/master-theorem/index.ts +++ b/src/viz/master-theorem/index.ts @@ -188,6 +188,7 @@ export const masterTheorem: VizDef = { theGap, simpleForm, ] as unknown as SavedExample[], + preview: { example: 'simple-form', level: 2 }, }; /** diff --git a/src/viz/max-flow/index.ts b/src/viz/max-flow/index.ts index da327ca..dcc39b5 100644 --- a/src/viz/max-flow/index.ts +++ b/src/viz/max-flow/index.ts @@ -187,6 +187,7 @@ export const maxFlow: VizDef = { unitPaths, ] as unknown as SavedExample[], practice: maxFlowPractice(() => maxFlow), + preview: { example: 'three-paths', level: 2 }, }; const netLines = (net: Network) => [ diff --git a/src/viz/memory-model/index.ts b/src/viz/memory-model/index.ts index 328910c..6f252c0 100644 --- a/src/viz/memory-model/index.ts +++ b/src/viz/memory-model/index.ts @@ -208,6 +208,7 @@ export const memoryModel: VizDef = { cppReference, cppPass, ] as unknown as SavedExample[], + preview: { example: 'reassign-vs-mutate', level: 3 }, }; /** diff --git a/src/viz/merge-sort/index.ts b/src/viz/merge-sort/index.ts index 8e772bc..c703fb9 100644 --- a/src/viz/merge-sort/index.ts +++ b/src/viz/merge-sort/index.ts @@ -133,6 +133,7 @@ export const mergeSort: VizDef = { duplicates, ] as unknown as SavedExample[], practice: mergePractice(() => mergeSort), + preview: { example: 'top-down', level: 2 }, }; export default mergeSort; diff --git a/src/viz/min-cost-flow/index.ts b/src/viz/min-cost-flow/index.ts index 46f1c37..2cbb203 100644 --- a/src/viz/min-cost-flow/index.ts +++ b/src/viz/min-cost-flow/index.ts @@ -210,6 +210,7 @@ export const minCostFlow: VizDef = { noRoute, ] as unknown as SavedExample[], practice: minCostPractice(() => minCostFlow), + preview: { example: 'cheapest-first', level: 2, hide: ['vn-', 'tbl-'] }, }; const netLines = (net: Network) => [ diff --git a/src/viz/n-queens/index.ts b/src/viz/n-queens/index.ts index 6b7c757..8b749c9 100644 --- a/src/viz/n-queens/index.ts +++ b/src/viz/n-queens/index.ts @@ -135,6 +135,7 @@ export const nQueens: VizDef = { sixAll, eightAll, ] as unknown as SavedExample[], + preview: { example: 'four-first', level: 2 }, }; /** diff --git a/src/viz/np-reductions/index.ts b/src/viz/np-reductions/index.ts index 97251f6..c88b857 100644 --- a/src/viz/np-reductions/index.ts +++ b/src/viz/np-reductions/index.ts @@ -233,6 +233,7 @@ export const npReductions: VizDef = { bruteForce, ] as unknown as SavedExample[], practice: npPractice(() => npReductions), + preview: { example: 'chain', level: 2 }, }; // ----- Driver protocol for the code tabs ----- diff --git a/src/viz/polygon/index.ts b/src/viz/polygon/index.ts index c6a3eff..0fe4634 100644 --- a/src/viz/polygon/index.ts +++ b/src/viz/polygon/index.ts @@ -227,6 +227,7 @@ export const polygon: VizDef = { starEvenOdd, starWinding, ] as unknown as SavedExample[], + preview: { example: 'star-winding', level: 2 }, }; /** diff --git a/src/viz/prim/index.ts b/src/viz/prim/index.ts index ca08dc4..3a86966 100644 --- a/src/viz/prim/index.ts +++ b/src/viz/prim/index.ts @@ -164,6 +164,7 @@ export const prim: VizDef = { dense, ] as unknown as SavedExample[], practice: primPractice(() => prim), + preview: { example: 'key-drops', level: 2, hide: ['vn-', 'pq-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/queue-array/index.ts b/src/viz/queue-array/index.ts index 312e643..84f42dc 100644 --- a/src/viz/queue-array/index.ts +++ b/src/viz/queue-array/index.ts @@ -181,6 +181,7 @@ export const queueArray: VizDef = { fullCount, underflow, ] as unknown as SavedExample[], + preview: { example: 'wraparound', level: 2 }, }; /** diff --git a/src/viz/queue-list/index.ts b/src/viz/queue-list/index.ts index 8d70f17..67412b5 100644 --- a/src/viz/queue-list/index.ts +++ b/src/viz/queue-list/index.ts @@ -122,6 +122,7 @@ export const queueList: VizDef queueList), // JSON imports lose the tuple types; the conformance test validates every example. examples: [fifo, firstEnqueue, lastDequeue, underflow, longLine] as unknown as SavedExample[], + preview: { example: 'long-line' }, }; /** diff --git a/src/viz/quick-sort/index.ts b/src/viz/quick-sort/index.ts index babe6b2..f2b8d8e 100644 --- a/src/viz/quick-sort/index.ts +++ b/src/viz/quick-sort/index.ts @@ -115,6 +115,7 @@ export const quickSort: VizDef = { allEqual, ] as unknown as SavedExample[], practice: quickPractice(() => quickSort), + preview: { example: 'lomuto', level: 2 }, }; export default quickSort; diff --git a/src/viz/quickselect/index.ts b/src/viz/quickselect/index.ts index 31a2524..7a538d4 100644 --- a/src/viz/quickselect/index.ts +++ b/src/viz/quickselect/index.ts @@ -114,6 +114,7 @@ export const quickselect: VizDef = { // JSON imports lose the tuple types; the conformance test validates every example. examples: [median, smallest, lucky, sortedWorst, hoare, duplicates] as unknown as SavedExample[], practice: selectPractice(() => quickselect), + preview: { example: 'median', level: 2 }, }; export default quickselect; diff --git a/src/viz/rabin-karp/index.ts b/src/viz/rabin-karp/index.ts index b143805..f116ffa 100644 --- a/src/viz/rabin-karp/index.ts +++ b/src/viz/rabin-karp/index.ts @@ -163,6 +163,7 @@ export const rabinKarp: VizDef, RkSettings, MatchExtra> = { needsDigits, ] as unknown as SavedExample[], practice: rkPractice(() => rabinKarp), + preview: { example: 'digits', level: 2 }, }; export default rabinKarp; diff --git a/src/viz/radix-sort/index.ts b/src/viz/radix-sort/index.ts index 00c47d5..795f8c2 100644 --- a/src/viz/radix-sort/index.ts +++ b/src/viz/radix-sort/index.ts @@ -133,6 +133,7 @@ export const radixSort: VizDef = { oneBucket, ] as unknown as SavedExample[], practice: radixPractice(() => radixSort), + preview: { example: 'three-digits', level: 2 }, }; export default radixSort; diff --git a/src/viz/range-queries/index.ts b/src/viz/range-queries/index.ts index d572e63..1abe129 100644 --- a/src/viz/range-queries/index.ts +++ b/src/viz/range-queries/index.ts @@ -177,6 +177,7 @@ export const rangeQueries: VizDef = { minPowerOfTwo, sparseUpdate, ] as unknown as SavedExample[], + preview: { example: 'update-then-query', level: 2 }, }; /** diff --git a/src/viz/recursion-dag/index.ts b/src/viz/recursion-dag/index.ts index f313b5f..dce937f 100644 --- a/src/viz/recursion-dag/index.ts +++ b/src/viz/recursion-dag/index.ts @@ -168,6 +168,7 @@ export const recursionDag: VizDef = { binomSixThree, nothingShared, ] as unknown as SavedExample[], + preview: { example: 'fib-five', level: 2 }, }; /** 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/reverse-string/index.ts b/src/viz/reverse-string/index.ts index b175097..f98a584 100644 --- a/src/viz/reverse-string/index.ts +++ b/src/viz/reverse-string/index.ts @@ -173,6 +173,7 @@ export const reverseString: VizDef = { palindrome, longest, ] as unknown as SavedExample[], + preview: { example: 'rest-first', level: 2 }, }; /** diff --git a/src/viz/rotate-scale-2d/index.ts b/src/viz/rotate-scale-2d/index.ts index 577f047..73cafea 100644 --- a/src/viz/rotate-scale-2d/index.ts +++ b/src/viz/rotate-scale-2d/index.ts @@ -203,6 +203,7 @@ export const rotateScale2d: VizDef = { rowVectors, tooBig, ] as unknown as SavedExample[], + preview: { example: 'about-a-point', level: 3, hide: ['op-', 'mt-'] }, }; const show = (pts: { x: number; y: number }[]) => diff --git a/src/viz/rotate-scale-3d/index.ts b/src/viz/rotate-scale-3d/index.ts index 6da2d57..a96a6c3 100644 --- a/src/viz/rotate-scale-3d/index.ts +++ b/src/viz/rotate-scale-3d/index.ts @@ -220,6 +220,7 @@ export const rotateScale3d: VizDef = { rowVectors, tumblingWedge, ] as unknown as SavedExample[], + preview: { example: 'tumbling-wedge', level: 3, hide: ['op-', 'mt-'] }, }; const show = (pts: Vec3[]) => diff --git a/src/viz/rotate-translate-2d/index.ts b/src/viz/rotate-translate-2d/index.ts index abd9de9..2d904a4 100644 --- a/src/viz/rotate-translate-2d/index.ts +++ b/src/viz/rotate-translate-2d/index.ts @@ -232,6 +232,7 @@ export const rotateTranslate2d: VizDef = { matrixPlusOffset, rowVectors, ] as unknown as SavedExample[], + preview: { example: 'undo-it-all', level: 3, hide: ['op-', 'mt-'] }, }; /** diff --git a/src/viz/scc/index.ts b/src/viz/scc/index.ts index 77c2e25..0ef301c 100644 --- a/src/viz/scc/index.ts +++ b/src/viz/scc/index.ts @@ -165,6 +165,7 @@ export const scc: VizDef = { undirected, ] as unknown as SavedExample[], practice: sccPractice(() => scc), + preview: { example: 'four-components', level: 2, hide: ['vn-', 'cs-', 'F-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/segment-tree/index.ts b/src/viz/segment-tree/index.ts index 45e64ea..bb67f99 100644 --- a/src/viz/segment-tree/index.ts +++ b/src/viz/segment-tree/index.ts @@ -148,6 +148,7 @@ export const segmentTree: VizDef = { rangeMinimum, unevenTree, ] as unknown as SavedExample[], + preview: { example: 'three-cases', level: 2 }, }; /** diff --git a/src/viz/selection-sort/index.ts b/src/viz/selection-sort/index.ts index 783128d..4977f1d 100644 --- a/src/viz/selection-sort/index.ts +++ b/src/viz/selection-sort/index.ts @@ -117,6 +117,7 @@ export const selectionSort: VizDef = notStable, ] as unknown as SavedExample[], practice: selectionPractice(() => selectionSort), + preview: { example: 'smallest-first', level: 2 }, }; export default selectionSort; diff --git a/src/viz/shell-sort/index.ts b/src/viz/shell-sort/index.ts index 175179e..aab98ae 100644 --- a/src/viz/shell-sort/index.ts +++ b/src/viz/shell-sort/index.ts @@ -128,6 +128,7 @@ export const shellSort: VizDef = { notStable, ] as unknown as SavedExample[], practice: shellPractice(() => shellSort), + preview: { example: 'halving', level: 2 }, }; export default shellSort; diff --git a/src/viz/skew-heap/index.ts b/src/viz/skew-heap/index.ts index 68e40e8..5977d1f 100644 --- a/src/viz/skew-heap/index.ts +++ b/src/viz/skew-heap/index.ts @@ -123,6 +123,7 @@ export const skewHeap: VizDef = { mergeTwoHeaps, deleteMinMerge, ] as unknown as SavedExample[], + preview: { example: 'merge-two-heaps', level: 2 }, }; export default skewHeap; diff --git a/src/viz/skip-list/index.ts b/src/viz/skip-list/index.ts index 3c3c24d..aa34cd0 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: ['cell-arg'] }, }; /** diff --git a/src/viz/sorted-array/index.ts b/src/viz/sorted-array/index.ts index 25d67cc..f5830ac 100644 --- a/src/viz/sorted-array/index.ts +++ b/src/viz/sorted-array/index.ts @@ -186,6 +186,7 @@ export const sortedArray: VizDef = { deleteExample, reverseFull, ] as unknown as SavedExample[], + preview: { example: 'binary-search', level: 2 }, }; function result(op: string, last: Step): string { diff --git a/src/viz/splay/index.ts b/src/viz/splay/index.ts index 127431a..355c959 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' }, }; /** diff --git a/src/viz/stack-array/index.ts b/src/viz/stack-array/index.ts index 1f24d99..5458bc8 100644 --- a/src/viz/stack-array/index.ts +++ b/src/viz/stack-array/index.ts @@ -142,6 +142,7 @@ export const stackArray: VizDef = { }, // JSON imports lose the tuple types; the conformance test validates every example. examples: [lifo, reverseWord, leftovers, overflow, underflow, index] as unknown as SavedExample[], + preview: { example: 'lifo', level: 2 }, }; /** diff --git a/src/viz/stack-list/index.ts b/src/viz/stack-list/index.ts index 0c984c5..17cfe47 100644 --- a/src/viz/stack-list/index.ts +++ b/src/viz/stack-list/index.ts @@ -115,6 +115,7 @@ export const stackList: VizDef stackList), // JSON imports lose the tuple types; the conformance test validates every example. examples: [lifo, firstPush, underflow, noOverflow, reverseWord] as unknown as SavedExample[], + preview: { example: 'reverse-word', level: 2 }, }; /** diff --git a/src/viz/steiner-tree/index.ts b/src/viz/steiner-tree/index.ts index dac3075..8e22a77 100644 --- a/src/viz/steiner-tree/index.ts +++ b/src/viz/steiner-tree/index.ts @@ -131,6 +131,7 @@ export const steinerTree: VizDef = { approxSpanning, ] as unknown as SavedExample[], practice: stPractice(() => steinerTree), + preview: { example: 'hidden-hub', level: 2, hide: ['cl-', 'cm-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/suffix-array/index.ts b/src/viz/suffix-array/index.ts index becad5f..17684cb 100644 --- a/src/viz/suffix-array/index.ts +++ b/src/viz/suffix-array/index.ts @@ -204,6 +204,7 @@ export const suffixArray: VizDef = { notThere, ] as unknown as SavedExample[], practice: suffixPractice(() => suffixArray), + preview: { example: 'banana', level: 2 }, }; function word(rng: { int(lo: number, hi: number): number }, n: number, letters: string): string { diff --git a/src/viz/suffix-tree/index.ts b/src/viz/suffix-tree/index.ts index 03645b7..f25fa23 100644 --- a/src/viz/suffix-tree/index.ts +++ b/src/viz/suffix-tree/index.ts @@ -221,6 +221,7 @@ export const suffixTree: VizDef = { notThere, ] as unknown as SavedExample[], practice: suffixTreePractice(() => suffixTree), + preview: { example: 'banana', level: 2 }, }; /** diff --git a/src/viz/topo-dfs/index.ts b/src/viz/topo-dfs/index.ts index 845cf99..25ac0a8 100644 --- a/src/viz/topo-dfs/index.ts +++ b/src/viz/topo-dfs/index.ts @@ -154,6 +154,7 @@ export const topoDfs: VizDef = { dense, ] as unknown as SavedExample[], practice: topoPractice(() => topoDfs), + preview: { example: 'getting-dressed', level: 2, hide: ['vn-', 'L-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/topo-indegree/index.ts b/src/viz/topo-indegree/index.ts index 2dde90e..78b4ea4 100644 --- a/src/viz/topo-indegree/index.ts +++ b/src/viz/topo-indegree/index.ts @@ -154,6 +154,7 @@ export const topoIndegree: VizDef = { wide, ] as unknown as SavedExample[], practice: topoPractice(() => topoIndegree), + preview: { example: 'paths-dag', level: 2, hide: ['vn-', 'q-', 'L-', 'tbl-'] }, }; const graphLines = (g: Graph) => [ 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/trie/index.ts b/src/viz/trie/index.ts index 29a8119..4e68bfc 100644 --- a/src/viz/trie/index.ts +++ b/src/viz/trie/index.ts @@ -188,6 +188,7 @@ export const trie: VizDef = { noSharedPrefixes, lazyDelete, ] as unknown as SavedExample[], + preview: { example: 'shared-prefixes', level: 2 }, }; /** diff --git a/src/viz/tsp/index.ts b/src/viz/tsp/index.ts index b4756f8..be81c13 100644 --- a/src/viz/tsp/index.ts +++ b/src/viz/tsp/index.ts @@ -194,6 +194,7 @@ export const tsp: VizDef = { onACircle, ] as unknown as SavedExample[], practice: tspPractice(() => tsp), + preview: { example: 'two-opt-fix', level: 2, hide: ['tour-'] }, }; const graphLines = (g: Graph) => [ 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/src/viz/two-sat/index.ts b/src/viz/two-sat/index.ts index 2e501ed..5201d80 100644 --- a/src/viz/two-sat/index.ts +++ b/src/viz/two-sat/index.ts @@ -177,6 +177,7 @@ export const twoSat: VizDef = { forced, ] as unknown as SavedExample[], practice: satPractice(() => twoSat), + preview: { example: 'three-clauses', level: 2, hide: ['vn-', 'cl-', 'F-', 'as-'] }, }; const formulaLines = (s: SatState) => [ diff --git a/src/viz/vertex-cover/index.ts b/src/viz/vertex-cover/index.ts index d94647c..4b3c012 100644 --- a/src/viz/vertex-cover/index.ts +++ b/src/viz/vertex-cover/index.ts @@ -169,6 +169,7 @@ export const vertexCover: VizDef = { noEdges, ] as unknown as SavedExample[], practice: vcPractice(() => vertexCover), + preview: { example: 'greedy-trap', level: 2, hide: ['deg-', 'cover-'] }, }; const graphLines = (g: Graph) => [ diff --git a/src/viz/weighted-matching/index.ts b/src/viz/weighted-matching/index.ts index ed465eb..4073232 100644 --- a/src/viz/weighted-matching/index.ts +++ b/src/viz/weighted-matching/index.ts @@ -161,6 +161,7 @@ export const weightedMatching: VizDef = { // JSON imports lose the tuple types; the conformance test validates every example. examples: [cheapest, maximize, products, zeros, allEqual, oneWorker] as unknown as SavedExample[], practice: hungarianPractice(() => weightedMatching), + preview: { example: 'cheapest-four', level: 2 }, }; /** diff --git a/tests/e2e/catalog.spec.ts b/tests/e2e/catalog.spec.ts new file mode 100644 index 0000000..1b2c0f3 --- /dev/null +++ b/tests/e2e/catalog.spec.ts @@ -0,0 +1,64 @@ +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 page has a thumbnail, hidden from screen readers', async ({ page }) => { + await page.goto('/'); + await expect(trees(page).locator('svg.thumb')).toHaveCount(10); + await expect(page.locator('li.live svg.thumb')).toHaveCount( + await page.locator('li.live').count(), + ); + 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' }); + const frameFiles: string[] = []; + page.on('request', (r) => { + if (r.url().includes('/previews/')) frameFiles.push(r.url()); + }); + 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); + // The frames are fetched only to play, so they are never fetched here. + expect(frameFiles).toEqual([]); +}); + +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..be95097 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,38 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, type Plugin } from 'vitest/config'; import { svelte } from '@sveltejs/vite-plugin-svelte'; +/** + * Serves the catalog thumbnails in development, made fresh on every request from the same code + * the build uses (scripts/prerender.mjs writes the files into dist): /previews.json holds every + * page's still picture, /previews/.json one page's frames. + */ +function previews(): Plugin { + return { + name: 'stepwise-previews', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = req.url?.split('?')[0] ?? ''; + const one = /^\/previews\/([a-z0-9-]+)\.json$/.exec(url); + if (url !== '/previews.json' && !one) return next(); + Promise.all([ + server.ssrLoadModule('/src/viz/all.ts'), + server.ssrLoadModule('/src/render/preview.ts'), + ]) + .then(([{ vizzes }, { makePosters, makePreview }]) => { + const def = one && vizzes.find((d: { id: string }) => d.id === one[1]); + const body = one ? (def ? makePreview(def) : null) : makePosters(vizzes); + if (!body) return next(); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(body)); + }) + .catch(next); + }); + }, + }; +} + export default defineConfig({ - plugins: [svelte()], + plugins: [svelte(), previews()], // Absolute, because each visualization has its own page at //. base: '/', build: { target: 'es2022' },