import { CategoryScale, Chart as ChartJS, type Chart as ChartType, LinearScale, LineElement, PointElement, Tooltip, } from "chart.js"; import clsx from "clsx"; import * as React from "react"; import { useRef } from "react"; import { Line } from "react-chartjs-2"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useHydrated } from "~/hooks/useHydrated"; import styles from "./Chart.module.css"; ChartJS.register( CategoryScale, LinearScale, PointElement, LineElement, Tooltip, ); export default function Chart({ options, containerClassName, headerSuffix, valueSuffix, xAxis, xTicksLimit, yTicksLimit, xAbilityLimit, highlight, crosshair = false, }: { options: [ { label: React.ReactNode; data: Array<{ primary: Date | number; secondary: number }>; }, ]; containerClassName?: string; headerSuffix?: string; valueSuffix?: string; xAxis: "linear" | "localTime"; xTicksLimit?: number; yTicksLimit?: number; xAbilityLimit?: number; /** Marks current positions on the curve, each at ability point `x` and stat value `y` (e.g. one per build being compared). */ highlight?: Array<{ x: number; y: number }>; /** When true, draws dashed guide lines from the hovered point to the x- and y-axes. */ crosshair?: boolean; }) { const isHydrated = useHydrated(); // Ref to the Chart.js instance, allows proper cleanup between renders to prevent "Canvas is already in use" errors const chartRef = useRef | null>(null); const chartId = React.useId(); // Chart.js re-fires the external tooltip on every redraw; track the last value to skip redundant state updates const lastTooltipSignature = useRef(null); const [tooltipData, setTooltipData] = React.useState<{ x: number; y: number; flipX: boolean; flipY: boolean; items: Array<{ label: React.ReactNode; value: number | null; color: string; }>; header: string; } | null>(null); // Format dates in the tooltip header using the user's locale const { formatter: headerFormatter } = useDateTimeFormat({ weekday: "short", day: "numeric", month: "numeric", }); // Format dates on the xAxis const { formatter: scaleFormatter } = useDateTimeFormat({ day: "numeric", month: "numeric", }); // Get the chart colors from CSS variables const [colors, setColors] = React.useState({ accentHigh: "", infoHigh: "", secondHigh: "", accentLow: "", secondLow: "", border: "", borderHigh: "", text: "", }); React.useEffect(() => { const resolve = () => { const get = (v: string) => getComputedStyle(document.documentElement).getPropertyValue(v).trim(); setColors({ // bright "high" variants for the curve lines so they stay legible on the dark chart accentHigh: get("--color-text-accent"), infoHigh: get("--color-info-high"), secondHigh: get("--color-second-high"), // low variants for the highlight marker fills (paired with a light border) accentLow: get("--color-accent-low"), secondLow: get("--color-second-low"), border: get("--color-border"), borderHigh: get("--color-border-high"), text: get("--color-text-high"), }); }; resolve(); const root = document.documentElement; const observer = new MutationObserver(resolve); observer.observe(root, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect(); }, []); const scaleDefaults = React.useMemo( () => ({ grid: { color: colors.border }, border: { color: colors.borderHigh }, ticks: { color: colors.text }, }), [colors.border, colors.borderHigh, colors.text], ); // Make a color list to use inside ChartData for the borderColor and the external tooltip const colorList = React.useMemo( () => [colors.accentHigh, colors.infoHigh, colors.secondHigh], [colors.accentHigh, colors.infoHigh, colors.secondHigh], ); // Distinct accent/secondary pair so the highlight markers (e.g. build 1 vs build 2) stay tellable apart in both themes const markerColors = React.useMemo( () => [colors.accentLow, colors.secondLow], [colors.accentLow, colors.secondLow], ); const datasetColors = React.useCallback( (i: number) => { const color = colorList[i % colorList.length]; return { borderColor: color, pointBackgroundColor: color, pointBorderColor: color, pointHoverBackgroundColor: color, pointHoverBorderColor: color, }; }, [colorList], ); const chartData = React.useMemo( () => ({ labels: options[0].data.map((_, i) => i), datasets: [ ...options.map((series, i) => ({ label: String(i), data: series.data.map((d) => d.secondary), ...datasetColors(i), pointRadius: 0, pointHoverRadius: 5, hitRadius: 50, backgroundColor: "transparent", })), ...(highlight ?? []).map((point, i) => ({ label: "highlight", data: options[0].data.map((_, j) => (j === point.x ? point.y : null)), borderColor: "transparent", backgroundColor: "transparent", pointBackgroundColor: markerColors[i % markerColors.length], pointBorderColor: colors.text, pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 5, pointHitRadius: 0, showLine: false, // lower order draws on top, so the marker sits above the curves order: -1, })), ], }), [options, datasetColors, highlight, markerColors, colors.text], ); // Draws dashed guide lines from the hovered point to the y-axis (left) and x-axis (bottom) const crosshairPlugin = React.useMemo( () => ({ id: "crosshair", afterDatasetsDraw: (chart: ChartType<"line">) => { const active = chart.tooltip?.getActiveElements() ?? []; if (active.length === 0) return; const { ctx, chartArea } = chart; const { x, y } = active[0].element as { x: number; y: number }; ctx.save(); ctx.beginPath(); ctx.setLineDash([4, 4]); ctx.lineWidth = 1; ctx.strokeStyle = colors.borderHigh || "#888"; ctx.moveTo(chartArea.left, y); ctx.lineTo(x, y); ctx.lineTo(x, chartArea.bottom); ctx.stroke(); ctx.restore(); }, }), [colors.borderHigh], ); const plugins = React.useMemo( () => (crosshair ? [crosshairPlugin] : []), [crosshair, crosshairPlugin], ); if (!isHydrated) { return
; } return (
{ if (xAxis === "localTime") { const date = options[0].data[value as number]?.primary; if (date instanceof Date) { return scaleFormatter.format(date); } } return value; }, }, }, y: { ...scaleDefaults, ticks: { ...scaleDefaults.ticks, maxTicksLimit: yTicksLimit, }, }, }, plugins: { tooltip: { enabled: false, external: ({ chart, tooltip }) => { if (tooltip.opacity === 0) { lastTooltipSignature.current = null; setTooltipData(null); return; } // the highlight marker has no entry in `options` and is non-interactive const dataPoints = tooltip.dataPoints.filter( (dp) => dp.datasetIndex < options.length, ); if (dataPoints.length === 0) { lastTooltipSignature.current = null; setTooltipData(null); return; } const x = tooltip.caretX; const y = tooltip.caretY; const items = dataPoints.map((dp) => ({ label: options[dp.datasetIndex].label, value: dp.parsed.y, color: colorList[dp.datasetIndex % colorList.length], })); const rawPrimary = options[0].data[dataPoints[0].dataIndex]?.primary; const header = rawPrimary instanceof Date ? headerFormatter.format(rawPrimary) + (headerSuffix ?? "") : `${dataPoints[0].parsed.x}${headerSuffix ?? ""}`; // skip redundant updates from repeated redraws to avoid an infinite render loop const signature = `${x}|${y}|${header}|${items .map((i) => `${i.value}:${i.color}`) .join(",")}`; if (signature === lastTooltipSignature.current) return; lastTooltipSignature.current = signature; setTooltipData({ x, y, flipX: x > chart.width / 2, flipY: y > chart.height / 2, header, items, }); }, }, }, }} /> {tooltipData && (

{tooltipData.header}

{tooltipData.items.map((item, i) => (
{item.label}
{item.value} {valueSuffix}
))}
)}
); }