mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
/**
|
|||
|
|
* 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>
|
||
|
|
);
|
||
|
|
}
|