MAESTRO: Build ScoreChart component with scatter, bar, and line chart types

Custom SVG-based charts with no external dependencies. Scatter plot for
score vs parameter value, bar chart for top N configs comparison, line
chart for score progression over time. Interactive tooltips, click
callbacks, chart type switching, dark mode support. 30 tests added.
This commit is contained in:
John Lightner 2026-04-07 03:29:17 -05:00
parent 1d3917a44e
commit 32535a92ea
3 changed files with 1133 additions and 1 deletions

View file

@ -41,7 +41,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [x] Implement the Compare page (frontend/src/pages/Compare.tsx). Side-by-side comparison of any two runs. Two columns, each with a run selector (dropdown or search). Show: config diff (highlight what changed), response diff (inline text diff with highlights), score comparison (bar chart overlay), and a "pick winner" button for human rating. - [x] Implement the Compare page (frontend/src/pages/Compare.tsx). Side-by-side comparison of any two runs. Two columns, each with a run selector (dropdown or search). Show: config diff (highlight what changed), response diff (inline text diff with highlights), score comparison (bar chart overlay), and a "pick winner" button for human rating.
<!-- Implemented in ComparePage.tsx. Two-column run selectors with experiment→run cascading dropdowns (URL state synced via searchParams). Config diff with color-coded entries (same/changed/added/removed). Line-level LCS response diff with added/removed/same highlighting. Score comparison with overlaid indigo/emerald bars per scorer. Full RunCard detail view for each run side by side. "Pick Winner" buttons submit human_preference score via runs.score() API with metadata. Winner state resets on run change. App.test.tsx updated for new page behavior. 15 tests added (5 unit tests for diff helpers + 10 component integration tests). --> <!-- Implemented in ComparePage.tsx. Two-column run selectors with experiment→run cascading dropdowns (URL state synced via searchParams). Config diff with color-coded entries (same/changed/added/removed). Line-level LCS response diff with added/removed/same highlighting. Score comparison with overlaid indigo/emerald bars per scorer. Full RunCard detail view for each run side by side. "Pick Winner" buttons submit human_preference score via runs.score() API with metadata. Winner state resets on run change. App.test.tsx updated for new page behavior. 15 tests added (5 unit tests for diff helpers + 10 component integration tests). -->
- [ ] Build the Score Chart component (frontend/src/components/ScoreChart.tsx). Multiple chart types: (1) scatter plot of score vs parameter value (e.g. score vs temperature), (2) bar chart comparing top N configs, (3) line chart showing score progression over time as sweep runs. Use a lightweight charting library (recharts via CDN). - [x] Build the Score Chart component (frontend/src/components/ScoreChart.tsx). Multiple chart types: (1) scatter plot of score vs parameter value (e.g. score vs temperature), (2) bar chart comparing top N configs, (3) line chart showing score progression over time as sweep runs. Use a lightweight charting library (recharts via CDN).
<!-- Implemented ScoreChart with custom SVG-based charts (zero external dependencies, consistent with project's dependency-minimal approach). Three chart types: ScatterPlot (score vs parameter value with filtered points, grid lines, axis labels), BarChart (top N configs sorted by score, truncated labels, score annotations), LineChart (score progression with area gradient fill, timestamp-sorted, adaptive tick labels). All charts share: interactive hover tooltips, click-to-select callbacks, dark mode support, responsive SVG viewBox. Chart type selector allows switching between views at runtime. Handles edge cases: identical scores, negative values, single data point, missing paramValue. 30 tests added. -->
- [ ] Implement the Admin page (frontend/src/pages/Admin.tsx). Settings management: toggle guest access, manage API keys (generate/revoke), configure default endpoint, set token budgets, view system stats (total runs, cache entries, storage usage). Include a section for webhook management (list/create/delete). - [ ] Implement the Admin page (frontend/src/pages/Admin.tsx). Settings management: toggle guest access, manage API keys (generate/revoke), configure default endpoint, set token budgets, view system stats (total runs, cache entries, storage usage). Include a section for webhook management (list/create/delete).

View file

@ -0,0 +1,305 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import ScoreChart from "./ScoreChart";
import type { ScoreDataPoint, ScoreChartProps } from "./ScoreChart";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makePoint(overrides: Partial<ScoreDataPoint> = {}): ScoreDataPoint {
return {
id: "pt-1",
label: "gpt-4 t=0.7",
score: 0.85,
paramValue: 0.7,
timestamp: 1,
...overrides,
};
}
function makePoints(n: number): ScoreDataPoint[] {
return Array.from({ length: n }, (_, i) => ({
id: `pt-${i}`,
label: `config-${i}`,
score: 0.5 + Math.round(((i * 7) % 11) * 4) / 100,
paramValue: 0.1 + i * 0.2,
timestamp: i + 1,
}));
}
function renderChart(overrides: Partial<ScoreChartProps> = {}) {
const props: ScoreChartProps = {
data: makePoints(5),
type: "scatter",
...overrides,
};
return { ...render(<ScoreChart {...props} />), props };
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
describe("ScoreChart — empty state", () => {
it("renders empty placeholder when data is empty", () => {
renderChart({ data: [] });
expect(screen.getByTestId("score-chart-empty")).toBeInTheDocument();
expect(screen.getByText("No data to display")).toBeInTheDocument();
});
it("does not render chart container when empty", () => {
renderChart({ data: [] });
expect(screen.queryByTestId("score-chart")).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Title & chart type selector
// ---------------------------------------------------------------------------
describe("ScoreChart — header", () => {
it("renders the title when provided", () => {
renderChart({ title: "Score vs Temperature" });
expect(screen.getByTestId("chart-title")).toHaveTextContent(
"Score vs Temperature",
);
});
it("does not render title element when title is omitted", () => {
renderChart({ title: undefined });
expect(screen.queryByTestId("chart-title")).not.toBeInTheDocument();
});
it("renders chart type selector with three buttons", () => {
renderChart();
const selector = screen.getByTestId("chart-type-selector");
expect(within(selector).getByTestId("chart-type-scatter")).toBeInTheDocument();
expect(within(selector).getByTestId("chart-type-bar")).toBeInTheDocument();
expect(within(selector).getByTestId("chart-type-line")).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Scatter plot
// ---------------------------------------------------------------------------
describe("ScoreChart — scatter plot", () => {
it("renders scatter plot SVG", () => {
renderChart({ type: "scatter" });
expect(screen.getByTestId("scatter-plot")).toBeInTheDocument();
});
it("renders one circle per data point", () => {
const data = makePoints(4);
renderChart({ type: "scatter", data });
data.forEach((d) => {
expect(screen.getByTestId(`scatter-point-${d.id}`)).toBeInTheDocument();
});
});
it("filters out points without paramValue", () => {
const data = [
makePoint({ id: "a", paramValue: 0.5 }),
makePoint({ id: "b", paramValue: undefined }),
];
renderChart({ type: "scatter", data });
expect(screen.getByTestId("scatter-point-a")).toBeInTheDocument();
expect(screen.queryByTestId("scatter-point-b")).not.toBeInTheDocument();
});
it("renders axis labels when provided", () => {
renderChart({ type: "scatter", xLabel: "Temperature", yLabel: "Score" });
expect(screen.getByTestId("x-axis-label")).toHaveTextContent("Temperature");
expect(screen.getByTestId("y-axis-label")).toHaveTextContent("Score");
});
it("fires onPointClick when a scatter point is clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const data = [makePoint({ id: "run-1" })];
renderChart({ type: "scatter", data, onPointClick: onClick });
await user.click(screen.getByTestId("scatter-point-run-1"));
expect(onClick).toHaveBeenCalledWith("run-1");
});
it("shows tooltip on hover", async () => {
const user = userEvent.setup();
const data = [makePoint({ id: "r1", label: "gpt-4" })];
renderChart({ type: "scatter", data });
await user.hover(screen.getByTestId("scatter-point-r1"));
expect(screen.getByTestId("chart-tooltip")).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Bar chart
// ---------------------------------------------------------------------------
describe("ScoreChart — bar chart", () => {
it("renders bar chart SVG", () => {
renderChart({ type: "bar" });
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
it("renders one bar per data point (up to topN)", () => {
const data = makePoints(5);
renderChart({ type: "bar", data, topN: 3 });
// Only top 3 by score should render
const bars = screen.getAllByTestId(/^bar-pt-/);
expect(bars.length).toBe(3);
});
it("renders all bars when topN exceeds data length", () => {
const data = makePoints(3);
renderChart({ type: "bar", data, topN: 10 });
const bars = screen.getAllByTestId(/^bar-pt-/);
expect(bars.length).toBe(3);
});
it("renders bar labels", () => {
const data = [makePoint({ id: "x1", label: "short" })];
renderChart({ type: "bar", data });
expect(screen.getByTestId("bar-label-x1")).toHaveTextContent("short");
});
it("truncates long labels to 9 chars + ellipsis", () => {
const data = [makePoint({ id: "x1", label: "a-very-long-config-name" })];
renderChart({ type: "bar", data });
expect(screen.getByTestId("bar-label-x1")).toHaveTextContent("a-very-lo…");
});
it("fires onPointClick when a bar is clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const data = [makePoint({ id: "r2" })];
renderChart({ type: "bar", data, onPointClick: onClick });
await user.click(screen.getByTestId("bar-r2"));
expect(onClick).toHaveBeenCalledWith("r2");
});
});
// ---------------------------------------------------------------------------
// Line chart
// ---------------------------------------------------------------------------
describe("ScoreChart — line chart", () => {
it("renders line chart SVG", () => {
renderChart({ type: "line" });
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
});
it("renders line path", () => {
renderChart({ type: "line" });
expect(screen.getByTestId("line-path")).toBeInTheDocument();
});
it("renders one point per data entry", () => {
const data = makePoints(4);
renderChart({ type: "line", data });
data.forEach((d) => {
expect(screen.getByTestId(`line-point-${d.id}`)).toBeInTheDocument();
});
});
it("sorts data by timestamp", () => {
const data = [
makePoint({ id: "c", timestamp: 3, score: 0.3 }),
makePoint({ id: "a", timestamp: 1, score: 0.1 }),
makePoint({ id: "b", timestamp: 2, score: 0.2 }),
];
renderChart({ type: "line", data });
// All points should render
expect(screen.getByTestId("line-point-a")).toBeInTheDocument();
expect(screen.getByTestId("line-point-b")).toBeInTheDocument();
expect(screen.getByTestId("line-point-c")).toBeInTheDocument();
});
it("renders axis labels when provided", () => {
renderChart({ type: "line", xLabel: "Run #", yLabel: "Score" });
expect(screen.getByTestId("x-axis-label")).toHaveTextContent("Run #");
expect(screen.getByTestId("y-axis-label")).toHaveTextContent("Score");
});
it("fires onPointClick when a line point is clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const data = [makePoint({ id: "lp-1" })];
renderChart({ type: "line", data, onPointClick: onClick });
await user.click(screen.getByTestId("line-point-lp-1"));
expect(onClick).toHaveBeenCalledWith("lp-1");
});
it("handles single data point without crashing", () => {
const data = [makePoint({ id: "solo" })];
renderChart({ type: "line", data });
expect(screen.getByTestId("line-point-solo")).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Chart type switching
// ---------------------------------------------------------------------------
describe("ScoreChart — type switching", () => {
it("switches from scatter to bar chart", async () => {
const user = userEvent.setup();
renderChart({ type: "scatter" });
expect(screen.getByTestId("scatter-plot")).toBeInTheDocument();
await user.click(screen.getByTestId("chart-type-bar"));
expect(screen.queryByTestId("scatter-plot")).not.toBeInTheDocument();
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
it("switches from scatter to line chart", async () => {
const user = userEvent.setup();
renderChart({ type: "scatter" });
await user.click(screen.getByTestId("chart-type-line"));
expect(screen.queryByTestId("scatter-plot")).not.toBeInTheDocument();
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
});
it("switches from line to scatter chart", async () => {
const user = userEvent.setup();
renderChart({ type: "line" });
await user.click(screen.getByTestId("chart-type-scatter"));
expect(screen.queryByTestId("line-chart")).not.toBeInTheDocument();
expect(screen.getByTestId("scatter-plot")).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
describe("ScoreChart — edge cases", () => {
it("handles all identical scores without crashing", () => {
const data = Array.from({ length: 3 }, (_, i) =>
makePoint({ id: `same-${i}`, score: 0.5, paramValue: i, timestamp: i }),
);
renderChart({ type: "scatter", data });
expect(screen.getByTestId("scatter-plot")).toBeInTheDocument();
});
it("handles negative scores", () => {
const data = [
makePoint({ id: "neg", score: -0.5, paramValue: 1, timestamp: 1 }),
makePoint({ id: "pos", score: 0.5, paramValue: 2, timestamp: 2 }),
];
renderChart({ type: "line", data });
expect(screen.getByTestId("line-point-neg")).toBeInTheDocument();
expect(screen.getByTestId("line-point-pos")).toBeInTheDocument();
});
it("handles large number of data points in bar chart with default topN", () => {
const data = makePoints(20);
renderChart({ type: "bar", data });
// Default topN is 10
const bars = screen.getAllByTestId(/^bar-pt-/);
expect(bars.length).toBe(10);
});
});

View file

@ -0,0 +1,826 @@
import { useState, useMemo } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ScoreDataPoint {
/** Unique identifier for the data point (e.g. run_id) */
id: string;
/** Label shown on hover / axis */
label: string;
/** Numeric score value (y-axis for scatter/line, bar height) */
score: number;
/** Parameter value for scatter plot x-axis */
paramValue?: number;
/** Timestamp or sequence index for line chart x-axis */
timestamp?: string | number;
}
export type ChartType = "scatter" | "bar" | "line";
export interface ScoreChartProps {
/** Data points to chart */
data: ScoreDataPoint[];
/** Which chart type to render */
type: ChartType;
/** Chart title */
title?: string;
/** X-axis label */
xLabel?: string;
/** Y-axis label */
yLabel?: string;
/** Callback when a data point is clicked */
onPointClick?: (id: string) => void;
/** Maximum number of bars for bar chart (top N by score) */
topN?: number;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CHART_WIDTH = 600;
const CHART_HEIGHT = 320;
const PADDING = { top: 30, right: 30, bottom: 50, left: 60 };
const PLOT_W = CHART_WIDTH - PADDING.left - PADDING.right;
const PLOT_H = CHART_HEIGHT - PADDING.top - PADDING.bottom;
const COLORS = [
"#6366f1", // indigo-500
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#ef4444", // red-500
"#8b5cf6", // violet-500
"#06b6d4", // cyan-500
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function niceRange(min: number, max: number): [number, number] {
if (min === max) return [min - 1, max + 1];
const padding = (max - min) * 0.1;
return [min - padding, max + padding];
}
function tickValues(min: number, max: number, count: number): number[] {
const step = (max - min) / (count - 1);
return Array.from({ length: count }, (_, i) => min + step * i);
}
function formatTick(v: number): string {
if (Math.abs(v) >= 1000) return v.toFixed(0);
if (Number.isInteger(v)) return v.toString();
return v.toFixed(2);
}
function scaleLinear(
value: number,
domainMin: number,
domainMax: number,
rangeMin: number,
rangeMax: number,
): number {
if (domainMax === domainMin) return (rangeMin + rangeMax) / 2;
return (
rangeMin +
((value - domainMin) / (domainMax - domainMin)) * (rangeMax - rangeMin)
);
}
// ---------------------------------------------------------------------------
// Chart Type Selector
// ---------------------------------------------------------------------------
function ChartTypeButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
active
? "bg-indigo-600 text-white"
: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600"
}`}
data-testid={`chart-type-${label.toLowerCase()}`}
>
{label}
</button>
);
}
// ---------------------------------------------------------------------------
// Tooltip
// ---------------------------------------------------------------------------
function Tooltip({
x,
y,
label,
value,
}: {
x: number;
y: number;
label: string;
value: string;
}) {
return (
<g data-testid="chart-tooltip">
<rect
x={x + 8}
y={y - 28}
width={Math.max(label.length, value.length) * 7 + 16}
height={36}
rx={4}
fill="rgba(15,23,42,0.9)"
/>
<text
x={x + 16}
y={y - 14}
fontSize={11}
fill="#e2e8f0"
fontFamily="monospace"
>
{label}
</text>
<text
x={x + 16}
y={y}
fontSize={11}
fill="#94a3b8"
fontFamily="monospace"
>
{value}
</text>
</g>
);
}
// ---------------------------------------------------------------------------
// Scatter Plot
// ---------------------------------------------------------------------------
function ScatterPlot({
data,
xLabel,
yLabel,
onPointClick,
}: {
data: ScoreDataPoint[];
xLabel?: string;
yLabel?: string;
onPointClick?: (id: string) => void;
}) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const points = useMemo(
() => data.filter((d) => d.paramValue != null),
[data],
);
const xValues = points.map((d) => d.paramValue!);
const yValues = points.map((d) => d.score);
const [xMin, xMax] = niceRange(
Math.min(...xValues),
Math.max(...xValues),
);
const [yMin, yMax] = niceRange(
Math.min(...yValues),
Math.max(...yValues),
);
const xTicks = tickValues(xMin, xMax, 5);
const yTicks = tickValues(yMin, yMax, 5);
return (
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full h-auto"
data-testid="scatter-plot"
>
{/* Grid lines */}
{yTicks.map((t) => {
const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H);
return (
<line
key={`yg-${t}`}
x1={PADDING.left}
y1={y}
x2={PADDING.left + PLOT_W}
y2={y}
stroke="#e2e8f0"
strokeDasharray="4 4"
className="dark:stroke-slate-700"
/>
);
})}
{/* Axes */}
<line
x1={PADDING.left}
y1={PADDING.top}
x2={PADDING.left}
y2={PADDING.top + PLOT_H}
stroke="#94a3b8"
/>
<line
x1={PADDING.left}
y1={PADDING.top + PLOT_H}
x2={PADDING.left + PLOT_W}
y2={PADDING.top + PLOT_H}
stroke="#94a3b8"
/>
{/* X ticks */}
{xTicks.map((t) => {
const x = PADDING.left + scaleLinear(t, xMin, xMax, 0, PLOT_W);
return (
<text
key={`xt-${t}`}
x={x}
y={PADDING.top + PLOT_H + 18}
fontSize={10}
fill="#94a3b8"
textAnchor="middle"
>
{formatTick(t)}
</text>
);
})}
{/* Y ticks */}
{yTicks.map((t) => {
const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H);
return (
<text
key={`yt-${t}`}
x={PADDING.left - 8}
y={y + 4}
fontSize={10}
fill="#94a3b8"
textAnchor="end"
>
{formatTick(t)}
</text>
);
})}
{/* Axis labels */}
{xLabel && (
<text
x={PADDING.left + PLOT_W / 2}
y={CHART_HEIGHT - 4}
fontSize={12}
fill="#64748b"
textAnchor="middle"
data-testid="x-axis-label"
>
{xLabel}
</text>
)}
{yLabel && (
<text
x={14}
y={PADDING.top + PLOT_H / 2}
fontSize={12}
fill="#64748b"
textAnchor="middle"
transform={`rotate(-90, 14, ${PADDING.top + PLOT_H / 2})`}
data-testid="y-axis-label"
>
{yLabel}
</text>
)}
{/* Data points */}
{points.map((d, i) => {
const cx = PADDING.left + scaleLinear(d.paramValue!, xMin, xMax, 0, PLOT_W);
const cy = PADDING.top + PLOT_H - scaleLinear(d.score, yMin, yMax, 0, PLOT_H);
const isHovered = hoveredId === d.id;
return (
<circle
key={d.id}
cx={cx}
cy={cy}
r={isHovered ? 7 : 5}
fill={COLORS[i % COLORS.length]}
fillOpacity={0.8}
stroke={isHovered ? "#fff" : "none"}
strokeWidth={2}
className="cursor-pointer transition-all"
data-testid={`scatter-point-${d.id}`}
onMouseEnter={() => setHoveredId(d.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => onPointClick?.(d.id)}
/>
);
})}
{/* Tooltip */}
{hoveredId &&
(() => {
const d = points.find((p) => p.id === hoveredId);
if (!d) return null;
const cx = PADDING.left + scaleLinear(d.paramValue!, xMin, xMax, 0, PLOT_W);
const cy = PADDING.top + PLOT_H - scaleLinear(d.score, yMin, yMax, 0, PLOT_H);
return (
<Tooltip
x={cx}
y={cy}
label={d.label}
value={`score: ${d.score.toFixed(3)}`}
/>
);
})()}
</svg>
);
}
// ---------------------------------------------------------------------------
// Bar Chart
// ---------------------------------------------------------------------------
function BarChart({
data,
topN,
yLabel,
onPointClick,
}: {
data: ScoreDataPoint[];
topN: number;
yLabel?: string;
onPointClick?: (id: string) => void;
}) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const sorted = useMemo(
() => [...data].sort((a, b) => b.score - a.score).slice(0, topN),
[data, topN],
);
const maxScore = Math.max(...sorted.map((d) => d.score), 0.01);
const [yMin, yMax] = [0, maxScore * 1.1];
const yTicks = tickValues(yMin, yMax, 5);
const barWidth = Math.min(40, (PLOT_W - 20) / sorted.length - 4);
const totalBarsWidth = sorted.length * (barWidth + 4);
const barsStartX = PADDING.left + (PLOT_W - totalBarsWidth) / 2;
return (
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full h-auto"
data-testid="bar-chart"
>
{/* Grid lines */}
{yTicks.map((t) => {
const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H);
return (
<line
key={`yg-${t}`}
x1={PADDING.left}
y1={y}
x2={PADDING.left + PLOT_W}
y2={y}
stroke="#e2e8f0"
strokeDasharray="4 4"
className="dark:stroke-slate-700"
/>
);
})}
{/* Y axis */}
<line
x1={PADDING.left}
y1={PADDING.top}
x2={PADDING.left}
y2={PADDING.top + PLOT_H}
stroke="#94a3b8"
/>
<line
x1={PADDING.left}
y1={PADDING.top + PLOT_H}
x2={PADDING.left + PLOT_W}
y2={PADDING.top + PLOT_H}
stroke="#94a3b8"
/>
{/* Y ticks */}
{yTicks.map((t) => {
const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H);
return (
<text
key={`yt-${t}`}
x={PADDING.left - 8}
y={y + 4}
fontSize={10}
fill="#94a3b8"
textAnchor="end"
>
{formatTick(t)}
</text>
);
})}
{yLabel && (
<text
x={14}
y={PADDING.top + PLOT_H / 2}
fontSize={12}
fill="#64748b"
textAnchor="middle"
transform={`rotate(-90, 14, ${PADDING.top + PLOT_H / 2})`}
data-testid="y-axis-label"
>
{yLabel}
</text>
)}
{/* Bars */}
{sorted.map((d, i) => {
const barH = scaleLinear(d.score, yMin, yMax, 0, PLOT_H);
const x = barsStartX + i * (barWidth + 4);
const y = PADDING.top + PLOT_H - barH;
const isHovered = hoveredId === d.id;
return (
<g key={d.id}>
<rect
x={x}
y={y}
width={barWidth}
height={barH}
rx={3}
fill={COLORS[i % COLORS.length]}
fillOpacity={isHovered ? 1 : 0.8}
className="cursor-pointer transition-all"
data-testid={`bar-${d.id}`}
onMouseEnter={() => setHoveredId(d.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => onPointClick?.(d.id)}
/>
{/* X label */}
<text
x={x + barWidth / 2}
y={PADDING.top + PLOT_H + 14}
fontSize={9}
fill="#94a3b8"
textAnchor="middle"
data-testid={`bar-label-${d.id}`}
>
{d.label.length > 10 ? d.label.slice(0, 9) + "…" : d.label}
</text>
{/* Score on top */}
<text
x={x + barWidth / 2}
y={y - 4}
fontSize={10}
fill="#64748b"
textAnchor="middle"
>
{d.score.toFixed(2)}
</text>
</g>
);
})}
{/* Tooltip */}
{hoveredId &&
(() => {
const idx = sorted.findIndex((d) => d.id === hoveredId);
if (idx < 0) return null;
const d = sorted[idx];
const x = barsStartX + idx * (barWidth + 4) + barWidth / 2;
const barH = scaleLinear(d.score, yMin, yMax, 0, PLOT_H);
const y = PADDING.top + PLOT_H - barH;
return (
<Tooltip
x={x}
y={y}
label={d.label}
value={`score: ${d.score.toFixed(3)}`}
/>
);
})()}
</svg>
);
}
// ---------------------------------------------------------------------------
// Line Chart
// ---------------------------------------------------------------------------
function LineChart({
data,
xLabel,
yLabel,
onPointClick,
}: {
data: ScoreDataPoint[];
xLabel?: string;
yLabel?: string;
onPointClick?: (id: string) => void;
}) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
// Sort by timestamp/sequence for line chart
const sorted = useMemo(() => {
return [...data].sort((a, b) => {
const ta = a.timestamp ?? 0;
const tb = b.timestamp ?? 0;
if (typeof ta === "number" && typeof tb === "number") return ta - tb;
return String(ta).localeCompare(String(tb));
});
}, [data]);
const yValues = sorted.map((d) => d.score);
const [yMin, yMax] = niceRange(Math.min(...yValues), Math.max(...yValues));
const yTicks = tickValues(yMin, yMax, 5);
// Evenly space points on x-axis
const xStep = sorted.length > 1 ? PLOT_W / (sorted.length - 1) : PLOT_W / 2;
const pointCoords = sorted.map((d, i) => ({
x: PADDING.left + (sorted.length > 1 ? i * xStep : PLOT_W / 2),
y: PADDING.top + PLOT_H - scaleLinear(d.score, yMin, yMax, 0, PLOT_H),
d,
}));
const linePath = pointCoords
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
.join(" ");
// Area fill path (line + close to bottom)
const areaPath =
pointCoords.length > 0
? `${linePath} L ${pointCoords[pointCoords.length - 1].x} ${PADDING.top + PLOT_H} L ${pointCoords[0].x} ${PADDING.top + PLOT_H} Z`
: "";
return (
<svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
className="w-full h-auto"
data-testid="line-chart"
>
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.02} />
</linearGradient>
</defs>
{/* Grid lines */}
{yTicks.map((t) => {
const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H);
return (
<line
key={`yg-${t}`}
x1={PADDING.left}
y1={y}
x2={PADDING.left + PLOT_W}
y2={y}
stroke="#e2e8f0"
strokeDasharray="4 4"
className="dark:stroke-slate-700"
/>
);
})}
{/* Axes */}
<line
x1={PADDING.left}
y1={PADDING.top}
x2={PADDING.left}
y2={PADDING.top + PLOT_H}
stroke="#94a3b8"
/>
<line
x1={PADDING.left}
y1={PADDING.top + PLOT_H}
x2={PADDING.left + PLOT_W}
y2={PADDING.top + PLOT_H}
stroke="#94a3b8"
/>
{/* X tick labels */}
{sorted.map((d, i) => {
const x = PADDING.left + (sorted.length > 1 ? i * xStep : PLOT_W / 2);
// Show fewer labels when there are many points
if (sorted.length > 10 && i % Math.ceil(sorted.length / 10) !== 0 && i !== sorted.length - 1) return null;
const tickLabel =
d.timestamp != null
? typeof d.timestamp === "number"
? `#${d.timestamp}`
: String(d.timestamp).slice(11, 16) || String(d.timestamp).slice(0, 10)
: `#${i + 1}`;
return (
<text
key={`xt-${d.id}`}
x={x}
y={PADDING.top + PLOT_H + 18}
fontSize={9}
fill="#94a3b8"
textAnchor="middle"
>
{tickLabel}
</text>
);
})}
{/* Y ticks */}
{yTicks.map((t) => {
const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H);
return (
<text
key={`yt-${t}`}
x={PADDING.left - 8}
y={y + 4}
fontSize={10}
fill="#94a3b8"
textAnchor="end"
>
{formatTick(t)}
</text>
);
})}
{/* Axis labels */}
{xLabel && (
<text
x={PADDING.left + PLOT_W / 2}
y={CHART_HEIGHT - 4}
fontSize={12}
fill="#64748b"
textAnchor="middle"
data-testid="x-axis-label"
>
{xLabel}
</text>
)}
{yLabel && (
<text
x={14}
y={PADDING.top + PLOT_H / 2}
fontSize={12}
fill="#64748b"
textAnchor="middle"
transform={`rotate(-90, 14, ${PADDING.top + PLOT_H / 2})`}
data-testid="y-axis-label"
>
{yLabel}
</text>
)}
{/* Area fill */}
{areaPath && <path d={areaPath} fill="url(#areaGrad)" />}
{/* Line */}
{linePath && (
<path
d={linePath}
fill="none"
stroke="#6366f1"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
data-testid="line-path"
/>
)}
{/* Data points */}
{pointCoords.map(({ x, y, d }) => {
const isHovered = hoveredId === d.id;
return (
<circle
key={d.id}
cx={x}
cy={y}
r={isHovered ? 6 : 4}
fill="#6366f1"
stroke={isHovered ? "#fff" : "#6366f1"}
strokeWidth={2}
className="cursor-pointer transition-all"
data-testid={`line-point-${d.id}`}
onMouseEnter={() => setHoveredId(d.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => onPointClick?.(d.id)}
/>
);
})}
{/* Tooltip */}
{hoveredId &&
(() => {
const pc = pointCoords.find((p) => p.d.id === hoveredId);
if (!pc) return null;
return (
<Tooltip
x={pc.x}
y={pc.y}
label={pc.d.label}
value={`score: ${pc.d.score.toFixed(3)}`}
/>
);
})()}
</svg>
);
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export default function ScoreChart({
data,
type: initialType,
title,
xLabel,
yLabel,
onPointClick,
topN = 10,
}: ScoreChartProps) {
const [chartType, setChartType] = useState<ChartType>(initialType);
if (data.length === 0) {
return (
<div
className="flex items-center justify-center h-48 border border-dashed border-slate-300 dark:border-slate-600 rounded-lg text-slate-400 dark:text-slate-500 text-sm"
data-testid="score-chart-empty"
>
No data to display
</div>
);
}
return (
<div
className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-4"
data-testid="score-chart"
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
{title && (
<h3
className="text-sm font-semibold text-slate-700 dark:text-slate-200"
data-testid="chart-title"
>
{title}
</h3>
)}
<div className="flex gap-1" data-testid="chart-type-selector">
<ChartTypeButton
active={chartType === "scatter"}
label="Scatter"
onClick={() => setChartType("scatter")}
/>
<ChartTypeButton
active={chartType === "bar"}
label="Bar"
onClick={() => setChartType("bar")}
/>
<ChartTypeButton
active={chartType === "line"}
label="Line"
onClick={() => setChartType("line")}
/>
</div>
</div>
{/* Chart */}
{chartType === "scatter" && (
<ScatterPlot
data={data}
xLabel={xLabel}
yLabel={yLabel}
onPointClick={onPointClick}
/>
)}
{chartType === "bar" && (
<BarChart
data={data}
topN={topN}
yLabel={yLabel}
onPointClick={onPointClick}
/>
)}
{chartType === "line" && (
<LineChart
data={data}
xLabel={xLabel}
yLabel={yLabel}
onPointClick={onPointClick}
/>
)}
</div>
);
}