"use client" import { useMemo, useState } from "react" interface ScoreSeries { /** Stable key — used by the metric dropdown to switch series. */ key: string /** Short label shown in the dropdown and as the panel sub-title. */ label: string /** Optional longer description shown next to the label. */ caption?: string values: number[] unit?: string lowerIsBetter?: boolean } interface ScoreDistributionProps { /** Single-series shorthand. Either pass `values` (single) or `series` (multi). */ values?: number[] label?: string unit?: string lowerIsBetter?: boolean /** Multi-series — when provided, a dropdown picker swaps between them. */ series?: ScoreSeries[] /** Initial selected key when multi-series. Defaults to first. */ initialKey?: string /** Compact variant — shorter, used for matrix per-column distributions. */ compact?: boolean } interface SummaryStats { n: number min: number max: number mean: number median: number q1: number q3: number } function computeStats(values: number[]): SummaryStats | null { const sorted = values.filter((v) => Number.isFinite(v)).slice().sort((a, b) => a - b) const n = sorted.length if (n === 0) return null const min = sorted[0] const max = sorted[n - 1] const mean = sorted.reduce((acc, v) => acc + v, 0) / n const quantile = (p: number) => { if (n === 1) return sorted[0] const pos = (n - 1) * p const base = Math.floor(pos) const rest = pos - base return sorted[base + 1] != null ? sorted[base] + rest * (sorted[base + 1] - sorted[base]) : sorted[base] } return { n, min, max, mean, median: quantile(0.5), q1: quantile(0.25), q3: quantile(0.75), } } function formatValue(v: number, unit?: string) { const abs = Math.abs(v) let formatted: string if (abs >= 100) formatted = v.toFixed(1) else if (abs >= 10) formatted = v.toFixed(2) else formatted = v.toFixed(3).replace(/0+$/g, "").replace(/\.$/, "") return unit ? `${formatted} ${unit}` : formatted } /** * Continuous-density distribution plot. * * Builds a smoothed kernel density estimate (KDE) from the raw values rather * than a binned histogram, which reads as a continuous probability-weight * curve in the paper's hairline style. Median and mean are rendered as * vertical rules on top of the curve; IQR is a bracket along the baseline. * * Multi-series mode shows a small dropdown inside the panel header so a * caller (e.g. a multi-metric leaderboard) can stack metrics into one * visualization the user swaps between, instead of rendering N panels. */ export function ScoreDistribution({ values, label, unit, lowerIsBetter, series, initialKey, compact = false, }: ScoreDistributionProps) { // Normalize: either we got a single series (via values) or many. const seriesList: ScoreSeries[] = useMemo(() => { if (series && series.length > 0) return series if (values && values.length > 0) { return [{ key: "__single", label: label ?? "Score", values, unit, lowerIsBetter }] } return [] }, [series, values, label, unit, lowerIsBetter]) const [activeKey, setActiveKey] = useState( () => initialKey ?? series?.[0]?.key ?? "__single", ) const active = seriesList.find((s) => s.key === activeKey) ?? seriesList[0] const stats = useMemo(() => (active ? computeStats(active.values) : null), [active]) const density = useMemo(() => { if (!active || !stats) return null if (stats.max === stats.min) { return { points: [{ x: stats.min, y: 1 }], maxY: 1 } } const sorted = active.values .filter((v) => Number.isFinite(v)) .slice() .sort((a, b) => a - b) const n = sorted.length if (n === 0) return null // Silverman's rule of thumb for bandwidth. const variance = sorted.reduce((acc, v) => acc + (v - stats.mean) ** 2, 0) / n const stdDev = Math.sqrt(variance) const iqr = stats.q3 - stats.q1 const sigma = iqr > 0 ? Math.min(stdDev, iqr / 1.34) : stdDev || (stats.max - stats.min) / 6 const bandwidth = Math.max( 1.06 * sigma * Math.pow(n, -0.2), (stats.max - stats.min) / 80, ) const sampleCount = compact ? 80 : 140 const range = stats.max - stats.min const xs: number[] = [] for (let i = 0; i < sampleCount; i++) { xs.push(stats.min + (range * i) / (sampleCount - 1)) } const ys = xs.map((x) => { let sum = 0 for (const v of sorted) { const u = (x - v) / bandwidth sum += Math.exp(-0.5 * u * u) } return sum / (n * bandwidth * Math.sqrt(2 * Math.PI)) }) const maxY = Math.max(...ys, 1e-9) const points = xs.map((x, i) => ({ x, y: ys[i] })) return { points, maxY } }, [active, stats, compact]) if (!active || !stats || !density) return null const width = 100 const plotHeight = compact ? 28 : 56 const fullRange = stats.max - stats.min || 1 const markerX = (v: number) => ((v - stats.min) / fullRange) * width const markerY = (y: number) => plotHeight - (y / density.maxY) * (plotHeight - 4) const path = density.points .map((p, i) => { const x = markerX(p.x) const y = markerY(p.y) return `${i === 0 ? "M" : "L"}${x.toFixed(3)},${y.toFixed(3)}` }) .join(" ") const fillPath = `${path} L${width.toFixed(3)},${plotHeight.toFixed(3)} L0,${plotHeight.toFixed(3)} Z` const captionItems: Array<{ label: string; value: string; key: string }> = [ { key: "n", label: "n", value: stats.n.toString() }, { key: "min", label: "min", value: formatValue(stats.min, active.unit) }, { key: "q1", label: "q1", value: formatValue(stats.q1, active.unit) }, { key: "median", label: "median", value: formatValue(stats.median, active.unit) }, { key: "mean", label: "mean", value: formatValue(stats.mean, active.unit) }, { key: "q3", label: "q3", value: formatValue(stats.q3, active.unit) }, { key: "max", label: "max", value: formatValue(stats.max, active.unit) }, ] const directionHint = active.lowerIsBetter ? "lower is better ←" : "higher is better →" const showPicker = seriesList.length > 1 return (
{!compact && (
Score distribution
{showPicker ? ( ) : ( · {active.label} )}
{directionHint}
)} {compact && (
{active.label}
)} {/* Filled density area */} {/* Density curve */} {/* Baseline */} {/* IQR bracket along baseline */} {/* Median vertical rule (accent) */} {/* Mean tick (dashed) */} {/* Caption row */}
{captionItems.map((item, i) => ( {i > 0 && ·} {item.label} {item.value} ))}
{!compact && (
median mean IQR (q1–q3)
)}
) }