import { useId, type CSSProperties } from 'react'; import { compactNumber, extent, filterFinite, linearScale, smoothPath } from './utils.js'; export interface LineSeries { /** Stable id — React key + accessible series label. */ id: string; /** Y-values (X is the array index, evenly spaced). */ values: number[]; /** Optional override colour. Defaults walk the token palette. */ color?: string; } export interface LineChartProps { /** One or more series, plotted in z-order. */ series: LineSeries[]; /** Chart width in px. Default 480. */ width?: number; /** Chart height in px. Default 240. */ height?: number; /** Smooth (catmull-rom) vs straight polyline. Default smooth. */ smooth?: boolean; /** Show subtle Y grid lines (3 ticks). Default true. */ grid?: boolean; /** Accessible label for the SVG. */ ariaLabel?: string; className?: string; style?: CSSProperties; } const TOKEN_PALETTE = [ 'var(--bl-accent, #6366f1)', 'var(--bl-success, #10b981)', 'var(--bl-warning, #f59e0b)', 'var(--bl-info, #0ea5e9)', 'var(--bl-danger, #ef4444)', ]; /** * `` — multi-series SVG line chart with optional smoothing. * * Pure-React, dependency-free. The series share a common Y extent; * legend / tooltip overlays are intentionally out-of-scope — wrap a * `` in a host that handles those if needed. * * Wave 9.A.1. */ export function LineChart({ series, width = 480, height = 240, smooth = true, grid = true, ariaLabel, className, style, }: LineChartProps) { const titleId = useId(); const padL = 36; const padR = 12; const padT = 12; const padB = 24; const innerW = Math.max(0, width - padL - padR); const innerH = Math.max(0, height - padT - padB); const allValues = series.flatMap((s) => s.values); const [yMin, yMax] = extent(allValues); // Find the longest series for the X domain. const maxLen = Math.max(1, ...series.map((s) => s.values.length)); const yScale = linearScale(yMin, yMax, innerH, 0); const xScale = (i: number) => maxLen > 1 ? (i / (maxLen - 1)) * innerW : innerW / 2; const yTicks = grid ? niceTicks(yMin, yMax, 3) : []; return ( {ariaLabel ?? 'Line chart'} {/* Grid */} {yTicks.map((t, i) => ( {compactNumber(t)} ))} {series.map((s, idx) => { const colour = s.color ?? TOKEN_PALETTE[idx % TOKEN_PALETTE.length]!; // Drop NaN / Infinity so the SVG path stays well-formed. const points: Array<[number, number]> = filterFinite(s.values).map( ([i, v]) => [xScale(i), yScale(v)], ); if (points.length === 0) return null; const d = smooth ? smoothPath(points) : `M ${points.map(([x, y]) => `${x} ${y}`).join(' L ')}`; return ( {points.map(([x, y], i) => ( ))} ); })} ); } /** Pick ~`n` nice ticks across `[lo, hi]`. */ function niceTicks(lo: number, hi: number, n: number): number[] { if (lo === hi) return [lo]; const step = (hi - lo) / Math.max(1, n - 1); return Array.from({ length: n }, (_, i) => lo + step * i); }