mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-03 05:27:36 +00:00
feat: refactored docs structure
This commit is contained in:
1 parent
31a1fcfdbd
commit
6247915ae5
222 files changed
+6452
-729
No files matched your search
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* 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);
|
||||
// The cursor reads this, not `drag`: a ref read during render never re-renders.
|
||||
const [panning, setPanning] = useState(false);
|
||||
|
||||
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;
|
||||
setPanning(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);
|
||||
setPanning(false);
|
||||
// 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: panning ? "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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The ten most recently solved problems — LeetCode's "Recent AC" list. Each
|
||||
* row is dated by the problem's first pass (an ET calendar date: the Worker
|
||||
* logs days, not times), read relative to today in ET with the full date on
|
||||
* hover. A row links to the problem's docs page when one exists: the
|
||||
* sync-generated short links (`redirects.ts`) are exactly the pages built.
|
||||
*/
|
||||
import { StatsPending } from "../components/progress/shared";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { useStats, utcDate } from "../lib/stats";
|
||||
import redirects from "../redirects";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
|
||||
|
||||
const ET_TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" });
|
||||
const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export default function RecentSolves() {
|
||||
const load = useStats();
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recently solved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-80 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const today = utcDate(ET_TODAY.format(new Date())).getTime();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recently solved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{load.stats.recent.length === 0 ? (
|
||||
<p className="my-0! text-muted-foreground text-sm">Nothing solved yet.</p>
|
||||
) : (
|
||||
<ol className="my-0! flex flex-col">
|
||||
{load.stats.recent.map((p) => {
|
||||
const date = utcDate(p.date);
|
||||
const page = PAGES.get(`/${p.lc}`);
|
||||
const name = (
|
||||
<>
|
||||
<span className="text-muted-foreground tabular-nums">{p.lc}.</span> {p.title}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={p.lc}
|
||||
className="flex items-center justify-between gap-4 rounded-lg px-4 py-3 text-sm odd:bg-muted"
|
||||
>
|
||||
{page ? (
|
||||
<a href={page} className="truncate font-normal! text-foreground no-underline! hover:underline!">
|
||||
{name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="truncate">{name}</span>
|
||||
)}
|
||||
<time dateTime={p.date} title={FULL.format(date)} className="shrink-0 text-muted-foreground">
|
||||
{RELATIVE.format(Math.round((date.getTime() - today) / DAY_MS), "day")}
|
||||
</time>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Solved-by-difficulty ring — LeetCode's profile gauge. A 270° arc split into
|
||||
* one segment per difficulty, each sized by that difficulty's share of the
|
||||
* catalog; the faint track is the whole set, the solid arc the solved part.
|
||||
* The centre reads the total, or one difficulty while its segment (or its
|
||||
* stat box) is hovered. "Attempting" is the open-miss count: problems whose
|
||||
* latest attempt failed.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Pie, PieChart } from "recharts";
|
||||
import { StatsPending } from "../components/progress/shared";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { type ChartConfig, ChartContainer } from "../components/ui/chart";
|
||||
import { DIFFICULTIES, type Difficulty, useStats } from "../lib/stats";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const chartConfig = {
|
||||
easy: { label: "Easy", color: "var(--chart-1)" },
|
||||
medium: { label: "Med.", color: "var(--chart-2)" },
|
||||
hard: { label: "Hard", color: "var(--chart-3)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
// Static class names, so Tailwind's scanner sees every one (the chart's
|
||||
// --color-* variables exist only inside ChartContainer).
|
||||
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
|
||||
easy: "text-chart-1",
|
||||
medium: "text-chart-2",
|
||||
hard: "text-chart-3",
|
||||
};
|
||||
|
||||
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
|
||||
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
|
||||
const START = 225;
|
||||
const SWEEP = 270;
|
||||
const GAP = 8;
|
||||
|
||||
export default function SolvedRing() {
|
||||
const load = useStats();
|
||||
const [focus, setFocus] = useState<Difficulty | null>(null);
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-52 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = DIFFICULTIES.map(
|
||||
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
|
||||
);
|
||||
const total = rows.reduce((n, r) => n + r.total, 0);
|
||||
const done = rows.reduce((n, r) => n + r.done, 0);
|
||||
const span = SWEEP - GAP * (rows.length - 1);
|
||||
|
||||
const segments: ((typeof rows)[number] & { from: number; to: number; solvedTo: number })[] = [];
|
||||
let cursor = START;
|
||||
for (const r of rows) {
|
||||
const size = total > 0 ? (span * r.total) / total : 0;
|
||||
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
|
||||
segments.push({ ...r, from: cursor, to: cursor - size, solvedTo: cursor - solved });
|
||||
cursor -= size + GAP;
|
||||
}
|
||||
|
||||
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
|
||||
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
|
||||
<div className="relative w-full max-w-56 shrink-0">
|
||||
<ChartContainer config={chartConfig} className="aspect-square w-full">
|
||||
<PieChart>
|
||||
{segments.map((s) => (
|
||||
<Pie
|
||||
key={`${s.difficulty}-track`}
|
||||
{...arc}
|
||||
data={[{ value: 1 }]}
|
||||
startAngle={s.from}
|
||||
endAngle={s.to}
|
||||
fill={`var(--color-${s.difficulty})`}
|
||||
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
|
||||
isAnimationActive={false}
|
||||
onMouseEnter={() => setFocus(s.difficulty)}
|
||||
onMouseLeave={() => setFocus(null)}
|
||||
/>
|
||||
))}
|
||||
{segments
|
||||
.filter((s) => s.done > 0)
|
||||
.map((s) => (
|
||||
<Pie
|
||||
key={`${s.difficulty}-solved`}
|
||||
{...arc}
|
||||
data={[{ value: 1 }]}
|
||||
startAngle={s.from}
|
||||
endAngle={s.solvedTo}
|
||||
fill={`var(--color-${s.difficulty})`}
|
||||
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
|
||||
onMouseEnter={() => setFocus(s.difficulty)}
|
||||
onMouseLeave={() => setFocus(null)}
|
||||
/>
|
||||
))}
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
|
||||
<div className="flex items-baseline">
|
||||
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
|
||||
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
|
||||
</div>
|
||||
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
|
||||
{focused ? chartConfig[focused.difficulty].label : "Solved"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
|
||||
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.difficulty}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
|
||||
focus && focus !== r.difficulty && "opacity-50",
|
||||
)}
|
||||
onPointerEnter={() => setFocus(r.difficulty)}
|
||||
onPointerLeave={() => setFocus(null)}
|
||||
>
|
||||
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
|
||||
{chartConfig[r.difficulty].label}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums">
|
||||
{r.done}/{r.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* This calendar year by difficulty — LeetCode's solved/submissions bar chart,
|
||||
* sharing its row with the heatmap. Stacked Easy / Med. / Hard bars per month
|
||||
* or per week (Monday-start, like the heatmap rows); "Solved" counts each
|
||||
* problem once, on its first-pass date (the date `recent` uses), while
|
||||
* "Submissions" counts every logged attempt. The totals under the chart are
|
||||
* the year's, in the selected mode. Buckets are cut from `year`, which always
|
||||
* covers January 1 to today.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { StatsPending } from "../components/progress/shared";
|
||||
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../components/ui/card";
|
||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../components/ui/chart";
|
||||
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
|
||||
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../lib/stats";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const chartConfig = {
|
||||
easy: { label: "Easy", color: "var(--chart-1)" },
|
||||
medium: { label: "Med.", color: "var(--chart-2)" },
|
||||
hard: { label: "Hard", color: "var(--chart-3)" },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
// Static class names, so Tailwind's scanner sees every one.
|
||||
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
|
||||
easy: "text-chart-1",
|
||||
medium: "text-chart-2",
|
||||
hard: "text-chart-3",
|
||||
};
|
||||
|
||||
type Mode = "solved" | "attempts";
|
||||
type Grain = "month" | "week";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
||||
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
|
||||
const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
||||
|
||||
interface Bucket extends Record<Difficulty, number> {
|
||||
index: number;
|
||||
/** Axis tick: the month the bucket starts in. */
|
||||
tick: string;
|
||||
/** Tooltip heading. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** The year's buckets, every one present (future ones empty), filled from `days`. */
|
||||
function buckets(days: Day[], year: number, grain: Grain, mode: Mode): Bucket[] {
|
||||
const jan1 = Date.UTC(year, 0, 1);
|
||||
const lead = (new Date(jan1).getUTCDay() + 6) % 7; // Jan 1's Monday-based weekday
|
||||
const length = Math.round((Date.UTC(year + 1, 0, 1) - jan1) / DAY_MS);
|
||||
const out: Bucket[] = [];
|
||||
if (grain === "month") {
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const start = new Date(Date.UTC(year, m, 1));
|
||||
out.push({ index: m, tick: MONTH.format(start), label: MONTH_YEAR.format(start), easy: 0, medium: 0, hard: 0 });
|
||||
}
|
||||
} else {
|
||||
const weeks = Math.floor((length - 1 + lead) / 7) + 1;
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
const start = new Date(Math.max(jan1, jan1 + (w * 7 - lead) * DAY_MS));
|
||||
const end = new Date(Math.min(jan1 + (length - 1) * DAY_MS, jan1 + (w * 7 - lead + 6) * DAY_MS));
|
||||
out.push({
|
||||
index: w,
|
||||
tick: MONTH.format(start),
|
||||
label: `${SHORT.format(start)} – ${SHORT.format(end)}`,
|
||||
easy: 0,
|
||||
medium: 0,
|
||||
hard: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const day of days) {
|
||||
const date = utcDate(day.date);
|
||||
if (date.getUTCFullYear() !== year) continue;
|
||||
const i = grain === "month" ? date.getUTCMonth() : Math.floor(((date.getTime() - jan1) / DAY_MS + lead) / 7);
|
||||
const bucket = out[i];
|
||||
if (!bucket) continue;
|
||||
for (const d of DIFFICULTIES) bucket[d] += day[mode][d];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function SubmissionChart() {
|
||||
const load = useStats();
|
||||
const [mode, setMode] = useState<Mode>("solved");
|
||||
const [grain, setGrain] = useState<Grain>("month");
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const year = utcDate(load.stats.year.at(-1)?.date ?? "1970-01-01").getUTCFullYear();
|
||||
const data = buckets(load.stats.year, year, grain, mode);
|
||||
const totals = Object.fromEntries(
|
||||
DIFFICULTIES.map((d) => [d, data.reduce((n, b) => n + b[d], 0)]),
|
||||
);
|
||||
const peak = Math.max(...data.map((b) => b.easy + b.medium + b.hard));
|
||||
// A round ceiling for the one labelled gridline, as LeetCode draws it.
|
||||
const top = peak <= 5 ? 5 : Math.ceil(peak / 10) * 10;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
spacing={1}
|
||||
value={mode}
|
||||
onValueChange={(value: string) => value && setMode(value as Mode)}
|
||||
className="rounded-full bg-muted p-0.5"
|
||||
aria-label="Count"
|
||||
>
|
||||
<ToggleGroupItem value="solved" className="h-7 rounded-full px-2.5 text-xs">
|
||||
Solved
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="attempts" className="h-7 rounded-full px-2.5 text-xs">
|
||||
Submissions
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div className="flex items-center">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
spacing={1}
|
||||
value={grain}
|
||||
onValueChange={(value: string) => value && setGrain(value as Grain)}
|
||||
className="rounded-full bg-muted p-0.5"
|
||||
aria-label="Bucket size"
|
||||
>
|
||||
<ToggleGroupItem value="month" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per month">
|
||||
M
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="week" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per week">
|
||||
W
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
|
||||
<BarChart accessibilityLayer data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={6}
|
||||
ticks={[0, data.length - 1]}
|
||||
interval={0}
|
||||
// First label anchored at its start, last at its end, so neither
|
||||
// is clipped when a week band is only a few pixels wide.
|
||||
tick={({ x, y, payload }) => (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={10}
|
||||
textAnchor={payload.value === 0 ? "start" : "end"}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
{data[Number(payload.value)]?.tick}
|
||||
</text>
|
||||
)}
|
||||
/>
|
||||
<YAxis
|
||||
orientation="right"
|
||||
domain={[0, top]}
|
||||
ticks={[0, top]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={28}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
// Recharts applies itemSorter only in its default tooltip; this
|
||||
// content gets the stack's bottom-up order, so flip it to read
|
||||
// top-down, Easy first, like the bars.
|
||||
content={({ active, label, payload }) => (
|
||||
<ChartTooltipContent
|
||||
active={active}
|
||||
label={label}
|
||||
payload={[...(payload ?? [])].reverse()}
|
||||
labelFormatter={(_, items) => items[0]?.payload?.label}
|
||||
indicator="dot"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{(["hard", "medium", "easy"] as const).map((d) => (
|
||||
<Bar
|
||||
key={d}
|
||||
dataKey={d}
|
||||
stackId="solved"
|
||||
fill={`var(--color-${d})`}
|
||||
radius={2}
|
||||
maxBarSize={grain === "month" ? 10 : 5}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="gap-4 text-sm">
|
||||
{DIFFICULTIES.map((d) => (
|
||||
<span key={d} className="flex items-baseline gap-1">
|
||||
<span className={DIFFICULTY_TEXT[d]}>{chartConfig[d].label}</span>
|
||||
<span className="text-muted-foreground tabular-nums">{totals[d]}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto text-muted-foreground tabular-nums">{year}</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The last six months of attempts — LeetCode's calendar heatmap, halved to
|
||||
* share its row with the submissions chart: one cell per day, weeks as
|
||||
* columns, each month its own block, and the window's attempt count, active
|
||||
* days and longest streak above it. The figures are counted from exactly the
|
||||
* cells drawn, so they cannot disagree with the grid. The colour ramp is the
|
||||
* README/email heatmap's (theme.css `--heat-0..4`, level = attempts capped at
|
||||
* 4) and rows start on Monday like it, so the three heatmaps read the same.
|
||||
* Drawn as SVG in a viewBox, so it scales to its column instead of scrolling.
|
||||
*/
|
||||
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { type Day, useStats, utcDate } from "../lib/stats";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const MONTHS = 6;
|
||||
const CELL = 10;
|
||||
const STEP = 13; // cell + gap
|
||||
const MONTH_GAP = 8;
|
||||
const LABEL_H = 16;
|
||||
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
|
||||
|
||||
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
||||
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||||
|
||||
interface Cell {
|
||||
x: number;
|
||||
y: number;
|
||||
date: Date;
|
||||
attempts: number;
|
||||
passes: number;
|
||||
}
|
||||
|
||||
interface Block {
|
||||
label: string;
|
||||
x: number;
|
||||
width: number;
|
||||
cells: Cell[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Month blocks laid left to right over the last MONTHS calendar months. Within
|
||||
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
|
||||
* from the 1st, so a month starting on a Thursday opens with a short column.
|
||||
*/
|
||||
function layout(year: Day[]): { blocks: Block[]; width: number } {
|
||||
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
|
||||
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
|
||||
const blocks: Block[] = [];
|
||||
let x = 0;
|
||||
for (const day of year) {
|
||||
const date = utcDate(day.date);
|
||||
if (date.getTime() < from) continue;
|
||||
let block = blocks.at(-1);
|
||||
if (date.getUTCDate() === 1 || !block) {
|
||||
if (block) x += block.width + MONTH_GAP;
|
||||
block = { label: MONTH.format(date), x, width: 0, cells: [] };
|
||||
blocks.push(block);
|
||||
}
|
||||
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
||||
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
|
||||
const col = Math.floor(index / 7);
|
||||
block.cells.push({
|
||||
x: x + col * STEP,
|
||||
y: (index % 7) * STEP,
|
||||
date,
|
||||
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
|
||||
passes: day.passes,
|
||||
});
|
||||
block.width = col * STEP + CELL;
|
||||
}
|
||||
const end = blocks.at(-1);
|
||||
return { blocks, width: end ? end.x + end.width : 0 };
|
||||
}
|
||||
|
||||
export default function SubmissionHeatmap() {
|
||||
const load = useStats();
|
||||
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { blocks, width } = layout(load.stats.year);
|
||||
const cells = blocks.flatMap((b) => b.cells);
|
||||
let run = 0;
|
||||
let streak = 0;
|
||||
for (const c of cells) {
|
||||
run = c.attempts > 0 ? run + 1 : 0;
|
||||
streak = Math.max(streak, run);
|
||||
}
|
||||
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-baseline gap-2">
|
||||
<span className="font-semibold text-2xl tabular-nums">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
|
||||
<span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex gap-4">
|
||||
<span>
|
||||
Active days:{" "}
|
||||
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
|
||||
</span>
|
||||
<span>
|
||||
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 items-center">
|
||||
<div ref={ref} className="relative w-full" onPointerLeave={hide}>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label={`Attempts per day over the past ${MONTHS} months`}
|
||||
>
|
||||
{blocks.map((block) => (
|
||||
<g key={`${block.label}-${block.x}`}>
|
||||
{block.cells.map((cell) => (
|
||||
<rect
|
||||
key={cell.x * 1000 + cell.y}
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
rx={2}
|
||||
fill={HEAT[Math.min(cell.attempts, 4)]}
|
||||
onPointerMove={(event) =>
|
||||
show(
|
||||
event,
|
||||
<>
|
||||
<div className="font-medium">{DAY.format(cell.date)}</div>
|
||||
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
|
||||
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
|
||||
</>,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<text
|
||||
x={block.x + block.width / 2}
|
||||
y={height - 3}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[9px]"
|
||||
>
|
||||
{block.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{node}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The campaign's topics as packed bubbles — LeetCode's "All Topics" view,
|
||||
* over our curriculum instead of LeetCode's tags. A bubble's area is the
|
||||
* topic's problem count; it fills from the bottom like a glass as problems
|
||||
* are solved, and turns solid once every one is. A topic the schedule has not
|
||||
* reached yet is dimmed. Packing is d3-hierarchy's front-chain (packSiblings,
|
||||
* largest first, so the layout is deterministic), drawn as SVG in a viewBox:
|
||||
* Recharts has no packed layout.
|
||||
*/
|
||||
import { packEnclose, packSiblings } from "d3-hierarchy";
|
||||
import { useId } from "react";
|
||||
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { type Stats, useStats } from "../lib/stats";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const PAD = 2; // gap between bubbles, in layout units
|
||||
const UNIT = 12; // radius of a one-problem bubble
|
||||
|
||||
type Topic = Stats["topics"][number];
|
||||
type Bubble = Topic & { x: number; y: number; r: number };
|
||||
|
||||
/** Split a name across lines of at most ~10 characters, never breaking a word. */
|
||||
function lines(name: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const word of name.split(" ")) {
|
||||
const last = out.at(-1);
|
||||
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
|
||||
else out.push(word);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function TopicBubbles() {
|
||||
const load = useStats();
|
||||
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
|
||||
const clip = useId().replace(/:/g, "");
|
||||
|
||||
if (load.phase !== "ready") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Topics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatsPending load={load} className="aspect-square w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const topics = load.stats.topics.filter((t) => t.total > 0);
|
||||
const circles: Bubble[] = topics
|
||||
.slice()
|
||||
.sort((a, b) => b.total - a.total || a.issue - b.issue)
|
||||
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
|
||||
packSiblings(circles);
|
||||
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
|
||||
const solved = topics.reduce((n, t) => n + t.done, 0);
|
||||
const total = topics.reduce((n, t) => n + t.total, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Topics</CardTitle>
|
||||
<CardDescription>
|
||||
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hide}>
|
||||
<svg
|
||||
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="Campaign topics sized by problem count and filled by problems solved"
|
||||
>
|
||||
{circles.map((c) => {
|
||||
const r = c.r - PAD;
|
||||
const ratio = c.total > 0 ? c.done / c.total : 0;
|
||||
const complete = c.done >= c.total;
|
||||
const words = lines(c.name);
|
||||
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
|
||||
const id = `${clip}-${c.issue}`;
|
||||
return (
|
||||
<g
|
||||
key={c.issue}
|
||||
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
|
||||
onPointerMove={(event) =>
|
||||
show(
|
||||
event,
|
||||
<>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
|
||||
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
|
||||
</>,
|
||||
)
|
||||
}
|
||||
>
|
||||
<clipPath id={id}>
|
||||
<circle cx={c.x} cy={c.y} r={r} />
|
||||
</clipPath>
|
||||
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
|
||||
<rect
|
||||
x={c.x - r}
|
||||
y={c.y + r - 2 * r * ratio}
|
||||
width={2 * r}
|
||||
height={2 * r * ratio}
|
||||
clipPath={`url(#${id})`}
|
||||
fill="var(--chart-4)"
|
||||
fillOpacity={complete ? 1 : 0.35}
|
||||
/>
|
||||
<text
|
||||
x={c.x}
|
||||
y={c.y - ((words.length - 1) * size * 1.15) / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
clipPath={`url(#${id})`}
|
||||
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
|
||||
style={{ fontSize: size }}
|
||||
>
|
||||
{words.map((w, i) => (
|
||||
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
|
||||
{w}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{node}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user