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 @@