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 (
() =>
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 {formatDate(shown.date)}