From d7e26a3338684c93d04d56299606338520cc1e8f Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Mon, 26 Oct 2026 12:22:38 +0000 Subject: [PATCH] math tracker: pannable 2D map of the Napkin (default), list view kept behind ?view=list Chapters are nodes grouped into part regions laid out like the book's dependency chart (roots Abs Alg, Topology, Lin Alg, Set Theory flowing down to AG, AT and ANT). Part prereqs are routed edges, optional ones (\coreqc in the book) dotted. Status colours, exercise rings, current-chapter glow, legend, search, minimap, pinch/drag/wheel zoom, fit and +/- buttons. Tapping a chapter opens the existing panel (side panel on desktop, bottom sheet on mobile). The view choice is remembered per device. --- src/lib/napkin/MathMap.svelte | 1331 +++++++++++++++++++++++++ src/lib/napkin/map.test.ts | 124 +++ src/lib/napkin/map.ts | 303 ++++++ src/routes/trackers/math/+page.svelte | 254 +++-- 4 files changed, 1951 insertions(+), 61 deletions(-) create mode 100644 src/lib/napkin/MathMap.svelte create mode 100644 src/lib/napkin/map.test.ts create mode 100644 src/lib/napkin/map.ts diff --git a/src/lib/napkin/MathMap.svelte b/src/lib/napkin/MathMap.svelte new file mode 100644 index 0000000..2750924 --- /dev/null +++ b/src/lib/napkin/MathMap.svelte @@ -0,0 +1,1331 @@ + + + + +
+ + + + + + + + + + + + + {#each layout.edges as e (e.id)} + {@const hi = focusPart !== null && (e.from === focusPart || e.to === focusPart)} + + {/each} + + {#each layout.regions as r (r.key)} + {@const p = partByKey.get(r.key)} + {@const st = regionStats.get(r.key)} + + (hoverPart = r.key)} + onpointerleave={() => (hoverPart = hoverPart === r.key ? null : hoverPart)} + > + + + {p?.num ?? 'A–E'} + {trunc(partShort(p), 22)} + {#if st} + {st.done}/{st.total} + + {#if st.done}{/if} + {/if} + + {/each} + + {#each layout.nodes as n (n.key)} + {@const c = chapterByKey.get(n.key)} + {#if c} + {@const s = statusByKey.get(n.key) ?? 'not_started'} + {@const Icon = status[s].icon} + + {chLabel(c.key)}: {c.title} ({STATUS_LABEL[s]}{c.exerciseCount + ? `, ${c.exercisesDone}/${c.exerciseCount} exercises` + : ''}) + {#if currentKey === n.key}{/if} + + + {#if ringLen(c, s) > 0} + + {/if} + {c.key} + {trunc(c.title, 25)} + {#if s !== 'not_started'} + + {/if} + + {/if} + {/each} + + {#if far} + + {/if} + + + + + +
+ {#if currentKey} + + {/if} + + + +
+ +
+ {#each Object.entries(status) as [s, v] (s)} + {STATUS_LABEL[s as MathStatus]} + {/each} + prereq + optional + exercises +
+ + {#if !mobile && cw} + { + miniDrag = true; + (e.currentTarget as SVGElement).setPointerCapture(e.pointerId); + miniMove(e); + }} + onpointermove={(e) => miniDrag && miniMove(e)} + onpointerup={() => (miniDrag = false)} + > + + {#each layout.regions as r (r.key)} + {@const st = regionStats.get(r.key)} + + {/each} + + + + {/if} + + {#if sel} + {@const s = statusByKey.get(sel.key) ?? 'not_started'} + {@const Icon = status[s].icon} + {@const p = partByKey.get(sel.partKey)} + + + {/if} +
+ + diff --git a/src/lib/napkin/map.test.ts b/src/lib/napkin/map.test.ts new file mode 100644 index 0000000..aae72ea --- /dev/null +++ b/src/lib/napkin/map.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { buildMapLayout, MAP_GRID, PART_ACCENT, PART_LINKS } from './map'; + +// Chapter counts per part, as in the seeded Napkin. +const COUNTS: Record = { + '1': 3, + '2': 3, + '3': 3, + '4': 7, + '5': 3, + '6': 4, + '7': 3, + '8': 5, + '9': 4, + '10': 5, + '11': 3, + '12': 4, + '13': 6, + '14': 6, + '15': 5, + '16': 3, + '17': 4, + '18': 6, + '19': 5, + '20': 6, + '21': 4, + '22': 3, + appendix: 5 +}; +let n = 0; +const parts = Object.entries(COUNTS).map(([key, count]) => ({ + key, + chapters: Array.from({ length: count }, () => String(++n)) +})); +const layout = buildMapLayout(parts); + +/** Points along an absolute M/L/C path. */ +function samples(d: string) { + const tokens = d.match(/[MLC]|-?\d+(?:\.\d+)?/g) ?? []; + const pts: [number, number][] = []; + let cur: [number, number] = [0, 0]; + let i = 0; + const num = () => Number(tokens[i++]); + while (i < tokens.length) { + const cmd = tokens[i++]; + if (cmd === 'M' || cmd === 'L') { + const p: [number, number] = [num(), num()]; + if (cmd === 'L') + for (let t = 0; t <= 1; t += 0.05) + pts.push([cur[0] + (p[0] - cur[0]) * t, cur[1] + (p[1] - cur[1]) * t]); + cur = p; + } else if (cmd === 'C') { + const [c1, c2, p] = [ + [num(), num()], + [num(), num()], + [num(), num()] + ]; + for (let t = 0; t <= 1; t += 0.05) { + const u = 1 - t; + pts.push([ + u ** 3 * cur[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t ** 3 * p[0], + u ** 3 * cur[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t ** 3 * p[1] + ]); + } + cur = p as [number, number]; + } else throw new Error(`unexpected path token ${cmd}`); + } + return pts; +} + +describe('napkin map layout', () => { + it('places every part and every chapter', () => { + expect(layout.regions.map((r) => r.key).sort()).toEqual(Object.keys(COUNTS).sort()); + expect(layout.nodes).toHaveLength(n); + for (const key of Object.keys(COUNTS)) { + expect(MAP_GRID[key]).toBeDefined(); + expect(PART_ACCENT[key]).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it('keeps regions apart and nodes inside their region', () => { + const rs = layout.regions; + for (let a = 0; a < rs.length; a++) + for (let b = a + 1; b < rs.length; b++) { + const [p, q] = [rs[a], rs[b]]; + const overlap = p.x < q.x + q.w && q.x < p.x + p.w && p.y < q.y + q.h && q.y < p.y + p.h; + expect(overlap, `${p.key} overlaps ${q.key}`).toBe(false); + } + for (const node of layout.nodes) { + const r = rs.find((x) => x.key === node.partKey)!; + expect(node.x).toBeGreaterThanOrEqual(r.x); + expect(node.y).toBeGreaterThanOrEqual(r.y); + expect(node.x + node.w).toBeLessThanOrEqual(r.x + r.w); + expect(node.y + node.h).toBeLessThanOrEqual(r.y + r.h); + } + }); + + it('draws one edge per part link, optional ones flagged', () => { + expect(layout.edges).toHaveLength(PART_LINKS.length); + expect(layout.edges.filter((e) => e.optional)).toHaveLength( + PART_LINKS.filter((l) => l.optional).length + ); + expect(PART_LINKS.filter((l) => l.optional).length).toBe(10); + for (const e of layout.edges) expect(e.d).toMatch(/^M/); + }); + + it('never routes an edge through an unrelated region', () => { + for (const e of layout.edges) { + const others = layout.regions.filter((r) => r.key !== e.from && r.key !== e.to); + for (const [x, y] of samples(e.d)) + for (const r of others) { + const inside = x > r.x + 1 && x < r.x + r.w - 1 && y > r.y + 1 && y < r.y + r.h - 1; + expect(inside, `${e.id} crosses part ${r.key} at ${x},${y}`).toBe(false); + } + } + }); + + it('flows downward from the roots', () => { + const row = (k: string) => MAP_GRID[k][1]; + for (const l of PART_LINKS) expect(row(l.to)).toBeGreaterThanOrEqual(row(l.from) - 1); + for (const root of ['2', '3', '4', '21']) expect(row(root)).toBeLessThanOrEqual(1); + for (const leaf of ['18', '20', '15']) expect(row(leaf)).toBe(5); + }); +}); diff --git a/src/lib/napkin/map.ts b/src/lib/napkin/map.ts new file mode 100644 index 0000000..0597535 --- /dev/null +++ b/src/lib/napkin/map.ts @@ -0,0 +1,303 @@ +/** + * The Napkin as a 2D map: one region per part, one node per chapter, prereq edges between parts. + * + * Region placement follows the book's dependency chart ("Advice for the reader", PDF p. 9, + * tex/frontmatter/digraph.tex): Abs Alg, Lin Alg, Topology and Set Theory are the roots and + * everything flows down to Alg Geo, Alg Top and Alg NT. Regions sit on a hand-placed grid; edges + * are routed through the empty channels between rows and the gutters between columns, so they + * never cut through a region. + */ + +/** Part → [column, row] on the map grid. */ +export const MAP_GRID: Record = { + appendix: [0, 0], + '1': [3, 0], + '21': [0, 1], + '2': [1, 1], + '4': [3, 1], + '3': [5, 1], + '22': [0, 2], + '5': [1, 2], + '6': [2, 2], + '7': [4, 2], + '8': [5, 2], + '17': [2, 3], + '12': [4, 3], + '9': [5, 3], + '10': [6, 3], + '19': [1, 4], + '16': [2, 4], + '14': [3, 4], + '13': [4, 4], + '11': [6, 4], + '20': [1, 5], + '18': [2, 5], + '15': [3, 5] +}; + +export type PartLink = { from: string; to: string; optional: boolean }; + +/** + * Part-level prereqs from the book's chart. `optional` = dotted in the book (\coreqc), solid + * otherwise (\prereqc). Additions: Starting Out leads into Abs Alg and Topology, Measure → + * Probability and Diff Geo → Riemann Surfaces (one block in the chart each). + */ +export const PART_LINKS: readonly PartLink[] = ( + [ + ['1', '2'], + ['1', '3'], + ['2', '4', true], + ['2', '5'], + ['2', '6'], + ['4', '6'], + ['4', '7'], + ['2', '17'], + ['4', '17', true], + ['3', '17', true], + ['17', '6', true], + ['3', '8'], + ['3', '9'], + ['8', '9', true], + ['16', '9', true], + ['3', '10'], + ['8', '10', true], + ['10', '11'], + ['4', '12'], + ['3', '12'], + ['8', '12', true], + ['12', '13'], + ['2', '14'], + ['4', '14'], + ['14', '15'], + ['5', '15'], + ['3', '16'], + ['5', '16'], + ['16', '17', true], + ['16', '18'], + ['17', '18'], + ['2', '19'], + ['3', '19'], + ['17', '19', true], + ['19', '20'], + ['17', '20'], + ['21', '22'] + ] as const +).map(([from, to, optional]) => ({ from, to, optional: optional === true })); + +/** Accent per area of maths (region badge stripe), kept apart from the status colours. */ +export const PART_ACCENT: Record = { + '1': '#e4e4e7', + appendix: '#a1a1aa', + '2': '#f472b6', + '5': '#f472b6', + '6': '#f472b6', + '17': '#f472b6', + '4': '#818cf8', + '7': '#818cf8', + '3': '#2dd4bf', + '16': '#2dd4bf', + '18': '#2dd4bf', + '8': '#fb923c', + '9': '#fb923c', + '10': '#fb923c', + '11': '#fb923c', + '12': '#fb923c', + '13': '#fb923c', + '14': '#e879f9', + '15': '#e879f9', + '19': '#a3e635', + '20': '#a3e635', + '21': '#94a3b8', + '22': '#94a3b8' +}; + +export const MAP = { + regionWidth: 240, + gutter: 96, + rowGap: 120, + head: 46, + padX: 10, + padBottom: 10, + nodeHeight: 34, + nodeGap: 6, + portGap: 18, + laneGap: 12 +} as const; + +export type MapRegion = { + key: string; + x: number; + y: number; + w: number; + h: number; + col: number; + row: number; +}; +export type MapNode = { key: string; partKey: string; x: number; y: number; w: number; h: number }; +export type MapEdge = { + id: string; + from: string; + to: string; + optional: boolean; + d: string; + /** End point, for the arrow head. */ + end: { x: number; y: number; dir: 'down' | 'up' | 'right' }; +}; +export type MapLayout = { + regions: MapRegion[]; + nodes: MapNode[]; + edges: MapEdge[]; + width: number; + height: number; +}; + +const r1 = (n: number) => Math.round(n * 10) / 10; + +export function buildMapLayout( + parts: readonly { key: string; chapters: readonly string[] }[], + links: readonly PartLink[] = PART_LINKS +): MapLayout { + const M = MAP; + const W = M.regionWidth; + const colX = (c: number) => c * (W + M.gutter); + const heightOf = (n: number) => M.head + n * (M.nodeHeight + M.nodeGap) - M.nodeGap + M.padBottom; + + const placed = parts.filter((p) => MAP_GRID[p.key]); + const rows = Math.max(...placed.map((p) => MAP_GRID[p.key][1])) + 1; + const cols = Math.max(...placed.map((p) => MAP_GRID[p.key][0])) + 1; + const rowHeight = Array.from({ length: rows }, () => 0); + for (const p of placed) { + const [, r] = MAP_GRID[p.key]; + rowHeight[r] = Math.max(rowHeight[r], heightOf(p.chapters.length)); + } + const rowTop: number[] = [0]; + for (let r = 1; r < rows; r++) rowTop[r] = rowTop[r - 1] + rowHeight[r - 1] + M.rowGap; + /** Channel between row r and r + 1: [channelTop, channelBottom]. */ + const chTop = (r: number) => rowTop[r] + rowHeight[r]; + const chBot = (r: number) => rowTop[r + 1]; + + const regions: MapRegion[] = []; + const nodes: MapNode[] = []; + for (const p of placed) { + const [col, row] = MAP_GRID[p.key]; + const reg = { + key: p.key, + col, + row, + x: colX(col), + y: rowTop[row], + w: W, + h: heightOf(p.chapters.length) + }; + regions.push(reg); + p.chapters.forEach((key, i) => + nodes.push({ + key, + partKey: p.key, + x: reg.x + M.padX, + y: reg.y + M.head + i * (M.nodeHeight + M.nodeGap), + w: W - 2 * M.padX, + h: M.nodeHeight + }) + ); + } + const reg = new Map(regions.map((r) => [r.key, r])); + const usable = links.filter((l) => reg.has(l.from) && reg.has(l.to)); + + // Ports: spread each region's edges along its bottom (outgoing down / incoming up) and top. + type Side = 'top' | 'bottom'; + const portLists = new Map(); + const addPort = (key: string, side: Side, id: string, other: number) => { + const k = `${key}:${side}`; + if (!portLists.has(k)) portLists.set(k, []); + portLists.get(k)!.push({ id, other }); + }; + const idOf = (l: PartLink) => `${l.from}-${l.to}`; + for (const l of usable) { + const a = reg.get(l.from)!; + const b = reg.get(l.to)!; + if (a.row === b.row) continue; + const down = b.row > a.row; + addPort(a.key, down ? 'bottom' : 'top', idOf(l), b.x + b.w / 2); + addPort(b.key, down ? 'top' : 'bottom', idOf(l), a.x + a.w / 2); + } + const portX = (key: string, side: Side, id: string) => { + const r = reg.get(key)!; + const list = [...portLists.get(`${key}:${side}`)!].sort( + (p, q) => p.other - q.other || (p.id < q.id ? -1 : 1) + ); + const i = list.findIndex((p) => p.id === id); + const gap = Math.min(M.portGap, (r.w * 0.7) / Math.max(1, list.length)); + return r.x + r.w / 2 + (i - (list.length - 1) / 2) * gap; + }; + /** Is x free of regions in every row strictly between a and b? */ + const freeAt = (x: number, a: number, b: number) => + regions.every((r) => r.row <= a || r.row >= b || x < r.x - 14 || x > r.x + r.w + 14); + const gutterLoad = new Map(); + const s = (x0: number, y0: number, x1: number, y1: number) => { + if (Math.abs(x0 - x1) < 0.5) return ` L${r1(x1)},${r1(y1)}`; + const m = (y0 + y1) / 2; + return ` C${r1(x0)},${r1(m)} ${r1(x1)},${r1(m)} ${r1(x1)},${r1(y1)}`; + }; + + const edges: MapEdge[] = []; + for (const l of usable) { + const a = reg.get(l.from)!; + const b = reg.get(l.to)!; + const id = idOf(l); + let d: string; + let end: MapEdge['end']; + if (a.row === b.row) { + // Same row (Abs Alg ⇢ Lin Alg): straight across at header height. + const y = a.y + 23; + const [x0, x1] = a.x < b.x ? [a.x + a.w, b.x] : [a.x, b.x + b.w]; + d = `M${r1(x0)},${r1(y)} L${r1(x1)},${r1(y)}`; + end = { x: x1, y, dir: 'right' }; + } else if (b.row < a.row) { + // Upward (optional back-links, adjacent rows only). + const sx = portX(a.key, 'top', id); + const tx = portX(b.key, 'bottom', id); + const ty = b.y + b.h; + d = `M${r1(sx)},${r1(a.y)}` + s(sx, a.y, tx, chTop(b.row)) + ` L${r1(tx)},${r1(ty)}`; + end = { x: tx, y: ty, dir: 'up' }; + } else { + const sx = portX(a.key, 'bottom', id); + const tx = portX(b.key, 'top', id); + const sy = a.y + a.h; + const ty = b.y; + d = `M${r1(sx)},${r1(sy)} L${r1(sx)},${r1(chTop(a.row))}`; + if (b.row === a.row + 1) d += s(sx, chTop(a.row), tx, ty); + else if (freeAt(tx, a.row, b.row)) + d += s(sx, chTop(a.row), tx, chBot(a.row)) + ` L${r1(tx)},${r1(ty)}`; + else if (freeAt(sx, a.row, b.row)) + d += ` L${r1(sx)},${r1(chTop(b.row - 1))}` + s(sx, chTop(b.row - 1), tx, ty); + else { + // Down a gutter between columns: the one with the least detour, then the least used. + let best = { g: 0, cost: Infinity }; + for (let g = 0; g <= cols; g++) { + const gx = colX(g) - M.gutter / 2; + const cost = Math.abs(sx - gx) + Math.abs(tx - gx) + (gutterLoad.get(g) ?? 0) * 40; + if (cost < best.cost) best = { g, cost }; + } + const n = gutterLoad.get(best.g) ?? 0; + gutterLoad.set(best.g, n + 1); + const lane = (n % 2 ? -1 : 1) * Math.ceil(n / 2) * M.laneGap; + const gx = colX(best.g) - M.gutter / 2 + lane; + d += + s(sx, chTop(a.row), gx, chBot(a.row)) + + ` L${r1(gx)},${r1(chTop(b.row - 1))}` + + s(gx, chTop(b.row - 1), tx, ty); + } + end = { x: tx, y: ty, dir: 'down' }; + } + edges.push({ id, from: l.from, to: l.to, optional: l.optional, d, end }); + } + + return { + regions, + nodes, + edges, + width: colX(cols) - M.gutter, + height: rowTop[rows - 1] + rowHeight[rows - 1] + }; +} diff --git a/src/routes/trackers/math/+page.svelte b/src/routes/trackers/math/+page.svelte index bfba98c..0cb9ee1 100644 --- a/src/routes/trackers/math/+page.svelte +++ b/src/routes/trackers/math/+page.svelte @@ -1,5 +1,9 @@