math tracker: pannable 2D map of the Napkin (default), list view kept behind ?view=list
All checks were successful
Deploy production / deploy (push) Successful in 1m18s

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.
This commit is contained in:
2026-10-26 12:22:38 +00:00
parent 44379b9a27
commit d7e26a3338
4 changed files with 1951 additions and 61 deletions

File diff suppressed because it is too large Load Diff

124
src/lib/napkin/map.test.ts Normal file
View File

@@ -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<string, number> = {
'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);
});
});

303
src/lib/napkin/map.ts Normal file
View File

@@ -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<string, readonly [number, number]> = {
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<string, string> = {
'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<string, { id: string; other: number }[]>();
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<number, number>();
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]
};
}

View File

@@ -1,5 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import MathMap from '#lib/napkin/MathMap.svelte';
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte'; import PrivateLock from '#lib/components/PrivateLock.svelte';
@@ -26,6 +30,7 @@
import ListChecks from 'lucide-svelte/icons/list-checks'; import ListChecks from 'lucide-svelte/icons/list-checks';
import MapIcon from 'lucide-svelte/icons/map'; import MapIcon from 'lucide-svelte/icons/map';
import StickyNote from 'lucide-svelte/icons/sticky-note'; import StickyNote from 'lucide-svelte/icons/sticky-note';
import List from 'lucide-svelte/icons/list';
let { data, form } = $props(); let { data, form } = $props();
@@ -47,6 +52,46 @@
let filter = $state<MathStatus | 'all'>('all'); let filter = $state<MathStatus | 'all'>('all');
let errorKey = $state(''); let errorKey = $state('');
// Map (default) or the original list; ?view=list in the URL, remembered per device.
type View = 'map' | 'list';
const VIEW_KEY = 'math:view';
const urlView = (): View | null => {
const v = page.url.searchParams.get('view');
return v === 'list' || v === 'map' ? v : null;
};
let view = $state<View>(urlView() ?? 'map');
let selected = $state<string | null>(null);
let map: ReturnType<typeof MathMap> | undefined = $state();
onMount(() => {
if (urlView()) return;
try {
const saved = localStorage.getItem(VIEW_KEY);
if (saved === 'list' || saved === 'map') view = saved;
} catch {
// Ignore.
}
});
function setView(next: View) {
if (next === view) return;
view = next;
try {
localStorage.setItem(VIEW_KEY, next);
} catch {
// Not persisted; fine.
}
const url = new URL(page.url);
if (next === 'map') url.searchParams.delete('view');
else url.searchParams.set('view', next);
replaceState(url, {});
}
/** Jump to a chapter: centre it on the map, or expand it in the list. */
function goTo(e: MouseEvent, key: string) {
if (view === 'map' && map) {
e.preventDefault();
map.focusChapter(key);
} else open[key] = true;
}
const statusOf = (c: Chapter): MathStatus => pending[c.key] ?? c.status; const statusOf = (c: Chapter): MathStatus => pending[c.key] ?? c.status;
const byKey = $derived(new Map(data.chapters.map((c) => [c.key, c]))); const byKey = $derived(new Map(data.chapters.map((c) => [c.key, c])));
@@ -219,7 +264,7 @@
class="dot" class="dot"
style:--c={STATUS[statusOf(p)].color} style:--c={STATUS[statusOf(p)].color}
title="{chapterLabel(p.key)} {p.title}: {STATUS_LABEL[statusOf(p)]}" title="{chapterLabel(p.key)} {p.title}: {STATUS_LABEL[statusOf(p)]}"
onclick={() => (open[p.key] = true)}>{p.key}</a onclick={(e) => goTo(e, p.key)}>{p.key}</a
> >
{/each} {/each}
</span> </span>
@@ -297,7 +342,7 @@
</div> </div>
{/snippet} {/snippet}
<div class="math-page"> <div class={['math-page', `${view}-view`]}>
<header class="head"> <header class="head">
<TrackerIcon name="math" class="size-10" /> <TrackerIcon name="math" class="size-10" />
<div> <div>
@@ -310,13 +355,25 @@
>, chapter by chapter. >, chapter by chapter.
</p> </p>
</div> </div>
<div class="views" role="group" aria-label="View">
<button
class={{ on: view === 'map' }}
aria-pressed={view === 'map'}
onclick={() => setView('map')}><MapIcon size={14} />map</button
>
<button
class={{ on: view === 'list' }}
aria-pressed={view === 'list'}
onclick={() => setView('list')}><List size={14} />list</button
>
</div>
</header> </header>
<section class="overview card"> <section class="overview card">
<div class="big"> <div class="big">
<span class="pct">{pct(counts.done, countable)}%</span> <span class="pct">{pct(counts.done, countable)}%</span>
<span class="muted" <span class="muted"
>{counts.done} of {countable} chapters done{#if counts.skipped}{' · '}{counts.skipped} >{counts.done} of {countable} chapters done{#if counts.skipped}&nbsp;· {counts.skipped}
skipped{/if}</span skipped{/if}</span
> >
</div> </div>
@@ -343,7 +400,27 @@
</div> </div>
</section> </section>
{#if current} {#if view === 'map'}
<div class="map-wrap">
<MathMap
bind:this={map}
bind:selected
parts={data.parts}
chapters={data.chapters}
{statusOf}
status={STATUS}
currentKey={current?.chapter.key ?? null}
{filter}
>
{#snippet panel(key)}
{@const c = byKey.get(key)}
{#if c}<div class="in-panel">{@render details(c)}</div>{/if}
{/snippet}
</MathMap>
</div>
{/if}
{#if current && view === 'list'}
{@const c = current.chapter} {@const c = current.chapter}
{@const part = data.parts.find((p) => p.key === c.partKey)} {@const part = data.parts.find((p) => p.key === c.partKey)}
<section class="current card" style:--c={STATUS[statusOf(c)].color}> <section class="current card" style:--c={STATUS[statusOf(c)].color}>
@@ -363,7 +440,7 @@
{/if} {/if}
<div class="layout"> <div class="layout">
<div class="parts"> {#if view === 'list'}<div class="parts">
{#each parts as p (p.key)} {#each parts as p (p.key)}
{#if p.shown.length} {#if p.shown.length}
<section class="part card"> <section class="part card">
@@ -418,7 +495,7 @@
</section> </section>
{/if} {/if}
{/each} {/each}
</div> </div>{/if}
<aside class="side"> <aside class="side">
<section class="card"> <section class="card">
@@ -436,7 +513,7 @@
><ListChecks size={14} /></span ><ListChecks size={14} /></span
>{:else}<span class="sicon" style:--c="#fbbf24"><StickyNote size={14} /></span >{:else}<span class="sicon" style:--c="#fbbf24"><StickyNote size={14} /></span
>{/if} >{/if}
<a href="#ch-{a.chapterKey}" onclick={() => (open[a.chapterKey] = true)} <a href="#ch-{a.chapterKey}" onclick={(e) => goTo(e, a.chapterKey)}
>{chapterLabel(a.chapterKey)}</a >{chapterLabel(a.chapterKey)}</a
> >
<span class="atext">{activityText(a)}</span> <span class="atext">{activityText(a)}</span>
@@ -479,6 +556,61 @@
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.math-page.map-view {
max-width: 110rem;
}
.views {
display: inline-flex;
margin-left: auto;
align-self: flex-start;
padding: 0.15rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-surface);
}
.views button {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.65rem;
border-radius: 0.45rem;
font-size: 0.82rem;
color: var(--site-muted);
cursor: pointer;
}
.views button:hover {
color: var(--site-text);
}
.views button.on {
background: var(--site-hover);
color: var(--site-text);
}
.map-wrap {
height: max(28rem, calc(100dvh - 6rem));
scroll-margin-top: 4.5rem;
}
.map-view .layout {
display: block;
}
.map-view .side {
position: static;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
align-items: start;
}
.in-panel .details {
padding: 0;
}
.in-panel .seg {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.in-panel .seg button {
flex-direction: column;
gap: 0.15rem;
padding: 0.45rem 0.2rem;
font-size: 0.7rem;
}
.head { .head {
display: flex; display: flex;
align-items: center; align-items: center;