mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
feat(docs): add progress dashboard components and styling
This commit is contained in:
1 parent
5ed33057ed
commit
63f0a81af3
16 files changed
+1266
-430
No files matched your search
@@ -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 hover = 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={hover.ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hover.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) =>
|
||||
hover.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>
|
||||
{hover.node}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user