import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "../../i18n"; import { useCurrency } from "../../hooks/useCurrency"; import { fmtPct, fmtTokens } from "../../lib/dashboard-format"; import type { HeatmapCell, HeatmapData } from "../../types/dashboard"; interface HeatmapProps { heatmap: HeatmapData; } const LEVEL_BG = [ "bg-cell-empty", // 0 — empty / no usage "bg-emerald-900/55", "bg-emerald-700/65", "bg-emerald-500/85", "bg-emerald-400", ]; const DOW_LABELS_ZH = ["日", "一", "二", "三", "四", "五", "六"]; const DOW_LABELS_EN = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const CELL = 16; // px const GAP = 3; // px const LABEL_W = 28; // dow label column width interface HoverState { cell: HeatmapCell; x: number; // viewport coords y: number; } export function Heatmap({ heatmap }: HeatmapProps) { const { t, lang } = useTranslation(); const cells = heatmap?.cells || []; const monthLabels = heatmap?.monthLabels || []; const dowLabels = lang === "zh" ? DOW_LABELS_ZH : DOW_LABELS_EN; const weekCount = useMemo(() => { if (!cells.length) return 0; return Math.max(...cells.map((c) => c.weekIndex)) + 1; }, [cells]); const grid = useMemo(() => { const cols: (HeatmapCell | null)[][] = Array.from( { length: weekCount }, () => Array(7).fill(null) ); for (const c of cells) { if (c.weekIndex >= 0 && c.weekIndex < weekCount) { cols[c.weekIndex][c.dow] = c; } } return cols; }, [cells, weekCount]); // Tooltip state with 120ms enter delay, matching reference (delayDuration=120) const [hover, setHover] = useState(null); const enterTimer = useRef(null); const leaveTimer = useRef(null); useEffect(() => { return () => { if (enterTimer.current) window.clearTimeout(enterTimer.current); if (leaveTimer.current) window.clearTimeout(leaveTimer.current); }; }, []); const showTooltip = (cell: HeatmapCell, e?: MouseEvent) => { if (leaveTimer.current) { window.clearTimeout(leaveTimer.current); leaveTimer.current = null; } enterTimer.current = window.setTimeout(() => { if (e) { setHover({ cell, x: e.clientX, y: e.clientY }); } else { // Keyboard focus path — fall back to last known position setHover((prev) => (prev ? { ...prev, cell } : { cell, x: 0, y: 0 })); } }, 120); }; const moveTooltip = (cell: HeatmapCell, e: MouseEvent) => { if (hover && hover.cell.date === cell.date) { setHover({ cell, x: e.clientX, y: e.clientY }); } }; const showTooltipFromFocus = (cell: HeatmapCell) => showTooltip(cell); const hideTooltip = () => { if (enterTimer.current) { window.clearTimeout(enterTimer.current); enterTimer.current = null; } leaveTimer.current = window.setTimeout(() => { setHover(null); }, 60); }; if (!cells.length) { return (
{t("noData")}
); } return (
{/* Fixed-size year strip, centered; scrolls horizontally only when the card is narrower than the strip */}
{/* month labels */}
{Array.from({ length: weekCount }, (_, wi) => { const label = monthLabels.find((m) => m.weekIndex === wi); return (
{label ? label.label : ""}
); })}
{/* dow labels */}
{dowLabels.map((d, i) => (
{d}
))}
{/* cells */}
{grid.map((week, wi) => (
{week.map((cell, di) => { if (!cell) { return (
); } const level = cell.level ?? 0; return (
))}
{heatmap.start} → {heatmap.end}
{t("heatLess")} {[0, 1, 2, 3, 4].map((lv) => (
))} {t("heatMore")}
{hover && createPortal( , document.body )}
); } function HeatmapTooltip({ state }: { state: HoverState }) { const { t } = useTranslation(); const { money } = useCurrency(); const { cell, x, y } = state; // Position above the cursor with a small offset; flip below if too close to top. const offset = 14; const style: React.CSSProperties = { position: "fixed", top: y - offset, left: x, transform: "translate(-50%, -100%)", zIndex: 9999, pointerEvents: "none", }; return (
{cell.date}
{fmtTokens(cell.totalTokens)} · {money(cell.costUsd)} ·{" "} {fmtInt(cell.requests)} {t("requests")}
{t("cacheHitRate")}: {fmtPct(cell.cacheHitRate)}
); } // Local int formatter (same style as DailyBars in this project) function fmtInt(n: number): string { return Math.round(n).toLocaleString(); }