import { useId, type CSSProperties } from 'react'; export interface RadarSeries { /** Stable id — React key + accessible label. */ id: string; /** One value per axis; non-finite values are treated as 0. */ values: number[]; /** Optional override colour (defaults walk the token palette). */ color?: string; /** Optional display label (falls back to `id`). */ label?: string; } export interface RadarChartProps { /** Axis (category) labels — defines the number of spokes. */ axes: string[]; /** One or more series plotted as polygons. */ series: RadarSeries[]; /** Total width/height in px. Default 260. */ size?: number; /** Fixed maximum for the radial scale. Defaults to the largest value. */ max?: number; /** Concentric grid rings. Default 4. */ levels?: number; /** Accessible label. */ 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)', ]; const safe = (v: number): number => (Number.isFinite(v) ? v : 0); /** Cartesian point for axis `i` of `n` at radius `radius`. */ function spoke(cx: number, cy: number, radius: number, i: number, n: number): [number, number] { const angle = -Math.PI / 2 + (i / n) * 2 * Math.PI; return [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; } const toPath = (pts: ReadonlyArray<[number, number]>): string => pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`).join(' ') + ' Z'; /** * `` — multi-axis polygon (spider) chart. Pure SVG, token-themed, * NaN-safe. Wave 9.A.5. */ export function RadarChart({ axes, series, size = 260, max, levels = 4, ariaLabel, className, style, }: RadarChartProps) { const titleId = useId(); const n = axes.length; const cx = size / 2; const cy = size / 2; const r = (size / 2) * 0.74; // leave room for labels const derivedMax = max ?? Math.max(1, ...series.flatMap(s => s.values.map(v => safe(v)))); const scale = (v: number) => (derivedMax === 0 ? 0 : (safe(v) / derivedMax) * r); return ( {ariaLabel ?? 'Radar chart'} {/* Concentric grid rings */} {n >= 3 && Array.from({ length: levels }, (_, l) => { const ringR = (r * (l + 1)) / levels; const pts = Array.from({ length: n }, (_, i) => spoke(cx, cy, ringR, i, n)); return ( ); })} {/* Spokes + axis labels */} {axes.map((label, i) => { const [ex, ey] = spoke(cx, cy, r, i, n); const [lx, ly] = spoke(cx, cy, r + 14, i, n); return ( cx + 1 ? 'start' : lx < cx - 1 ? 'end' : 'middle'} dominantBaseline="middle" fill="var(--bl-text-secondary, #444)" fontFamily="ui-sans-serif, system-ui, sans-serif" > {label} ); })} {/* Series polygons */} {series.map((s, si) => { const colour = s.color ?? TOKEN_PALETTE[si % TOKEN_PALETTE.length]!; const pts = axes.map((_, i) => spoke(cx, cy, scale(s.values[i] ?? 0), i, n)); return ( ); })} ); }