mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
146 lines
5.8 KiB
TypeScript
146 lines
5.8 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|