commit 4bb4cb64ef5efb430c6a2757632ee77720e7b741
parent f87935500650d6e4b49041f7096eadae319c1515
Author: Brian Graham <brian@buildingbetterteams.de>
Date: Mon, 6 Apr 2026 20:18:04 +0200
Box plots for grid charts, model toggles for scatter hulls
- Average Score by Model: replaced bar+error with box plot (Q1/median/Q3/whiskers)
- Pass Rate by Task: same box plot conversion
- Scatter plots: toggle buttons to show/hide individual models
Axis scales stay fixed when models are hidden
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 353 insertions(+), 115 deletions(-)
diff --git a/dashboard/src/components/Charts.tsx b/dashboard/src/components/Charts.tsx
@@ -1,6 +1,7 @@
import {
- BarChart,
+ ComposedChart,
Bar,
+ Scatter,
XAxis,
YAxis,
CartesianGrid,
@@ -8,7 +9,7 @@ import {
ResponsiveContainer,
Legend,
Cell,
- ErrorBar,
+ ZAxis,
} from "recharts";
import type { Run } from "../lib/types";
@@ -16,26 +17,41 @@ interface ChartsProps {
runs: Run[];
}
-interface ModelScore {
- model: string;
- avg_score: number;
- min_score: number;
- max_score: number;
- errorRange: [number, number];
- avg_cost: number;
+interface BoxPlotData {
+ label: string;
+ min: number;
+ q1: number;
+ median: number;
+ q3: number;
+ max: number;
cellCount: number;
+ scores: number[];
+ // Derived fields for recharts stacked bar trick
+ base: number; // invisible bar height = q1
+ iqr: number; // visible box height = q3 - q1
+ color: string;
}
-interface TaskScore {
- task: string;
- avg_score: number;
- min_score: number;
- max_score: number;
- scoreErrorRange: [number, number];
- pass_rate: number;
- min_pass_rate: number;
- max_pass_rate: number;
- passRateErrorRange: [number, number];
+interface TaskBoxPlotData {
+ label: string;
+ // Score distribution
+ sMin: number;
+ sQ1: number;
+ sMedian: number;
+ sQ3: number;
+ sMax: number;
+ sBase: number;
+ sIqr: number;
+ sScores: number[];
+ // Pass rate distribution
+ pMin: number;
+ pQ1: number;
+ pMedian: number;
+ pQ3: number;
+ pMax: number;
+ pBase: number;
+ pIqr: number;
+ pScores: number[];
cellCount: number;
}
@@ -132,7 +148,30 @@ function aggregateCells(runs: Run[]): CellAggregate[] {
}));
}
-function aggregateByModel(runs: Run[]): ModelScore[] {
+const MODEL_ORDER: Record<string, number> = { haiku: 1, sonnet: 2, opus: 3 };
+
+function quantile(sorted: number[], q: number): number {
+ if (sorted.length === 0) return 0;
+ if (sorted.length === 1) return sorted[0];
+ const pos = q * (sorted.length - 1);
+ const lo = Math.floor(pos);
+ const hi = Math.ceil(pos);
+ const frac = pos - lo;
+ return sorted[lo] + frac * (sorted[hi] - sorted[lo]);
+}
+
+function computeBoxStats(values: number[]): { min: number; q1: number; median: number; q3: number; max: number } {
+ const sorted = [...values].sort((a, b) => a - b);
+ return {
+ min: sorted.length > 0 ? sorted[0] : 0,
+ q1: quantile(sorted, 0.25),
+ median: quantile(sorted, 0.5),
+ q3: quantile(sorted, 0.75),
+ max: sorted.length > 0 ? sorted[sorted.length - 1] : 0,
+ };
+}
+
+function aggregateByModel(runs: Run[]): BoxPlotData[] {
const cells = aggregateCells(runs);
const byModel: Record<string, CellAggregate[]> = {};
@@ -141,36 +180,27 @@ function aggregateByModel(runs: Run[]): ModelScore[] {
byModel[cell.model].push(cell);
}
- const MODEL_ORDER: Record<string, number> = { haiku: 1, sonnet: 2, opus: 3 };
const sortedEntries = Object.entries(byModel).sort(([a], [b]) =>
- (MODEL_ORDER[a] || 99) - (MODEL_ORDER[b] || 99)
+ (MODEL_ORDER[a] || 99) - (MODEL_ORDER[b] || 99) || a.localeCompare(b)
);
return sortedEntries.map(([model, modelCells]) => {
const scores = modelCells.map((c) => Math.round(c.avgScore * 100));
- const costs = modelCells.map((c) => c.avgCost);
- const avgScore = scores.length > 0
- ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
- : 0;
- const minScore = scores.length > 0 ? Math.min(...scores) : 0;
- const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
- const avgCost = costs.length > 0
- ? Math.round((costs.reduce((a, b) => a + b, 0) / costs.length) * 100) / 100
- : 0;
-
+ const stats = computeBoxStats(scores);
+ const baseModel = model;
return {
- model: `${model} (n=${modelCells.length} cells)`,
- avg_score: avgScore,
- min_score: minScore,
- max_score: maxScore,
- errorRange: [avgScore - minScore, maxScore - avgScore] as [number, number],
- avg_cost: avgCost,
+ label: `${model} (n=${modelCells.length})`,
+ ...stats,
+ base: stats.q1,
+ iqr: stats.q3 - stats.q1,
cellCount: modelCells.length,
+ scores,
+ color: MODEL_COLORS[baseModel] || SMUI.frost2,
};
});
}
-function aggregateByTask(runs: Run[]): TaskScore[] {
+function aggregateByTask(runs: Run[]): TaskBoxPlotData[] {
const cells = aggregateCells(runs);
const byTask: Record<string, CellAggregate[]> = {};
@@ -182,34 +212,170 @@ function aggregateByTask(runs: Run[]): TaskScore[] {
return Object.entries(byTask).map(([task, taskCells]) => {
const scores = taskCells.map((c) => Math.round(c.avgScore * 100));
const passRates = taskCells.map((c) => Math.round(c.passRate * 100));
-
- const avgScore = scores.length > 0
- ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
- : 0;
- const minScore = scores.length > 0 ? Math.min(...scores) : 0;
- const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
-
- const avgPassRate = passRates.length > 0
- ? Math.round(passRates.reduce((a, b) => a + b, 0) / passRates.length)
- : 0;
- const minPassRate = passRates.length > 0 ? Math.min(...passRates) : 0;
- const maxPassRate = passRates.length > 0 ? Math.max(...passRates) : 0;
+ const sStats = computeBoxStats(scores);
+ const pStats = computeBoxStats(passRates);
return {
- task: `${task} (n=${taskCells.length} cells)`,
- avg_score: avgScore,
- min_score: minScore,
- max_score: maxScore,
- scoreErrorRange: [avgScore - minScore, maxScore - avgScore] as [number, number],
- pass_rate: avgPassRate,
- min_pass_rate: minPassRate,
- max_pass_rate: maxPassRate,
- passRateErrorRange: [avgPassRate - minPassRate, maxPassRate - avgPassRate] as [number, number],
+ label: `${task} (n=${taskCells.length})`,
+ sMin: sStats.min, sQ1: sStats.q1, sMedian: sStats.median, sQ3: sStats.q3, sMax: sStats.max,
+ sBase: sStats.q1, sIqr: sStats.q3 - sStats.q1,
+ sScores: scores,
+ pMin: pStats.min, pQ1: pStats.q1, pMedian: pStats.median, pQ3: pStats.q3, pMax: pStats.max,
+ pBase: pStats.q1, pIqr: pStats.q3 - pStats.q1,
+ pScores: passRates,
cellCount: taskCells.length,
};
});
}
+// Custom shape: draws a box from q1 to q3 with whiskers from min to max and a median line
+function BoxPlotShape(props: any) {
+ const { x, y, width, height, payload } = props as {
+ x: number; y: number; width: number; height: number;
+ payload: BoxPlotData;
+ };
+ if (!payload || height === undefined) return null;
+
+ const { min, median, max, color } = payload;
+ // The bar is rendered from q1 (base) with height iqr (q3-q1).
+ // y is the top of the bar (q3 in chart coords), y+height is the bottom (q1).
+ const boxTop = y;
+ const boxBottom = y + height;
+ const boxQ3 = payload.q3;
+ const boxQ1 = payload.q1;
+ const centerX = x + width / 2;
+
+ // Scale: we need to convert data values to pixel positions.
+ // We know q1 maps to boxBottom and q3 maps to boxTop.
+ const dataToY = (val: number): number => {
+ if (boxQ3 === boxQ1) return boxTop;
+ return boxTop + ((boxQ3 - val) / (boxQ3 - boxQ1)) * (boxBottom - boxTop);
+ };
+
+ const minY = dataToY(min);
+ const maxY = dataToY(max);
+ const medianY = dataToY(median);
+ const whiskerHalfW = width * 0.3;
+
+ return (
+ <g>
+ {/* Whisker line: min to max */}
+ <line x1={centerX} y1={minY} x2={centerX} y2={maxY} stroke={SMUI.muted} strokeWidth={1} />
+ {/* Min whisker cap */}
+ <line x1={centerX - whiskerHalfW} y1={minY} x2={centerX + whiskerHalfW} y2={minY} stroke={SMUI.muted} strokeWidth={1} />
+ {/* Max whisker cap */}
+ <line x1={centerX - whiskerHalfW} y1={maxY} x2={centerX + whiskerHalfW} y2={maxY} stroke={SMUI.muted} strokeWidth={1} />
+ {/* Box (IQR) */}
+ <rect x={x} y={boxTop} width={width} height={Math.max(height, 1)} fill={color} fillOpacity={0.3} stroke={color} strokeWidth={1} />
+ {/* Median line */}
+ <line x1={x} y1={medianY} x2={x + width} y2={medianY} stroke={color} strokeWidth={2} />
+ </g>
+ );
+}
+
+// Custom shape for task box plots (score or pass rate)
+function TaskBoxPlotShape(prefix: "s" | "p", color: string) {
+ return function Shape(props: any) {
+ const { x, y, width, height, payload } = props as {
+ x: number; y: number; width: number; height: number;
+ payload: TaskBoxPlotData;
+ };
+ if (!payload || height === undefined) return null;
+
+ const min = payload[`${prefix}Min`] as number;
+ const q1 = payload[`${prefix}Q1`] as number;
+ const q3 = payload[`${prefix}Q3`] as number;
+ const median = payload[`${prefix}Median`] as number;
+ const max = payload[`${prefix}Max`] as number;
+
+ const boxTop = y;
+ const boxBottom = y + height;
+ const centerX = x + width / 2;
+
+ const dataToY = (val: number): number => {
+ if (q3 === q1) return boxTop;
+ return boxTop + ((q3 - val) / (q3 - q1)) * (boxBottom - boxTop);
+ };
+
+ const minY = dataToY(min);
+ const maxY = dataToY(max);
+ const medianY = dataToY(median);
+ const whiskerHalfW = width * 0.3;
+
+ return (
+ <g>
+ <line x1={centerX} y1={minY} x2={centerX} y2={maxY} stroke={SMUI.muted} strokeWidth={1} />
+ <line x1={centerX - whiskerHalfW} y1={minY} x2={centerX + whiskerHalfW} y2={minY} stroke={SMUI.muted} strokeWidth={1} />
+ <line x1={centerX - whiskerHalfW} y1={maxY} x2={centerX + whiskerHalfW} y2={maxY} stroke={SMUI.muted} strokeWidth={1} />
+ <rect x={x} y={boxTop} width={width} height={Math.max(height, 1)} fill={color} fillOpacity={0.3} stroke={color} strokeWidth={1} />
+ <line x1={x} y1={medianY} x2={x + width} y2={medianY} stroke={color} strokeWidth={2} />
+ </g>
+ );
+ };
+}
+
+// Build scatter data for individual cell dots on model chart
+function modelScatterData(data: BoxPlotData[]): Array<{ label: string; score: number; color: string }> {
+ const points: Array<{ label: string; score: number; color: string }> = [];
+ for (const d of data) {
+ for (const s of d.scores) {
+ points.push({ label: d.label, score: s, color: d.color });
+ }
+ }
+ return points;
+}
+
+// Build scatter data for task chart
+function taskScatterData(data: TaskBoxPlotData[], prefix: "s" | "p", color: string): Array<{ label: string; value: number; color: string }> {
+ const key = `${prefix}Scores` as "sScores" | "pScores";
+ const points: Array<{ label: string; value: number; color: string }> = [];
+ for (const d of data) {
+ for (const s of d[key]) {
+ points.push({ label: d.label, value: s, color });
+ }
+ }
+ return points;
+}
+
+// Custom tooltip for model box plot
+function ModelBoxTooltipContent({ active, payload, label }: { active?: boolean; payload?: Array<{ payload: BoxPlotData }>; label?: string }) {
+ if (!active || !payload || payload.length === 0) return null;
+ const d = payload[0].payload;
+ return (
+ <div style={TOOLTIP_STYLE}>
+ <div style={{ marginBottom: 4, fontWeight: 600 }}>{label}</div>
+ <div>Max: {d.max}%</div>
+ <div>Q3: {Math.round(d.q3)}%</div>
+ <div>Median: {Math.round(d.median)}%</div>
+ <div>Q1: {Math.round(d.q1)}%</div>
+ <div>Min: {d.min}%</div>
+ </div>
+ );
+}
+
+// Custom tooltip for task box plot
+function TaskBoxTooltipContent({ active, payload, label }: { active?: boolean; payload?: Array<{ payload: TaskBoxPlotData }>; label?: string }) {
+ if (!active || !payload || payload.length === 0) return null;
+ const d = payload[0].payload;
+ return (
+ <div style={TOOLTIP_STYLE}>
+ <div style={{ marginBottom: 4, fontWeight: 600 }}>{label}</div>
+ <div style={{ marginBottom: 4 }}>
+ <div style={{ color: SMUI.frost2, fontWeight: 600 }}>Score</div>
+ <div>Max: {d.sMax}% / Q3: {Math.round(d.sQ3)}%</div>
+ <div>Median: {Math.round(d.sMedian)}%</div>
+ <div>Q1: {Math.round(d.sQ1)}% / Min: {d.sMin}%</div>
+ </div>
+ <div>
+ <div style={{ color: SMUI.green, fontWeight: 600 }}>Pass Rate</div>
+ <div>Max: {d.pMax}% / Q3: {Math.round(d.pQ3)}%</div>
+ <div>Median: {Math.round(d.pMedian)}%</div>
+ <div>Q1: {Math.round(d.pQ1)}% / Min: {d.pMin}%</div>
+ </div>
+ </div>
+ );
+}
+
export default function Charts({ runs }: ChartsProps) {
if (runs.length === 0) {
return (
@@ -221,16 +387,19 @@ export default function Charts({ runs }: ChartsProps) {
const modelData = aggregateByModel(runs);
const taskData = aggregateByTask(runs);
+ const modelDots = modelScatterData(modelData);
+ const taskScoreDots = taskScatterData(taskData, "s", SMUI.frost2);
+ const taskPassDots = taskScatterData(taskData, "p", SMUI.green);
return (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
<div className="card">
- <h3 style={{ marginBottom: "16px" }}>Average Score by Model</h3>
+ <h3 style={{ marginBottom: "16px" }}>Score Distribution by Model</h3>
<ResponsiveContainer width="100%" height={250}>
- <BarChart data={modelData} barCategoryGap="20%">
+ <ComposedChart data={modelData} barCategoryGap="20%">
<CartesianGrid strokeDasharray="3 3" stroke={SMUI.border} vertical={false} />
<XAxis
- dataKey="model"
+ dataKey="label"
stroke={SMUI.muted}
fontSize={11}
fontFamily="'JetBrains Mono', monospace"
@@ -244,34 +413,41 @@ export default function Charts({ runs }: ChartsProps) {
domain={[0, 100]}
tickLine={false}
axisLine={false}
+ yAxisId="score"
/>
- <Tooltip
- contentStyle={TOOLTIP_STYLE}
- cursor={{ fill: "hsl(217 17% 28% / 0.3)" }}
- formatter={(value: number, name: string) => {
- if (name === "Avg Score %") return [`${value}%`, name];
- return [value, name];
- }}
- labelFormatter={(label: string) => label}
- />
- <Bar dataKey="avg_score" name="Avg Score %" radius={0}>
- <ErrorBar dataKey="errorRange" stroke={SMUI.muted} strokeWidth={1.5} width={6} />
- {modelData.map((entry) => {
- const baseModel = entry.model.split(" ")[0];
- return <Cell key={entry.model} fill={MODEL_COLORS[baseModel] || SMUI.frost2} />;
- })}
+ <Tooltip content={<ModelBoxTooltipContent />} cursor={{ fill: "hsl(217 17% 28% / 0.3)" }} />
+ {/* Invisible base bar to push the visible box up to q1 */}
+ <Bar dataKey="base" stackId="box" fill="transparent" barSize={40} yAxisId="score" />
+ {/* Visible IQR box with custom shape for whiskers and median */}
+ <Bar dataKey="iqr" stackId="box" barSize={40} yAxisId="score" shape={<BoxPlotShape />}>
+ {modelData.map((entry) => (
+ <Cell key={entry.label} fill={entry.color} />
+ ))}
</Bar>
- </BarChart>
+ {/* Individual cell score dots */}
+ <Scatter
+ data={modelDots}
+ dataKey="score"
+ yAxisId="score"
+ fill={SMUI.frost2}
+ fillOpacity={0.5}
+ >
+ <ZAxis range={[20, 20]} />
+ {modelDots.map((dot, i) => (
+ <Cell key={i} fill={dot.color} fillOpacity={0.5} />
+ ))}
+ </Scatter>
+ </ComposedChart>
</ResponsiveContainer>
</div>
<div className="card">
- <h3 style={{ marginBottom: "16px" }}>Pass Rate by Task</h3>
+ <h3 style={{ marginBottom: "16px" }}>Score & Pass Rate Distribution by Task</h3>
<ResponsiveContainer width="100%" height={250}>
- <BarChart data={taskData} barCategoryGap="20%">
+ <ComposedChart data={taskData} barCategoryGap="20%">
<CartesianGrid strokeDasharray="3 3" stroke={SMUI.border} vertical={false} />
<XAxis
- dataKey="task"
+ dataKey="label"
stroke={SMUI.muted}
fontSize={11}
fontFamily="'JetBrains Mono', monospace"
@@ -285,15 +461,9 @@ export default function Charts({ runs }: ChartsProps) {
domain={[0, 100]}
tickLine={false}
axisLine={false}
+ yAxisId="score"
/>
- <Tooltip
- contentStyle={TOOLTIP_STYLE}
- cursor={{ fill: "hsl(217 17% 28% / 0.3)" }}
- formatter={(value: number, name: string) => {
- return [`${value}%`, name];
- }}
- labelFormatter={(label: string) => label}
- />
+ <Tooltip content={<TaskBoxTooltipContent />} cursor={{ fill: "hsl(217 17% 28% / 0.3)" }} />
<Legend
wrapperStyle={{
fontFamily: "'JetBrains Mono', monospace",
@@ -301,14 +471,46 @@ export default function Charts({ runs }: ChartsProps) {
textTransform: "uppercase",
letterSpacing: "0.5px",
}}
+ payload={[
+ { value: "Score %", type: "rect", color: SMUI.frost2 },
+ { value: "Pass Rate %", type: "rect", color: SMUI.green },
+ ]}
/>
- <Bar dataKey="avg_score" fill={SMUI.frost2} name="Avg Score %" radius={0}>
- <ErrorBar dataKey="scoreErrorRange" stroke={SMUI.muted} strokeWidth={1.5} width={6} />
- </Bar>
- <Bar dataKey="pass_rate" fill={SMUI.green} name="Pass Rate %" radius={0}>
- <ErrorBar dataKey="passRateErrorRange" stroke={SMUI.muted} strokeWidth={1.5} width={6} />
- </Bar>
- </BarChart>
+ {/* Score box plot */}
+ <Bar dataKey="sBase" stackId="scoreBox" fill="transparent" barSize={30} yAxisId="score" name=" " legendType="none" />
+ <Bar dataKey="sIqr" stackId="scoreBox" barSize={30} yAxisId="score" name="Score %" shape={TaskBoxPlotShape("s", SMUI.frost2)} fill={SMUI.frost2} />
+ {/* Pass rate box plot */}
+ <Bar dataKey="pBase" stackId="passBox" fill="transparent" barSize={30} yAxisId="score" name=" " legendType="none" />
+ <Bar dataKey="pIqr" stackId="passBox" barSize={30} yAxisId="score" name="Pass Rate %" shape={TaskBoxPlotShape("p", SMUI.green)} fill={SMUI.green} />
+ {/* Individual score dots */}
+ <Scatter
+ data={taskScoreDots}
+ dataKey="value"
+ yAxisId="score"
+ fill={SMUI.frost2}
+ fillOpacity={0.5}
+ legendType="none"
+ >
+ <ZAxis range={[15, 15]} />
+ {taskScoreDots.map((_, i) => (
+ <Cell key={i} fill={SMUI.frost2} fillOpacity={0.5} />
+ ))}
+ </Scatter>
+ {/* Individual pass rate dots */}
+ <Scatter
+ data={taskPassDots}
+ dataKey="value"
+ yAxisId="score"
+ fill={SMUI.green}
+ fillOpacity={0.5}
+ legendType="none"
+ >
+ <ZAxis range={[15, 15]} />
+ {taskPassDots.map((_, i) => (
+ <Cell key={i} fill={SMUI.green} fillOpacity={0.5} />
+ ))}
+ </Scatter>
+ </ComposedChart>
</ResponsiveContainer>
</div>
</div>
diff --git a/dashboard/src/components/ScatterPlot.tsx b/dashboard/src/components/ScatterPlot.tsx
@@ -368,6 +368,7 @@ export default function ScatterPlot({
}: ScatterPlotProps) {
const [xMetric, setXMetric] = React.useState(defaultX);
const [yMetric, setYMetric] = React.useState(defaultY);
+ const [visibleModels, setVisibleModels] = React.useState<Set<string> | null>(null);
const xConf = METRIC_CONFIG[xMetric];
const yConf = METRIC_CONFIG[yMetric];
@@ -393,7 +394,33 @@ export default function ScatterPlot({
totalCells++;
}
- const regions = computeRegions(byModel);
+ // All models present in data (stable order)
+ const MODEL_ORDER: Record<string, number> = { haiku: 1, sonnet: 2, opus: 3 };
+ const allModels = Object.keys(byModel).sort(
+ (a, b) => (MODEL_ORDER[a] || 99) - (MODEL_ORDER[b] || 99)
+ );
+
+ // Initialize visibleModels on first render or when models change
+ const effectiveVisible = visibleModels ?? new Set(allModels);
+
+ const toggleModel = (model: string) => {
+ setVisibleModels((prev) => {
+ const current = prev ?? new Set(allModels);
+ const next = new Set(current);
+ if (next.has(model)) {
+ next.delete(model);
+ } else {
+ next.add(model);
+ }
+ return next;
+ });
+ };
+
+ // Compute regions from ALL data (for stable axis domains)
+ const allRegions = computeRegions(byModel);
+
+ // Filter to visible models for rendering
+ const regions = allRegions.filter((r) => effectiveVisible.has(r.model));
const centroids: CentroidDatum[] = regions.map((r) => ({
model: r.model,
@@ -408,9 +435,9 @@ export default function ScatterPlot({
const [hovered, setHovered] = React.useState<CentroidDatum | null>(null);
- // Compute axis domains with padding
- const allX = regions.flatMap((r) => r.points.map((p) => p[0]));
- const allY = regions.flatMap((r) => r.points.map((p) => p[1]));
+ // Compute axis domains with padding (from ALL data, not just visible)
+ const allX = allRegions.flatMap((r) => r.points.map((p) => p[0]));
+ const allY = allRegions.flatMap((r) => r.points.map((p) => p[1]));
const xMin = Math.min(...allX);
const xMax = Math.max(...allX);
const yMin = Math.min(...allY);
@@ -451,25 +478,34 @@ export default function ScatterPlot({
</span>
</div>
- {/* Legend */}
+ {/* Model toggles */}
<div
style={{
display: "flex",
- gap: "16px",
+ gap: "8px",
justifyContent: "center",
- marginBottom: "8px",
- fontSize: "12px",
- fontFamily: "'JetBrains Mono', monospace",
- color: "hsl(213 14% 65%)",
+ marginBottom: "12px",
+ flexWrap: "wrap",
}}
>
- {regions.map((r) => (
- <div key={r.model} style={{ display: "flex", alignItems: "center", gap: "6px" }}>
- <svg width={12} height={12}>
- <circle cx={6} cy={6} r={5} fill={fallbackColor(r.model)} />
- </svg>
- <span>{r.model}</span>
- </div>
+ {allModels.map((model) => (
+ <button
+ key={model}
+ onClick={() => toggleModel(model)}
+ style={{
+ padding: "4px 10px",
+ borderRadius: "0",
+ border: `1px solid ${fallbackColor(model)}`,
+ background: effectiveVisible.has(model) ? `${fallbackColor(model)}22` : "transparent",
+ color: effectiveVisible.has(model) ? fallbackColor(model) : "var(--text-muted, hsl(213 14% 55%))",
+ opacity: effectiveVisible.has(model) ? 1 : 0.4,
+ cursor: "pointer",
+ fontSize: "0.75rem",
+ fontFamily: "var(--font-mono, 'JetBrains Mono', monospace)",
+ }}
+ >
+ {model}
+ </button>
))}
</div>
@@ -502,7 +538,7 @@ export default function ScatterPlot({
{/* Hidden scatter to seed axis scales with data */}
<ZAxis range={[0, 0]} />
<Scatter
- data={regions.flatMap((r) => r.points.map((p) => ({ x: p[0], y: p[1] })))}
+ data={allRegions.flatMap((r) => r.points.map((p) => ({ x: p[0], y: p[1] })))}
fill="transparent"
isAnimationActive={false}
/>