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:
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user