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 }}
/>
} />
);
}