/** * 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; import { type Stats, useStats } from "../../lib/stats"; import { cn } from "../../lib/utils"; import { StatsPending, TipRow, useHoverTip } from "./shared"; 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(); const clip = useId().replace(/:/g, ""); if (load.phase !== "ready") { return ( Topics ); } 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 ( Topics {solved} of {total} problems across {topics.length} topics · size is problem count, fill is 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 ( show( event, <>
{c.name}
{!c.taught &&
Not reached yet
} , ) } > {words.map((w, i) => ( {w} ))} ); })} {node}
); }