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

View File

@@ -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}&nbsp;· {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,62 +440,62 @@
{/if}
<div class="layout">
<div class="parts">
{#each parts as p (p.key)}
{#if p.shown.length}
<section class="part card">
<button
class="part-head"
aria-expanded={!collapsedParts[p.key]}
onclick={() => (collapsedParts[p.key] = !collapsedParts[p.key])}
>
<span class="pnum">{p.num ?? 'A–E'}</span>
<span class="ptitle">{p.title}</span>
<span class="pbar">{@render bar(p.counts, p.chapters.length)}</span>
<span class="muted pcount">{p.counts.done}/{p.chapters.length}</span>
<ChevronDown size={16} class={!collapsedParts[p.key] ? 'chev flipped' : 'chev'} />
</button>
{#if !collapsedParts[p.key]}
<ul class="chapters">
{#each p.shown as c (c.key)}
{@const s = statusOf(c)}
<li id="ch-{c.key}" class={['chapter', s, { open: open[c.key] }]}>
<button
class="row"
aria-expanded={!!open[c.key]}
onclick={() => (open[c.key] = !open[c.key])}
>
{@render statusIcon(s)}
<span class="ckey">{c.key}</span>
<span class="ctitle">{c.title}</span>
<span class="cmeta">
{#if c.exerciseCount}<span
class={['ex', { full: c.exercisesDone >= c.exerciseCount }]}
>{c.exercisesDone}/{c.exerciseCount}</span
>{/if}
{#if c.prereqs.length}
{@const info = prereqInfo(c)}
<span
class="pre"
title="builds on Ch {compactKeys(c.prereqs)} ({info.done}/{info.list
.length} done)"
style:--c={info.done === info.list.length
? STATUS.done.color
: '#a1a1aa'}>⟵ {compactKeys(c.prereqs)}</span
>
{/if}
</span>
<ChevronDown size={14} class={open[c.key] ? 'chev flipped' : 'chev'} />
</button>
{#if open[c.key]}{@render details(c)}{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if}
{/each}
</div>
{#if view === 'list'}<div class="parts">
{#each parts as p (p.key)}
{#if p.shown.length}
<section class="part card">
<button
class="part-head"
aria-expanded={!collapsedParts[p.key]}
onclick={() => (collapsedParts[p.key] = !collapsedParts[p.key])}
>
<span class="pnum">{p.num ?? 'A–E'}</span>
<span class="ptitle">{p.title}</span>
<span class="pbar">{@render bar(p.counts, p.chapters.length)}</span>
<span class="muted pcount">{p.counts.done}/{p.chapters.length}</span>
<ChevronDown size={16} class={!collapsedParts[p.key] ? 'chev flipped' : 'chev'} />
</button>
{#if !collapsedParts[p.key]}
<ul class="chapters">
{#each p.shown as c (c.key)}
{@const s = statusOf(c)}
<li id="ch-{c.key}" class={['chapter', s, { open: open[c.key] }]}>
<button
class="row"
aria-expanded={!!open[c.key]}
onclick={() => (open[c.key] = !open[c.key])}
>
{@render statusIcon(s)}
<span class="ckey">{c.key}</span>
<span class="ctitle">{c.title}</span>
<span class="cmeta">
{#if c.exerciseCount}<span
class={['ex', { full: c.exercisesDone >= c.exerciseCount }]}
>{c.exercisesDone}/{c.exerciseCount}</span
>{/if}
{#if c.prereqs.length}
{@const info = prereqInfo(c)}
<span
class="pre"
title="builds on Ch {compactKeys(c.prereqs)} ({info.done}/{info.list
.length} done)"
style:--c={info.done === info.list.length
? STATUS.done.color
: '#a1a1aa'}>⟵ {compactKeys(c.prereqs)}</span
>
{/if}
</span>
<ChevronDown size={14} class={open[c.key] ? 'chev flipped' : 'chev'} />
</button>
{#if open[c.key]}{@render details(c)}{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if}
{/each}
</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;