import { useMemo } from "react"; import { Area, CartesianGrid, ComposedChart, Legend, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import type { HistoryResponse } from "../api"; import { HISTORY_CHART_SYNC_ID, parseDateToTs as toTs } from "../lib/chartTime"; interface Props { history: HistoryResponse; domain: [number, number]; onSelectDocument?: (docId: string) => void; } function chairLastName(chair: string): string { return chair.split(" ").slice(-1)[0]; } interface ChartPoint { ts: number; score: number | null; rolling: number | null; label: string | null; chair: string | null; doc_id: string | null; } function TimelineTooltip({ active, payload }: { active?: boolean; payload?: { payload: ChartPoint }[] }) { if (!active || !payload?.length) return null; const point = payload[0].payload; if (point.score === null) return null; return (
{new Date(point.ts).toISOString().slice(0, 10)}
{point.chair &&
{point.chair}
}
score {point.score.toFixed(3)} ({point.label})
{point.doc_id &&
Click to read the source document
}
); } export default function Timeline({ history, domain, onSelectDocument }: Props) { const chartData = useMemo( () => history.points.map((p) => ({ ts: toTs(p.date) ?? 0, score: p.combined_score, rolling: p.combined_score_rolling, label: p.combined_label, chair: p.chair, doc_id: p.doc_id, })), [history.points], ); // The chart-level onClick event (via activeTooltipIndex/activePayload) // turned out to be unreliable in this Recharts version -- confirmed // empirically with a real headless-browser click that it fires with // stale/null state (a race against the hover-state update), flaky // roughly 2 times out of 3. It also structurally can't work on touch // devices at all, since there's no hover phase before a tap. A real, // independent SVG element per point sidesteps both problems entirely -- // it's a native click/tap target, not dependent on chart-wide gesture // state being in sync. function renderClickableDot(props: { cx?: number; cy?: number; payload?: ChartPoint }) { const { cx, cy, payload } = props; if (cx === undefined || cy === undefined || !payload?.doc_id || !onSelectDocument) { return ; } const docId = payload.doc_id; return ( onSelectDocument(docId)} /> ); } const gradientOffset = useMemo(() => { const values = chartData.map((d) => d.rolling).filter((v): v is number => v !== null); const dataMax = Math.max(0, ...values); const dataMin = Math.min(0, ...values); if (dataMax <= 0) return 0; if (dataMin >= 0) return 1; return dataMax / (dataMax - dataMin); }, [chartData]); return (
{history.regimes.map((r, i) => { const x1 = toTs(r.start); const x2 = r.end ? toTs(r.end) : Date.now(); if (x1 === null || x2 === null) return null; return ( ); })} {history.annotations.map((a, i) => { const x1 = a.start ? toTs(a.start) : domain[0]; const x2 = a.end ? toTs(a.end) : domain[1]; if (x1 === null || x2 === null) return null; return ( ); })} new Date(ts).getUTCFullYear().toString()} stroke="var(--border)" tick={{ fill: "var(--muted)", fontSize: 11 }} /> } />
); }