"use client"; import { useState, useRef } from "react"; // ─── Types ─────────────────────────────────────────────────────────────────── interface Series { name: string; color: string; /** One value per label. null = no data for this commit. */ values: (number | null)[]; } interface TrendChartProps { /** Shared x-axis labels (e.g. commit short hashes), same length as each series' values array. */ labels: string[]; series: Series[]; yLabel?: string; formatY?: (value: number) => string; height?: number; } // ─── SVG Trend Chart ───────────────────────────────────────────────────────── const PADDING = { top: 20, right: 20, bottom: 40, left: 70 }; export function TrendChart({ labels, series, yLabel = "", formatY = (v) => String(v), height = 300, }: TrendChartProps) { const svgRef = useRef(null); const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string; } | null>(null); // Collect all non-null values to determine y-axis bounds const allValues = series.flatMap((s) => s.values.filter((v): v is number => v !== null), ); if (allValues.length === 0) { return (
No data points to display
); } const numPoints = labels.length; const minVal = Math.min(...allValues) * 0.9; const maxVal = Math.max(...allValues) * 1.1; const chartWidth = 700; const innerW = chartWidth - PADDING.left - PADDING.right; const innerH = height - PADDING.top - PADDING.bottom; function scaleX(i: number): number { if (numPoints <= 1) return PADDING.left + innerW / 2; return PADDING.left + (i / (numPoints - 1)) * innerW; } function scaleY(v: number): number { const range = maxVal - minVal || 1; return PADDING.top + innerH - ((v - minVal) / range) * innerH; } // Y-axis ticks (5 ticks) const yTicks = Array.from({ length: 5 }, (_, i) => { const v = minVal + ((maxVal - minVal) * i) / 4; return { value: v, y: scaleY(v) }; }); // X-axis labels (show every Nth) const labelStep = Math.max(1, Math.floor(numPoints / 8)); return (
setTooltip(null)} > {/* Grid lines */} {yTicks.map((tick, i) => ( {formatY(tick.value)} ))} {/* X-axis labels */} {labels.map((label, i) => { if (i % labelStep !== 0 && i !== numPoints - 1) return null; return ( {label} ); })} {/* Series lines + dots */} {series.map((s) => { // Build path segments, breaking on null values const segments: string[] = []; let inSegment = false; for (let i = 0; i < s.values.length; i++) { const v = s.values[i]; if (v === null) { inSegment = false; continue; } const x = scaleX(i); const y = scaleY(v); if (!inSegment) { segments.push(`M ${x} ${y}`); inSegment = true; } else { segments.push(`L ${x} ${y}`); } } if (segments.length === 0) return null; const pathD = segments.join(" "); return ( {/* Line */} {/* Dots — only for non-null values */} {s.values.map((v, i) => { if (v === null) return null; return ( { const rect = svgRef.current?.getBoundingClientRect(); if (!rect) return; setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top - 10, content: `${s.name}: ${formatY(v)} (${labels[i]})`, }); }} onMouseLeave={() => setTooltip(null)} /> ); })} ); })} {/* Y-axis label */} {yLabel && ( {yLabel} )} {/* Legend */}
{series.map((s) => (
{s.name}
))}
{/* Tooltip */} {tooltip && (
{tooltip.content}
)}
); }