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

145 lines
5.8 KiB
TypeScript
Raw Normal View History

/**
* 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>
);
}