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:
parent
1d3917a44e
commit
32535a92ea
3 changed files with 1133 additions and 1 deletions
|
|
@ -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.
|
||||
<!-- 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).
|
||||
|
||||
|
|
|
|||
305
frontend/src/components/ScoreChart.test.tsx
Normal file
305
frontend/src/components/ScoreChart.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
826
frontend/src/components/ScoreChart.tsx
Normal file
826
frontend/src/components/ScoreChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue