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
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:
1331
src/lib/napkin/MathMap.svelte
Normal file
1331
src/lib/napkin/MathMap.svelte
Normal file
File diff suppressed because it is too large
Load Diff
124
src/lib/napkin/map.test.ts
Normal file
124
src/lib/napkin/map.test.ts
Normal 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
303
src/lib/napkin/map.ts
Normal 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]
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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 TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import PrivateLock from '#lib/components/PrivateLock.svelte';
|
||||
@@ -26,6 +30,7 @@
|
||||
import ListChecks from 'lucide-svelte/icons/list-checks';
|
||||
import MapIcon from 'lucide-svelte/icons/map';
|
||||
import StickyNote from 'lucide-svelte/icons/sticky-note';
|
||||
import List from 'lucide-svelte/icons/list';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -47,6 +52,46 @@
|
||||
let filter = $state<MathStatus | 'all'>('all');
|
||||
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 byKey = $derived(new Map(data.chapters.map((c) => [c.key, c])));
|
||||
|
||||
@@ -219,7 +264,7 @@
|
||||
class="dot"
|
||||
style:--c={STATUS[statusOf(p)].color}
|
||||
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}
|
||||
</span>
|
||||
@@ -297,7 +342,7 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="math-page">
|
||||
<div class={['math-page', `${view}-view`]}>
|
||||
<header class="head">
|
||||
<TrackerIcon name="math" class="size-10" />
|
||||
<div>
|
||||
@@ -310,13 +355,25 @@
|
||||
>, chapter by chapter.
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<section class="overview card">
|
||||
<div class="big">
|
||||
<span class="pct">{pct(counts.done, countable)}%</span>
|
||||
<span class="muted"
|
||||
>{counts.done} of {countable} chapters done{#if counts.skipped}{' · '}{counts.skipped}
|
||||
>{counts.done} of {countable} chapters done{#if counts.skipped} · {counts.skipped}
|
||||
skipped{/if}</span
|
||||
>
|
||||
</div>
|
||||
@@ -343,7 +400,27 @@
|
||||
</div>
|
||||
</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 part = data.parts.find((p) => p.key === c.partKey)}
|
||||
<section class="current card" style:--c={STATUS[statusOf(c)].color}>
|
||||
@@ -363,7 +440,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="layout">
|
||||
<div class="parts">
|
||||
{#if view === 'list'}<div class="parts">
|
||||
{#each parts as p (p.key)}
|
||||
{#if p.shown.length}
|
||||
<section class="part card">
|
||||
@@ -418,7 +495,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>{/if}
|
||||
|
||||
<aside class="side">
|
||||
<section class="card">
|
||||
@@ -436,7 +513,7 @@
|
||||
><ListChecks size={14} /></span
|
||||
>{:else}<span class="sicon" style:--c="#fbbf24"><StickyNote size={14} /></span
|
||||
>{/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
|
||||
>
|
||||
<span class="atext">{activityText(a)}</span>
|
||||
@@ -479,6 +556,61 @@
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user