import { useCallback, useId, useMemo, useState, type KeyboardEvent, type PointerEvent } from "react"; import { AreaClosed, Line, LinePath } from "@visx/shape"; import { AxisBottom, AxisLeft } from "@visx/axis"; import { GridRows } from "@visx/grid"; import { Group } from "@visx/group"; import { scaleLinear, scaleTime } from "@visx/scale"; import { curveMonotoneX } from "@visx/curve"; import { localPoint } from "@visx/event"; import { useParentSize } from "@visx/responsive"; import { bisector, extent } from "d3-array"; import { parseLocalDate } from "@/lib/utils"; import { ChartTooltip, TooltipRow } from "./ChartTooltip"; export interface TimePoint { /** Local calendar date, `YYYY-MM-DD`. */ date: string; value: number; } interface Props { data: TimePoint[]; height: number; /** Name of an HSL colour token, e.g. `--chart-blue`. */ colorVar?: string; /** Series name shown in the tooltip, e.g. "Balance". */ label: string; formatValue: (n: number) => string; formatAxisValue?: (n: number) => string; /** Tooltip date, so it honours the user's date-format setting. */ formatDate?: (date: string) => string; /** Draw a zero baseline (with this caption) whenever the series dips below 0. */ zeroLineLabel?: string; yAxisWidth?: number; /** Daily flow amounts need a true zero baseline even in line mode. */ zeroBaseline?: boolean; /** "line" (default): area + line, the original look. "bar": one column per * point, sharing the same scales/axes/tooltip/keyboard nav. */ chartType?: "line" | "bar"; } const MARGIN = { top: 8, right: 8, bottom: 28 }; const axisText = { fill: "hsl(var(--muted-foreground))", fontSize: 10 } as const; type P = { t: Date; v: number; date: string }; const byDate = bisector((d) => d.t).center; /** * Area + line over time with a snapping crosshair, the chart behind every * balance and price trend. Monotone curve rather than Recharts' "natural": * natural overshoots between points, drawing balances that never existed. * * Arrow keys walk the points when the chart has focus, so the readout is not * pointer-only. */ export function TimeSeriesChart({ data, height, colorVar = "--primary", label, formatValue, formatAxisValue = formatValue, formatDate = (d) => parseLocalDate(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }), zeroLineLabel, yAxisWidth = 48, zeroBaseline = false, chartType = "line", }: Props) { const { parentRef, width } = useParentSize({ debounceTime: 30 }); const gradientId = useId(); const [active, setActive] = useState(null); const color = `hsl(var(${colorVar}))`; const points = useMemo( () => data .map((d) => ({ t: parseLocalDate(d.date), v: d.value, date: d.date })) // A row with an unreadable date can't be placed on a time axis. .filter((p) => !Number.isNaN(p.t.getTime()) && Number.isFinite(p.v)), [data] ); const innerW = Math.max(0, width - yAxisWidth - MARGIN.right); const innerH = Math.max(0, height - MARGIN.top - MARGIN.bottom); const dipsBelowZero = !!zeroLineLabel && points.some((p) => p.v < 0); const xScale = useMemo(() => { const [lo, hi] = extent(points, (p) => p.t) as [Date, Date]; return scaleTime({ domain: [lo ?? new Date(), hi ?? new Date()], range: [0, innerW] }); }, [points, innerW]); const yScale = useMemo(() => { let [lo, hi] = extent(points, (p) => p.v) as [number, number]; lo ??= 0; hi ??= 0; if (dipsBelowZero) hi = Math.max(hi, 0); // A bar's height only reads correctly against a true zero baseline (two // bars twice as tall as each other should mean twice the value); the // line chart has no such requirement and keeps the tighter, padded-to- // the-data domain that makes small movements visible. if (chartType === "bar" || zeroBaseline) lo = Math.min(lo, 0); const topPad = (hi - lo) * 0.08 || Math.abs(hi) * 0.08 || 1; const bottomPad = chartType === "bar" || zeroBaseline ? 0 : topPad; return scaleLinear({ domain: [lo - bottomPad, hi + topPad], range: [innerH, 0], nice: 4 }); }, [points, innerH, dipsBelowZero, chartType, zeroBaseline]); const spanDays = points.length > 1 ? (points[points.length - 1].t.getTime() - points[0].t.getTime()) / 86_400_000 : 0; const tickDate = (d: Date) => d.toLocaleDateString(undefined, spanDays > 300 ? { month: "short", year: "2-digit" } : { month: "short", day: "numeric" }); const onPointerMove = useCallback( (e: PointerEvent) => { const pt = localPoint(e); if (!pt || points.length === 0) return; setActive(byDate(points, xScale.invert(pt.x - yAxisWidth))); }, [points, xScale, yAxisWidth] ); const onKeyDown = (e: KeyboardEvent) => { if (points.length === 0) return; const last = points.length - 1; const next = e.key === "ArrowLeft" ? Math.max(0, (active ?? last) - 1) : e.key === "ArrowRight" ? Math.min(last, (active ?? last) + 1) : e.key === "Home" ? 0 : e.key === "End" ? last : null; if (next === null) return; e.preventDefault(); setActive(next); }; const shown = active !== null ? points[active] : null; const lastPoint = points[points.length - 1]; const x = (p: P) => xScale(p.t); const y = (p: P) => yScale(p.v); // Bar width from the tightest gap between consecutive points, so daily // data doesn't touch and sparse data doesn't turn into hairlines. const barWidth = useMemo(() => { if (points.length < 2) return Math.min(24, innerW * 0.5); const xs = points.map((p) => xScale(p.t)).sort((a, b) => a - b); let minGap = Infinity; for (let i = 1; i < xs.length; i++) minGap = Math.min(minGap, xs[i] - xs[i - 1]); return Math.max(2, Math.min(28, minGap * 0.6)); }, [points, xScale, innerW]); return (
{width > 0 && points.length > 0 && ( setActive((a) => a ?? points.length - 1)} onBlur={() => setActive(null)} className="outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" > {dipsBelowZero && ( <> {zeroLineLabel} )} {chartType === "bar" ? ( points.map((p, i) => { const top = y(p); // Bars grow from the true zero line (the yScale domain // itself extends to 0 in bar mode, see above), so height is // always proportional to value, not just to the visible range. return ( ); }) ) : ( <> )} formatAxisValue(Number(v))} tickLabelProps={() => ({ ...axisText, textAnchor: "end", dx: -6, dy: 3 })} /> tickDate(v as Date)} tickLabelProps={() => ({ ...axisText, textAnchor: "middle", dy: 4 })} /> {/* Bar mode signals the active point through the bar's own opacity above; a dot sitting on top of a column, or a crosshair cutting through it, would be redundant. */} {chartType === "line" && ( shown ? ( ) : ( // Resting end-dot marks "today" on the line. ) )} {/* Hit area covers the whole plot: readers aim at a date, not a 2px line. */} setActive(null)} /> )} {shown && (

{formatDate(shown.date)}

)}
); }