diff --git a/Auto Run Docs/02b-frontend-dashboard.md b/Auto Run Docs/02b-frontend-dashboard.md index 474eae0..8a449b3 100644 --- a/Auto Run Docs/02b-frontend-dashboard.md +++ b/Auto Run Docs/02b-frontend-dashboard.md @@ -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. -- [ ] 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). + - [ ] 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). diff --git a/frontend/src/components/ScoreChart.test.tsx b/frontend/src/components/ScoreChart.test.tsx new file mode 100644 index 0000000..5b25b8e --- /dev/null +++ b/frontend/src/components/ScoreChart.test.tsx @@ -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 { + 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 = {}) { + const props: ScoreChartProps = { + data: makePoints(5), + type: "scatter", + ...overrides, + }; + return { ...render(), 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); + }); +}); diff --git a/frontend/src/components/ScoreChart.tsx b/frontend/src/components/ScoreChart.tsx new file mode 100644 index 0000000..9cc3855 --- /dev/null +++ b/frontend/src/components/ScoreChart.tsx @@ -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 ( + + ); +} + +// --------------------------------------------------------------------------- +// Tooltip +// --------------------------------------------------------------------------- + +function Tooltip({ + x, + y, + label, + value, +}: { + x: number; + y: number; + label: string; + value: string; +}) { + return ( + + + + {label} + + + {value} + + + ); +} + +// --------------------------------------------------------------------------- +// Scatter Plot +// --------------------------------------------------------------------------- + +function ScatterPlot({ + data, + xLabel, + yLabel, + onPointClick, +}: { + data: ScoreDataPoint[]; + xLabel?: string; + yLabel?: string; + onPointClick?: (id: string) => void; +}) { + const [hoveredId, setHoveredId] = useState(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 ( + + {/* Grid lines */} + {yTicks.map((t) => { + const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H); + return ( + + ); + })} + + {/* Axes */} + + + + {/* X ticks */} + {xTicks.map((t) => { + const x = PADDING.left + scaleLinear(t, xMin, xMax, 0, PLOT_W); + return ( + + {formatTick(t)} + + ); + })} + + {/* Y ticks */} + {yTicks.map((t) => { + const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H); + return ( + + {formatTick(t)} + + ); + })} + + {/* Axis labels */} + {xLabel && ( + + {xLabel} + + )} + {yLabel && ( + + {yLabel} + + )} + + {/* 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 ( + 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 ( + + ); + })()} + + ); +} + +// --------------------------------------------------------------------------- +// Bar Chart +// --------------------------------------------------------------------------- + +function BarChart({ + data, + topN, + yLabel, + onPointClick, +}: { + data: ScoreDataPoint[]; + topN: number; + yLabel?: string; + onPointClick?: (id: string) => void; +}) { + const [hoveredId, setHoveredId] = useState(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 ( + + {/* Grid lines */} + {yTicks.map((t) => { + const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H); + return ( + + ); + })} + + {/* Y axis */} + + + + {/* Y ticks */} + {yTicks.map((t) => { + const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H); + return ( + + {formatTick(t)} + + ); + })} + + {yLabel && ( + + {yLabel} + + )} + + {/* 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 ( + + setHoveredId(d.id)} + onMouseLeave={() => setHoveredId(null)} + onClick={() => onPointClick?.(d.id)} + /> + {/* X label */} + + {d.label.length > 10 ? d.label.slice(0, 9) + "…" : d.label} + + {/* Score on top */} + + {d.score.toFixed(2)} + + + ); + })} + + {/* 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 ( + + ); + })()} + + ); +} + +// --------------------------------------------------------------------------- +// Line Chart +// --------------------------------------------------------------------------- + +function LineChart({ + data, + xLabel, + yLabel, + onPointClick, +}: { + data: ScoreDataPoint[]; + xLabel?: string; + yLabel?: string; + onPointClick?: (id: string) => void; +}) { + const [hoveredId, setHoveredId] = useState(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 ( + + + + + + + + + {/* Grid lines */} + {yTicks.map((t) => { + const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H); + return ( + + ); + })} + + {/* Axes */} + + + + {/* 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 ( + + {tickLabel} + + ); + })} + + {/* Y ticks */} + {yTicks.map((t) => { + const y = PADDING.top + PLOT_H - scaleLinear(t, yMin, yMax, 0, PLOT_H); + return ( + + {formatTick(t)} + + ); + })} + + {/* Axis labels */} + {xLabel && ( + + {xLabel} + + )} + {yLabel && ( + + {yLabel} + + )} + + {/* Area fill */} + {areaPath && } + + {/* Line */} + {linePath && ( + + )} + + {/* Data points */} + {pointCoords.map(({ x, y, d }) => { + const isHovered = hoveredId === d.id; + return ( + 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 ( + + ); + })()} + + ); +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +export default function ScoreChart({ + data, + type: initialType, + title, + xLabel, + yLabel, + onPointClick, + topN = 10, +}: ScoreChartProps) { + const [chartType, setChartType] = useState(initialType); + + if (data.length === 0) { + return ( +
+ No data to display +
+ ); + } + + return ( +
+ {/* Header */} +
+ {title && ( +

+ {title} +

+ )} +
+ setChartType("scatter")} + /> + setChartType("bar")} + /> + setChartType("line")} + /> +
+
+ + {/* Chart */} + {chartType === "scatter" && ( + + )} + {chartType === "bar" && ( + + )} + {chartType === "line" && ( + + )} +
+ ); +}