mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-03 05:27:36 +00:00
139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
/**
|
|||
|
|
* The past year of attempts — LeetCode's calendar heatmap: one cell per day,
|
||
|
|
* weeks as columns, each month its own block with a gap between, and the
|
||
|
|
* year's attempt count, active days and longest streak above it. The colour
|
||
|
|
* ramp is the README/email heatmap's (theme.css `--heat-0..4`, level =
|
||
|
|
* attempts capped at 4) and rows start on Monday like it, so the three
|
||
|
|
* heatmaps read the same. Drawn as SVG in a viewBox, so it scales to the
|
||
|
|
* column instead of scrolling.
|
||
|
|
*/
|
||
|
|
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
|
||
|
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||
|
|
import { type Stats, useStats, utcDate } from "../lib/stats";
|
||
|
|
|
||
|
|
export const client = "load";
|
||
|
|
|
||
|
|
const CELL = 10;
|
||
|
|
const STEP = 13; // cell + gap
|
||
|
|
const MONTH_GAP = 8;
|
||
|
|
const LABEL_H = 16;
|
||
|
|
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
|
||
|
|
|
||
|
|
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
||
|
|
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||
|
|
|
||
|
|
interface Block {
|
||
|
|
label: string;
|
||
|
|
x: number;
|
||
|
|
width: number;
|
||
|
|
cells: { x: number; y: number; date: Date; attempts: number; passes: number }[];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Month blocks laid left to right. Within a block a day sits at row =
|
||
|
|
* weekday (Mon = 0) and column = its week counted from the block's first day,
|
||
|
|
* so a month starting on a Thursday opens with a short first column.
|
||
|
|
*/
|
||
|
|
function layout(days: Stats["year"]["days"]): { blocks: Block[]; width: number } {
|
||
|
|
const blocks: Block[] = [];
|
||
|
|
let x = 0;
|
||
|
|
let month = -1;
|
||
|
|
let block: Block | undefined;
|
||
|
|
let lead = 0;
|
||
|
|
let index = 0;
|
||
|
|
for (const day of days) {
|
||
|
|
const date = utcDate(day.date);
|
||
|
|
if (date.getUTCMonth() !== month) {
|
||
|
|
if (block) x += block.width + MONTH_GAP;
|
||
|
|
month = date.getUTCMonth();
|
||
|
|
lead = (date.getUTCDay() + 6) % 7;
|
||
|
|
index = 0;
|
||
|
|
block = { label: MONTH.format(date), x, width: 0, cells: [] };
|
||
|
|
blocks.push(block);
|
||
|
|
}
|
||
|
|
const row = (lead + index) % 7;
|
||
|
|
const col = Math.floor((lead + index) / 7);
|
||
|
|
block!.cells.push({ x: x + col * STEP, y: row * STEP, date, attempts: day.attempts, passes: day.passes });
|
||
|
|
block!.width = (col + 1) * STEP - (STEP - CELL);
|
||
|
|
index++;
|
||
|
|
}
|
||
|
|
return { blocks, width: block ? x + block.width : 0 };
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function SubmissionHeatmap() {
|
||
|
|
const load = useStats();
|
||
|
|
const hover = useHoverTip<HTMLDivElement>();
|
||
|
|
|
||
|
|
if (load.phase !== "ready") {
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardContent>
|
||
|
|
<StatsPending load={load} className="h-32 w-full" />
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const { year } = load.stats;
|
||
|
|
const { blocks, width } = layout(year.days);
|
||
|
|
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-baseline gap-2">
|
||
|
|
<span className="font-semibold text-2xl tabular-nums">{year.attempts}</span>
|
||
|
|
<span className="font-normal text-muted-foreground">attempts in the past one year</span>
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Total active days: <span className="text-foreground tabular-nums">{year.active_days}</span>
|
||
|
|
</CardDescription>
|
||
|
|
<CardAction className="text-muted-foreground text-sm">
|
||
|
|
Max streak: <span className="text-foreground tabular-nums">{year.max_streak}</span>
|
||
|
|
</CardAction>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div ref={hover.ref} className="relative" onPointerLeave={hover.hide}>
|
||
|
|
<svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" role="img" aria-label="Attempts per day over the past year">
|
||
|
|
{blocks.map((block) => (
|
||
|
|
<g key={`${block.label}-${block.x}`}>
|
||
|
|
{block.cells.map((cell) => (
|
||
|
|
<rect
|
||
|
|
key={cell.x * 1000 + cell.y}
|
||
|
|
x={cell.x}
|
||
|
|
y={cell.y}
|
||
|
|
width={CELL}
|
||
|
|
height={CELL}
|
||
|
|
rx={2}
|
||
|
|
fill={HEAT[Math.min(cell.attempts, 4)]}
|
||
|
|
onPointerMove={(event) =>
|
||
|
|
hover.show(
|
||
|
|
event,
|
||
|
|
<>
|
||
|
|
<div className="font-medium">{DAY.format(cell.date)}</div>
|
||
|
|
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
|
||
|
|
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
|
||
|
|
</>,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
{block.width >= 2 * STEP && (
|
||
|
|
<text
|
||
|
|
x={block.x + block.width / 2}
|
||
|
|
y={height - 3}
|
||
|
|
textAnchor="middle"
|
||
|
|
className="fill-muted-foreground text-[9px]"
|
||
|
|
>
|
||
|
|
{block.label}
|
||
|
|
</text>
|
||
|
|
)}
|
||
|
|
</g>
|
||
|
|
))}
|
||
|
|
</svg>
|
||
|
|
{hover.node}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|