/** * Dependency spine island — the "how the 24 topics build on each other" map * from the README, redrawn as data (nodes on a 7×6 grid, edges as SVG path * strings) so it is themed, zoomable, and clickable. * * - Wheel over the figure zooms about the cursor (trackpad pinch arrives as a * ctrl+wheel and behaves the same); dragging pans; the toolbar and +/-/0 * keys do the same. Zooming OUT at the minimum scale is deliberately left * to the page, so a reader scrolling past the map is never trapped in it. * - A node is a link to its topic. Routes come from Blume's own navigation * endpoint at mount: the topic's landing page when the folder has an * `index.mdx`, else its first solution page. A topic with no pages yet is * drawn inert rather than linked to a 404, so the map stays honest as the * campaign fills in without a rebuild of this file. * * Invariants: the first frame is the figure at HOME_ZOOM, centred, with no * links (server and client render identically); the geometry is the README * SVG's, to the pixel, so `viewBox` maths below is in that 1472×648 space. */ import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react"; import { withBase } from "blume/components/islands/base-path.ts"; export const client = "visible"; // ── figure data (README dependency-spine geometry) ─────────────── const W = 1472; const H = 648; const MAX_ZOOM = 6; const NODE_W = 144; const NODE_H = 48; const COL_X = [56, 248, 472, 664, 888, 1080, 1272] as const; const ROW_Y = [72, 144, 216, 288, 416, 488] as const; type Kind = "entry" | "topic" | "focal"; interface Topic { n: number; name: string; sub?: string; /** Sync's group slug for the README topic name — the docs folder. */ slug: string; kind: Kind; col: number; row: number; } const TOPICS: Topic[] = [ { n: 5, name: "Binary Search", slug: "binary-search", kind: "entry", col: 0, row: 0 }, { n: 3, name: "Two Pointers", slug: "two-pointers", kind: "entry", col: 0, row: 1 }, { n: 4, name: "Sliding Window", slug: "sliding-window", kind: "topic", col: 1, row: 1 }, { n: 6, name: "Stack", slug: "stack", kind: "entry", col: 0, row: 2 }, { n: 7, name: "Monotonic Stack", slug: "monotonic-stack", kind: "topic", col: 1, row: 2 }, { n: 1, name: "Hash Lookup", slug: "hash-table", kind: "entry", col: 0, row: 3 }, { n: 2, name: "Prefix Sum", slug: "prefix-sum", kind: "topic", col: 1, row: 3 }, { n: 11, name: "Tree DFS", slug: "tree-dfs", kind: "focal", col: 2, row: 0 }, { n: 13, name: "Binary Search Tree", slug: "binary-search-tree", kind: "topic", col: 2, row: 1 }, { n: 14, name: "Heap", sub: "priority queue", slug: "heap-priority-queue", kind: "topic", col: 3, row: 1 }, { n: 15, name: "Trie", slug: "trie", kind: "topic", col: 3, row: 2 }, { n: 12, name: "Tree BFS", slug: "tree-bfs", kind: "entry", col: 2, row: 3 }, { n: 8, name: "Linked List", slug: "linked-list", kind: "entry", col: 2, row: 4 }, { n: 9, name: "Hybrid Structures", slug: "hybrid-structures", kind: "topic", col: 3, row: 4 }, { n: 10, name: "Matrix Index Math", slug: "matrix-index-math", kind: "topic", col: 2, row: 5 }, { n: 16, name: "Graph DFS", slug: "graph-dfs", kind: "topic", col: 4, row: 0 }, { n: 18, name: "Topological Sort", slug: "topological-sort", kind: "topic", col: 5, row: 0 }, { n: 19, name: "Union-Find", slug: "union-find", kind: "topic", col: 4, row: 1 }, { n: 20, name: "Backtracking", slug: "backtracking", kind: "topic", col: 5, row: 1 }, { n: 21, name: "1-D DP", sub: "dynamic programming", slug: "1-d-dynamic-programming", kind: "focal", col: 5, row: 2 }, { n: 22, name: "Multi-D / Grid DP", slug: "multi-d-grid-dp", kind: "topic", col: 6, row: 2 }, { n: 17, name: "Graph BFS", slug: "graph-bfs", kind: "topic", col: 4, row: 3 }, { n: 23, name: "Greedy", slug: "greedy", kind: "topic", col: 5, row: 3 }, { n: 24, name: "Intervals", slug: "intervals", kind: "topic", col: 6, row: 3 }, ]; /** "builds on" arrows, source → target, in the figure's coordinate space. */ const EDGES: [string, string][] = [ ["3 → 4", "M200,168 H248"], ["6 → 7", "M200,240 H248"], ["1 → 2", "M200,312 H248"], ["8 → 9", "M616,440 H664"], ["16 → 18", "M1032,96 H1080"], ["21 → 22", "M1224,240 H1272"], ["23 → 24", "M1224,312 H1272"], ["11 → 16", "M616,84 H888"], ["12 → 17", "M616,312 H888"], ["11 → 13", "M544,120 V144"], ["16 → 19", "M960,120 V144"], ["18 → 20", "M1152,120 V144"], ["20 → 21", "M1152,192 V216"], ["21 → 23", "M1152,264 V288"], ["5 → 13", "M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472"], ["11 → 14", "M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664"], ["11 → 15", "M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664"], ["4 → 10", "M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472"], ["1 → 9", "M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464"], ]; const ZONES: [string, number, number, number, number, number][] = [ // label, x, y, w, h, label plate width ["PHASE I — LINEAR STRUCTURES", 40, 40, 368, 312, 168], ["PHASE III — HIERARCHICAL", 456, 40, 368, 312, 152], ["PHASE II — NODAL & GRID", 456, 384, 368, 168, 144], ["PHASES IV–V — RELATIONAL & DECISION-SPACE", 872, 40, 560, 312, 264], ]; // ── theme ──────────────────────────────────────────────────────── // Everything is a Blume token so the map follows the site's light/dark // scheme instead of the README's fixed black; tints go through color-mix. const fg = "var(--blume-foreground)"; const muted = "var(--blume-muted-foreground)"; const accent = "var(--blume-accent)"; const tint = (color: string, pct: number) => `color-mix(in oklab, ${color} ${pct}%, transparent)`; const NODE_STYLE: Record = { entry: { fill: tint(muted, 10), stroke: tint(muted, 70), strokeWidth: 1, tag: muted }, topic: { fill: tint(fg, 4), stroke: fg, strokeWidth: 1, tag: muted }, focal: { fill: tint(accent, 12), stroke: accent, strokeWidth: 1.2, tag: accent }, }; const mono: CSSProperties = { fontFamily: "var(--blume-font-mono, ui-monospace, monospace)" }; const sans: CSSProperties = { fontFamily: "var(--blume-font-sans, system-ui, sans-serif)" }; const toolButton: CSSProperties = { width: "1.75rem", height: "1.75rem", border: "1px solid var(--blume-border)", borderRadius: "9999px", background: "var(--blume-background)", color: muted, font: "inherit", lineHeight: 1, cursor: "pointer", }; // ── navigation lookup ──────────────────────────────────────────── interface NavGroup { kind: "group"; path: string; route?: string | null; children: unknown[]; } const isGroup = (v: unknown): v is NavGroup => typeof v === "object" && v !== null && (v as NavGroup).kind === "group" && typeof (v as NavGroup).path === "string" && Array.isArray((v as NavGroup).children); /** The first page route under a sidebar entry, depth-first. */ const firstRoute = (items: unknown[]): string | null => { for (const item of items) { if (typeof item !== "object" || item === null) continue; const v = item as { route?: unknown; children?: unknown }; if (typeof v.route === "string") return v.route; if (Array.isArray(v.children)) { const inner = firstRoute(v.children); if (inner) return inner; } } return null; }; /** folder path (`/tree-dfs`) → the route a node should open. */ const topicRoutes = (nav: unknown): Record => { const out: Record = {}; const sidebar = (nav as { sidebar?: unknown })?.sidebar; if (!Array.isArray(sidebar)) return out; for (const entry of sidebar) { if (!isGroup(entry)) continue; const route = entry.route ?? firstRoute(entry.children); if (route) out[entry.path] = route; } return out; }; // ── component ──────────────────────────────────────────────────── interface ViewBox { x: number; y: number; w: number; h: number } /** The resting view — 2× on the centre so node labels read at the content width; 1× stays reachable by zooming out. */ const HOME_ZOOM = 2; const HOME: ViewBox = { x: (W - W / HOME_ZOOM) / 2, y: (H - H / HOME_ZOOM) / 2, w: W / HOME_ZOOM, h: H / HOME_ZOOM }; const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi); export default function DependencySpine() { const svgRef = useRef(null); const [vb, setVbState] = useState(HOME); // The native wheel listener and back-to-back events read the latest view // from the ref, which is written synchronously — state alone lags a render. const vbRef = useRef(vb); const setVb = (next: ViewBox) => { vbRef.current = next; setVbState(next); }; const [routes, setRoutes] = useState | null>(null); const drag = useRef<{ x: number; y: number; moved: boolean } | null>(null); useEffect(() => { let live = true; fetch(withBase("/api/docs/navigation.json")) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) .then((nav) => live && setRoutes(topicRoutes(nav))) .catch(() => live && setRoutes({})); return () => { live = false; }; }, []); /** Zoom by `factor` (>1 in) about a client-space point; omitted = centre. */ const zoomAt = (factor: number, clientX?: number, clientY?: number) => { const rect = svgRef.current?.getBoundingClientRect(); const cur = vbRef.current; if (!rect) return; const px = clientX === undefined ? cur.x + cur.w / 2 : cur.x + ((clientX - rect.left) / rect.width) * cur.w; const py = clientY === undefined ? cur.y + cur.h / 2 : cur.y + ((clientY - rect.top) / rect.height) * cur.h; const w = clamp(cur.w / factor, W / MAX_ZOOM, W); const f = cur.w / w; const h = H * (w / W); setVb({ x: clamp(px - (px - cur.x) / f, 0, W - w), y: clamp(py - (py - cur.y) / f, 0, H - h), w, h }); }; // React registers `wheel` as passive, so preventDefault has to go native. useEffect(() => { const svg = svgRef.current; if (!svg) return; const onWheel = (e: WheelEvent) => { if (e.deltaY > 0 && vbRef.current.w >= W) return; // fully zoomed out: let the page scroll e.preventDefault(); zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX, e.clientY); }; svg.addEventListener("wheel", onWheel, { passive: false }); return () => svg.removeEventListener("wheel", onWheel); }, []); const onPointerDown = (e: PointerEvent) => { if (e.button !== 0) return; drag.current = { x: e.clientX, y: e.clientY, moved: false }; }; const onPointerMove = (e: PointerEvent) => { const d = drag.current; const rect = svgRef.current?.getBoundingClientRect(); if (!d || !rect) return; const dx = e.clientX - d.x; const dy = e.clientY - d.y; if (!d.moved && Math.hypot(dx, dy) < 4) return; // a click, not a drag if (!d.moved) { // Capture only once it is a drag: capturing on pointerdown would retarget // pointerup (and so the click) to the , and a node's link would never fire. d.moved = true; e.currentTarget.setPointerCapture(e.pointerId); } d.x = e.clientX; d.y = e.clientY; const cur = vbRef.current; const scale = cur.w / rect.width; setVb({ ...cur, x: clamp(cur.x - dx * scale, 0, W - cur.w), y: clamp(cur.y - dy * scale, 0, H - cur.h) }); }; const onPointerUp = (e: PointerEvent) => { if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); // Cleared on the next tick so a node's click handler can still see `moved`. setTimeout(() => { drag.current = null; }, 0); }; const zoom = W / vb.w; const atHome = vb.x === HOME.x && vb.y === HOME.y && vb.w === HOME.w; return (
{ if (e.key === "+" || e.key === "=") zoomAt(1.25); else if (e.key === "-") zoomAt(0.8); else if (e.key === "0") setVb(HOME); else return; e.preventDefault(); }} tabIndex={0} >
scroll to zoom · drag to pan · click a topic {zoom.toFixed(1)}×
1.001 ? "grab" : "default", userSelect: "none" }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onDoubleClick={() => setVb(HOME)} > LeetCode topic dependency spine Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs. {ZONES.map(([label, x, y, w, h, plate]) => ( {label} ))} {EDGES.map(([id, d]) => ( {id} ))} {TOPICS.map((t) => { const x = COL_X[t.col]!; const y = ROW_Y[t.row]!; const s = NODE_STYLE[t.kind]; const route = routes?.[`/${t.slug}`]; const tag = String(t.n).padStart(2, "0"); const body = ( <> {route ? `${tag} · ${t.name} — open topic` : `${tag} · ${t.name} — no pages yet`} {tag} {t.name} {t.sub && {t.sub}} ); return route ? ( { if (drag.current?.moved) e.preventDefault(); }} > {body} ) : ( {body} ); })} LEGEND ENTRY — NO PREREQS TOPIC FOCAL HUB BUILDS ON PHASE ZONE NO PAGES YET ); }