/** * DistributionChart — SVG Box Plot (Story 6.4) * * Renders a box plot for a single metric across multiple variants. * No external chart library — pure SVG. * * Box plot elements: * - Median line * - Q1-Q3 box * - Whiskers at 1.5×IQR (clamped to data range) * - Outlier dots beyond whiskers */ import React, { useMemo } from 'react'; // ─── Types ────────────────────────────────────────────────────────── export interface VariantDistribution { variantId: string; variantName: string; values: number[]; color: string; } export interface DistributionChartProps { metric: string; metricLabel: string; variants: VariantDistribution[]; } // ─── Stat Helpers ─────────────────────────────────────────────────── function quantile(sorted: number[], q: number): number { if (sorted.length === 0) return 0; if (sorted.length === 1) return sorted[0]; const pos = (sorted.length - 1) * q; const lo = Math.floor(pos); const hi = Math.ceil(pos); const frac = pos - lo; return sorted[lo] * (1 - frac) + sorted[hi] * frac; } interface BoxStats { min: number; q1: number; median: number; q3: number; max: number; whiskerLow: number; whiskerHigh: number; outliers: number[]; } function computeBoxStats(values: number[]): BoxStats | null { if (values.length === 0) return null; const sorted = [...values].sort((a, b) => a - b); const min = sorted[0]; const max = sorted[sorted.length - 1]; const q1 = quantile(sorted, 0.25); const median = quantile(sorted, 0.5); const q3 = quantile(sorted, 0.75); const iqr = q3 - q1; const fenceLow = q1 - 1.5 * iqr; const fenceHigh = q3 + 1.5 * iqr; // Whiskers: furthest data points within fences const whiskerLow = sorted.find((v) => v >= fenceLow) ?? min; const whiskerHigh = [...sorted].reverse().find((v) => v <= fenceHigh) ?? max; // Outliers: points beyond fences const outliers = sorted.filter((v) => v < fenceLow || v > fenceHigh); return { min, q1, median, q3, max, whiskerLow, whiskerHigh, outliers }; } // ─── SVG Constants ────────────────────────────────────────────────── const CHART_WIDTH = 600; const CHART_HEIGHT = 200; const PADDING_LEFT = 60; const PADDING_RIGHT = 20; const PADDING_TOP = 30; const PADDING_BOTTOM = 40; const PLOT_WIDTH = CHART_WIDTH - PADDING_LEFT - PADDING_RIGHT; const PLOT_HEIGHT = CHART_HEIGHT - PADDING_TOP - PADDING_BOTTOM; // ─── Component ────────────────────────────────────────────────────── export function DistributionChart({ metric, metricLabel, variants, }: DistributionChartProps): React.ReactElement { const { allStats, globalMin, globalMax, scale } = useMemo(() => { const stats = variants.map((v) => ({ ...v, stats: computeBoxStats(v.values), })); // Global range for shared x-axis let gMin = Infinity; let gMax = -Infinity; for (const s of stats) { if (!s.stats) continue; gMin = Math.min(gMin, s.stats.min); gMax = Math.max(gMax, s.stats.max); } // Add 5% padding on each side if (gMin === Infinity) { gMin = 0; gMax = 1; } const range = gMax - gMin || 1; gMin -= range * 0.05; gMax += range * 0.05; const scaleFn = (val: number) => PADDING_LEFT + ((val - gMin) / (gMax - gMin)) * PLOT_WIDTH; return { allStats: stats, globalMin: gMin, globalMax: gMax, scale: scaleFn }; }, [variants]); const variantCount = allStats.length; const boxHeight = Math.min(30, (PLOT_HEIGHT - 10) / Math.max(variantCount, 1)); const gap = variantCount > 1 ? (PLOT_HEIGHT - variantCount * boxHeight) / (variantCount + 1) : (PLOT_HEIGHT - boxHeight) / 2; // Generate axis ticks const ticks = useMemo(() => { const range = globalMax - globalMin; const rawStep = range / 5; const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep || 1))); const step = Math.ceil(rawStep / magnitude) * magnitude || 1; const t: number[] = []; let v = Math.ceil(globalMin / step) * step; while (v <= globalMax) { t.push(v); v += step; } return t; }, [globalMin, globalMax]); const hasData = allStats.some((s) => s.stats !== null); if (!hasData) { return (