Files
leetcode/apps/docs/islands/DependencySpine.tsx
T

377 lines
19 KiB
TypeScript
Raw Normal View History

/**
* 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<Kind, { fill: string; stroke: string; strokeWidth: number; tag: string }> = {
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<string, string> => {
const out: Record<string, string> = {};
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 — 1.6× on the centre so node labels read at the content width; 1× stays reachable by zooming out. */
const HOME_ZOOM = 1.6;
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<SVGSVGElement>(null);
const [vb, setVbState] = useState<ViewBox>(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<Record<string, string> | 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<SVGSVGElement>) => {
if (e.button !== 0) return;
drag.current = { x: e.clientX, y: e.clientY, moved: false };
};
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
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 <svg>, 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<SVGSVGElement>) => {
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 (
<figure
className="not-prose"
style={{ margin: "1.5rem 0", border: "1px solid var(--blume-border)", borderRadius: "var(--blume-radius)", background: tint("var(--blume-muted)", 30), overflow: "hidden" }}
aria-label="Dependency spine: how the 24 topics build on each other"
onKeyDown={(e) => {
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}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--blume-border)" }}>
<span style={{ ...mono, fontSize: "0.7rem", letterSpacing: "0.12em", textTransform: "uppercase", color: muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
scroll to zoom · drag to pan · click a topic
</span>
<span style={{ display: "flex", alignItems: "center", gap: "0.35rem", flexShrink: 0 }}>
<span style={{ ...mono, fontSize: "0.7rem", color: muted, minWidth: "4ch", textAlign: "right", whiteSpace: "nowrap" }}>{zoom.toFixed(1)}×</span>
<button type="button" style={toolButton} aria-label="Zoom out" onClick={() => zoomAt(0.8)} disabled={zoom <= 1.001}>−</button>
<button type="button" style={toolButton} aria-label="Zoom in" onClick={() => zoomAt(1.25)} disabled={zoom >= MAX_ZOOM - 0.001}>+</button>
<button type="button" style={toolButton} aria-label="Reset zoom" onClick={() => setVb(HOME)} disabled={atHome}>⟲</button>
</span>
</div>
<svg
ref={svgRef}
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="dependency-spine-title dependency-spine-desc"
style={{ display: "block", width: "100%", height: "auto", touchAction: "none", cursor: drag.current?.moved ? "grabbing" : zoom > 1.001 ? "grab" : "default", userSelect: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={() => setVb(HOME)}
>
<title id="dependency-spine-title">LeetCode topic dependency spine</title>
<desc id="dependency-spine-desc">
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.
</desc>
<defs>
<marker id="spine-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" style={{ fill: muted }} />
</marker>
</defs>
{ZONES.map(([label, x, y, w, h, plate]) => (
<g key={label}>
<rect x={x} y={y} width={w} height={h} rx={8} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
<rect x={x + 16} y={y + 4} width={plate} height={12} rx={2} style={{ fill: "var(--blume-background)" }} />
<text x={x + 20} y={y + 13} fontSize={7} letterSpacing="0.14em" style={{ ...mono, fill: tint(fg, 40) }}>{label}</text>
</g>
))}
{EDGES.map(([id, d]) => (
<path key={id} d={d} fill="none" strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }}>
<title>{id}</title>
</path>
))}
{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 = (
<>
<title>{route ? `${tag} · ${t.name} — open topic` : `${tag} · ${t.name} — no pages yet`}</title>
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: "var(--blume-background)" }} />
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: s.fill, stroke: s.stroke, strokeWidth: s.strokeWidth }} />
<rect x={x + 8} y={y + 6} width={24} height={12} rx={2} fill="transparent" style={{ stroke: tint(s.tag, 50), strokeWidth: 0.8 }} />
<text x={x + 20} y={y + 15} fontSize={7} textAnchor="middle" letterSpacing="0.08em" style={{ ...mono, fill: s.tag }}>{tag}</text>
<text x={x + NODE_W / 2} y={y + (t.sub ? 26 : 32)} fontSize={12} fontWeight={600} textAnchor="middle" style={{ ...sans, fill: fg }}>{t.name}</text>
{t.sub && <text x={x + NODE_W / 2} y={y + 40} fontSize={9} textAnchor="middle" style={{ ...mono, fill: muted }}>{t.sub}</text>}
</>
);
return route ? (
<a
key={t.n}
href={withBase(route)}
className="spine-node"
style={{ cursor: "pointer", textDecoration: "none" }}
onClick={(e) => { if (drag.current?.moved) e.preventDefault(); }}
>
{body}
</a>
) : (
<g key={t.n} style={{ opacity: routes ? 0.55 : 1 }} aria-disabled="true">{body}</g>
);
})}
<line x1={40} y1={600} x2={1432} y2={600} strokeWidth={0.8} style={{ stroke: tint(fg, 10) }} />
<text x={40} y={620} fontSize={8} letterSpacing="0.14em" style={{ ...mono, fill: muted }}>LEGEND</text>
<rect x={160} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.entry.fill, stroke: NODE_STYLE.entry.stroke, strokeWidth: 1 }} />
<text x={188} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>ENTRY — NO PREREQS</text>
<rect x={360} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1 }} />
<text x={388} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>TOPIC</text>
<rect x={480} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.focal.fill, stroke: NODE_STYLE.focal.stroke, strokeWidth: 1.2 }} />
<text x={508} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>FOCAL HUB</text>
<line x1={624} y1={614} x2={656} y2={614} strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }} />
<text x={668} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>BUILDS ON</text>
<rect x={792} y={608} width={20} height={12} rx={2} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
<text x={820} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>PHASE ZONE</text>
<rect x={1000} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1, opacity: 0.55 }} />
<text x={1028} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>NO PAGES YET</text>
</svg>
<style>{`.spine-node:hover rect:nth-of-type(2), .spine-node:focus-visible rect:nth-of-type(2) { stroke: ${accent}; stroke-width: 1.6; } .spine-node:focus { outline: none; }`}</style>
</figure>
);
}