diff --git a/frontend/src/component/ui/interactive-chart.test.tsx b/frontend/src/component/ui/interactive-chart.test.tsx new file mode 100644 index 00000000..823e6418 --- /dev/null +++ b/frontend/src/component/ui/interactive-chart.test.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { + ChartSeries, + InteractiveChart, + computeMaxValue, + describeSeries, + pickAxisLabels, +} from "./interactive-chart"; + +vi.mock("next/link", () => ({ + default: ({ children, ...props }: React.ComponentProps<"a">) => ( + {children} + ), +})); + +function makeSeries( + values: number[], + overrides: Partial = {}, +): ChartSeries { + return { + id: "s1", + name: "Volume", + color: "#4FD1C5", + data: values.map((value, i) => ({ label: `Day ${i + 1}`, value })), + ...overrides, + }; +} + +const format = (value: number) => value.toFixed(2); + +describe("computeMaxValue", () => { + it("returns the largest value across visible series", () => { + expect(computeMaxValue([makeSeries([1, 9, 4])])).toBe(9); + }); + + it("returns null for no series at all", () => { + expect(computeMaxValue([])).toBeNull(); + }); + + it("returns null when every series is empty", () => { + expect(computeMaxValue([makeSeries([])])).toBeNull(); + }); + + it("returns null for an all-zero series, which cannot be scaled", () => { + // Dividing bar heights by a max of 0 previously produced `NaN%`. + expect(computeMaxValue([makeSeries([0, 0])])).toBeNull(); + }); + + it("ignores non-finite values rather than propagating them", () => { + expect(computeMaxValue([makeSeries([1, Number.NaN, 5, Infinity])])).toBe(5); + }); + + it("scales to a single point", () => { + expect(computeMaxValue([makeSeries([42])])).toBe(42); + }); +}); + +describe("pickAxisLabels", () => { + it("returns nothing for an empty series", () => { + expect(pickAxisLabels([])).toEqual([]); + }); + + it("returns one label for a single point, not the same label three times", () => { + expect(pickAxisLabels(makeSeries([1]).data)).toEqual(["Day 1"]); + }); + + it("returns both labels for two points", () => { + expect(pickAxisLabels(makeSeries([1, 2]).data)).toEqual(["Day 1", "Day 2"]); + }); + + it("returns first, middle, and last for a longer series", () => { + expect(pickAxisLabels(makeSeries([1, 2, 3, 4, 5]).data)).toEqual([ + "Day 1", + "Day 3", + "Day 5", + ]); + }); +}); + +describe("describeSeries", () => { + it("says plainly when a series has no data", () => { + expect(describeSeries(makeSeries([]), format)).toBe("Volume: no data."); + }); + + it("describes a single point without implying a range", () => { + const text = describeSeries(makeSeries([42]), format); + expect(text).toContain("a single point"); + expect(text).toContain("42.00"); + expect(text).not.toContain("Ranges from"); + }); + + it("reports count, span, range, and latest value", () => { + const text = describeSeries(makeSeries([1, 9, 4]), format); + expect(text).toContain("3 points"); + expect(text).toContain("Day 1"); + expect(text).toContain("Day 3"); + expect(text).toContain("1.00"); + expect(text).toContain("9.00"); + expect(text).toContain("ending at 4.00"); + }); +}); + +describe("InteractiveChart", () => { + it("renders a skeleton and no plot while loading", () => { + const { container } = render( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("Loading chart data"); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(0); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("renders an empty state instead of bare axes when there is no data", () => { + render(); + + expect(screen.getByText("No data yet")).toBeInTheDocument(); + // The bug: axes drawn over nothing read as "the values are zero". + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.queryByText("-Infinity")).not.toBeInTheDocument(); + }); + + it("treats an all-zero series as empty rather than plotting NaN heights", () => { + render(); + + expect(screen.getByText("No data yet")).toBeInTheDocument(); + expect(screen.queryByText("NaN")).not.toBeInTheDocument(); + }); + + it("accepts custom empty wording", () => { + render( + , + ); + + expect(screen.getByText("Nothing traded")).toBeInTheDocument(); + expect(screen.getByText("This market has no volume yet.")).toBeInTheDocument(); + }); + + it("renders a single-point series without breaking scaling", () => { + const { container } = render( + , + ); + + expect(screen.getByRole("img")).toBeInTheDocument(); + expect(screen.getByTestId("chart-summary")).toHaveTextContent("a single point"); + + // Every bar height must be a real percentage. + const heights = Array.from( + container.querySelectorAll('[style*="height"]'), + ).map((el) => el.style.height); + expect(heights.some((h) => h.includes("NaN") || h.includes("Infinity"))).toBe(false); + }); + + it("exposes a readable summary of the data to screen readers", () => { + render(); + + const summary = screen.getByTestId("chart-summary"); + expect(summary).toHaveTextContent("Volume: 3 points"); + expect(screen.getByRole("img").getAttribute("aria-label")).toContain( + "Volume: 3 points", + ); + }); + + it("keeps the legend reachable when every series is hidden", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /Volume/ })); + + // Hiding the last series must not swap in the empty state, or there would + // be no legend left to turn it back on. + expect(screen.getByRole("button", { name: /Volume/ })).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("All series are hidden"); + expect(screen.queryByText("No data yet")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/component/ui/interactive-chart.tsx b/frontend/src/component/ui/interactive-chart.tsx index 09a4e20d..b651bec2 100644 --- a/frontend/src/component/ui/interactive-chart.tsx +++ b/frontend/src/component/ui/interactive-chart.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState, useCallback, useRef, useEffect } from "react"; -import { ChevronDown } from "lucide-react"; +import { useState, useCallback, useRef, useMemo } from "react"; +import { EmptyState } from "./empty-state"; +import { Skeleton } from "./skeleton"; export interface ChartDataPoint { label: string; @@ -23,6 +24,79 @@ interface InteractiveChartProps { description?: string; tooltipFormatter?: (value: number, series: ChartSeries) => string; height?: number; + /** Renders the skeleton instead of the plot. Defaults to false. */ + isLoading?: boolean; + /** Overrides the wording of the "no data yet" state. */ + emptyTitle?: string; + emptyDescription?: string; +} + +/** + * Largest value across the visible series, or `null` when there is nothing to + * scale against. + * + * `Math.max()` of an empty list is `-Infinity`, which used to flow straight + * into the axis labels ("-Infinity") and into every bar height as + * `value / -Infinity`. A series of all-zero values is the other trap: the max + * is a legitimate 0, and dividing by it yields `NaN%`. Both are reported here + * as "cannot scale" so the caller shows an empty state instead of a plot. + */ +export function computeMaxValue(series: ChartSeries[]): number | null { + const values = series + .flatMap((s) => s.data.map((d) => d.value)) + .filter((v) => Number.isFinite(v)); + + if (values.length === 0) return null; + + const max = Math.max(...values); + return max > 0 ? max : null; +} + +/** + * Plain-language summary of a series, announced to screen readers. + * + * A bar chart is `role="img"`; without this the only accessible content is a + * title, and the data itself is unreachable. + */ +export function describeSeries( + s: ChartSeries, + format: (value: number, series: ChartSeries) => string, +): string { + if (s.data.length === 0) return `${s.name}: no data.`; + + const values = s.data.map((d) => d.value); + const first = s.data[0]; + const last = s.data[s.data.length - 1]; + + if (s.data.length === 1) { + return `${s.name}: a single point, ${format(first.value, s)} at ${ + first.date || first.label + }.`; + } + + return ( + `${s.name}: ${s.data.length} points from ${first.date || first.label} to ` + + `${last.date || last.label}. ` + + `Ranges from ${format(Math.min(...values), s)} to ` + + `${format(Math.max(...values), s)}, ending at ${format(last.value, s)}.` + ); +} + +/** + * Up to three x-axis labels, de-duplicated by position. + * + * With a single point the first, middle and last index are all 0, which used + * to print the same label three times as though it were a range. + */ +export function pickAxisLabels(points: ChartDataPoint[]): string[] { + if (points.length === 0) return []; + if (points.length === 1) return [points[0].label]; + if (points.length === 2) return [points[0].label, points[1].label]; + return [ + points[0].label, + points[Math.floor(points.length / 2)].label, + points[points.length - 1].label, + ]; } export function InteractiveChart({ @@ -31,6 +105,9 @@ export function InteractiveChart({ description, tooltipFormatter, height = 300, + isLoading = false, + emptyTitle = "No data yet", + emptyDescription = "There is nothing to chart for this period. Data will appear here once it is recorded.", }: InteractiveChartProps) { const [visibleSeries, setVisibleSeries] = useState( new Set(series.map((s) => s.id)), @@ -63,11 +140,19 @@ export function InteractiveChart({ setTooltipPos({ x, y }); // Calculate which data point we're hovering over + const pointCount = series[0]?.data.length ?? 0; + if (pointCount === 0) { + setHoveredIndex(null); + return; + } + const chartArea = rect.width * 0.85; // Approximate chart width - const pointWidth = chartArea / (series[0]?.data.length || 1); + const pointWidth = chartArea / pointCount; const index = Math.floor(x / pointWidth); - setHoveredIndex(Math.max(0, Math.min(index, series[0]?.data.length - 1))); + // Previously `data.length - 1` on an absent series produced NaN here, + // which silently poisoned every downstream index lookup. + setHoveredIndex(Math.max(0, Math.min(index, pointCount - 1))); }, [series], ); @@ -80,16 +165,68 @@ export function InteractiveChart({ const defaultFormatter = (value: number) => value.toFixed(2); const formatter = tooltipFormatter || defaultFormatter; - // Calculate chart dimensions - const maxValue = Math.max( - ...series - .filter((s) => visibleSeries.has(s.id)) - .flatMap((s) => s.data.map((d) => d.value)), + const visibleData = useMemo( + () => series.filter((s) => visibleSeries.has(s.id)), + [series, visibleSeries], ); - const visibleData = series.filter((s) => visibleSeries.has(s.id)); + // null means "nothing to scale against" — see computeMaxValue. + const maxValue = useMemo(() => computeMaxValue(visibleData), [visibleData]); const dataPoints = series[0]?.data.length || 0; + const summary = useMemo( + () => + visibleData.length === 0 + ? "No series are currently shown." + : visibleData.map((s) => describeSeries(s, formatter)).join(" "), + // `formatter` is derived from a prop on every render; the summary is cheap + // and correctness matters more than skipping the recompute. + [visibleData, formatter], + ); + + const axisLabels = pickAxisLabels(series[0]?.data ?? []); + + if (isLoading) { + return ( +
+ {title && } +
+ + +
+ + + Loading chart data + +
+ ); + } + + // No plottable data: an axis drawn over nothing reads as "the values are + // zero" rather than "we have no values", which is the bug this guards. + // + // Deliberately keyed on the whole `series` prop, not on `maxValue`: when the + // data exists but the user has hidden every series, replacing the card with + // an empty state would take the legend away and leave them no way back. + if (computeMaxValue(series) === null) { + return ( +
+ {title && ( +
+

{title}

+ {description && ( +

{description}

+ )} +
+ )} + +
+ ); + } + return (
{title && ( @@ -127,6 +264,20 @@ export function InteractiveChart({ })}
+ {/* Screen-reader summary of the plotted data. */} +

+ {summary} +

+ + {maxValue === null ? ( +

+ All series are hidden. Use the legend above to show one. +

+ ) : ( + <> {/* Chart container */}
{/* Y-axis labels */}
@@ -166,7 +317,12 @@ export function InteractiveChart({ const dataPoint = s.data[idx]; if (!dataPoint) return null; - const heightPercent = (dataPoint.value / maxValue) * 100; + const heightPercent = Number.isFinite(dataPoint.value) + ? Math.max( + 0, + Math.min(100, (dataPoint.value / maxValue) * 100), + ) + : 0; return (
- {series[0]?.data[0]?.label} - {series[0]?.data[Math.floor(dataPoints / 2)]?.label} - {series[0]?.data[dataPoints - 1]?.label} + {axisLabels.map((label, i) => ( + {label} + ))}
+ + )}
); }