"use client";
import * as React from "react";
export interface BrushChartDatum {
/** Time: epoch milliseconds or an ISO 8601 string. */
t: number | string;
v: number;
}
export interface BrushChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
data: BrushChartDatum[];
/** Accessible name of the chart. */
label: string;
/** Filled area or a plain line. */
type?: "area" | "line";
/** Monotone curve (no overshoot) or straight segments. */
curve?: "monotone" | "linear";
/** Height of the main chart in px. */
height?: number;
/** Height of the overview strip in px. */
overviewHeight?: number;
/** Initial window as a fraction of the full range, anchored to the end. */
defaultSpan?: number;
/** Initial window as [start, end] in epoch ms (overrides defaultSpan). */
defaultRange?: [number, number];
/** Domain transition time constant in ms (0 = instant). */
duration?: number;
/** Horizontal grid lines. */
showGrid?: boolean;
/** Keep 0 inside the value axis. */
includeZero?: boolean;
/** Line, area and brush colour. */
accent?: string;
formatValue?: (value: number) => string;
formatTime?: (time: number) => string;
/** Called with [start, end] (epoch ms) when the selected window settles. */
onRangeChange?: (range: [number, number]) => void;
}
/* --------------------------- reduced-motion hook --------------------------- */
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* ------------------------------- data helpers ------------------------------ */
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
const MARGIN = { top: 14, right: 14, bottom: 26, left: 48 };
const X_SLOTS = 12;
const Y_SLOTS = 8;
interface Series {
ts: number[];
vs: number[];
/** Median spacing between points, in ms. */
step: number;
}
function toSeries(data: BrushChartDatum[]): Series {
const points = data
.map((d) => ({ t: typeof d.t === "string" ? Date.parse(d.t) : d.t, v: d.v }))
.filter((d) => Number.isFinite(d.t) && Number.isFinite(d.v))
.sort((a, b) => a.t - b.t);
const ts = points.map((p) => p.t);
const gaps = ts.slice(1).map((t, i) => t - ts[i]).sort((a, b) => a - b);
return { ts, vs: points.map((p) => p.v), step: gaps.length ? Math.max(1, gaps[Math.floor(gaps.length / 2)]) : DAY };
}
/** First index with ts[i] >= t. */
function lowerBound(ts: number[], t: number) {
let lo = 0;
let hi = ts.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (ts[mid] < t) lo = mid + 1;
else hi = mid;
}
return lo;
}
function nearestIndex(ts: number[], t: number) {
const i = lowerBound(ts, t);
if (i <= 0) return 0;
if (i >= ts.length) return ts.length - 1;
return t - ts[i - 1] < ts[i] - t ? i - 1 : i;
}
function niceStep(raw: number) {
const exp = Math.pow(10, Math.floor(Math.log10(Math.max(raw, 1e-12))));
const f = raw / exp;
return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * exp;
}
/** A rounded value domain for the points inside [d0, d1]. */
function valueDomain(s: Series, d0: number, d1: number, includeZero: boolean): [number, number] {
const from = Math.max(0, lowerBound(s.ts, d0) - 1);
const to = Math.min(s.ts.length, lowerBound(s.ts, d1) + 1);
let min = Infinity;
let max = -Infinity;
for (let i = from; i < to; i++) {
min = Math.min(min, s.vs[i]);
max = Math.max(max, s.vs[i]);
}
if (!Number.isFinite(min)) return [0, 1];
if (includeZero) {
min = Math.min(0, min);
max = Math.max(0, max);
}
if (max - min < 1e-9) {
min -= 1;
max += 1;
}
const step = niceStep((max - min) / 4);
return [Math.floor(min / step) * step, Math.ceil(max / step) * step];
}
/* ---------------------------------- paths ---------------------------------- */
const f1 = (n: number) => n.toFixed(1);
/** Monotone cubic interpolation (Fritsch–Carlson): smooth, never overshoots the data. */
function linePath(xs: number[], ys: number[], curve: "monotone" | "linear") {
const n = xs.length;
if (!n) return "";
let d = `M${f1(xs[0])},${f1(ys[0])}`;
if (n === 1) return d;
if (curve === "linear") {
for (let i = 1; i < n; i++) d += `L${f1(xs[i])},${f1(ys[i])}`;
return d;
}
const slopes: number[] = [];
for (let i = 0; i < n - 1; i++) slopes.push((ys[i + 1] - ys[i]) / (xs[i + 1] - xs[i] || 1));
const tangents = xs.map((_, i) => {
if (i === 0) return slopes[0];
if (i === n - 1) return slopes[n - 2];
return slopes[i - 1] * slopes[i] <= 0 ? 0 : (slopes[i - 1] + slopes[i]) / 2;
});
for (let i = 0; i < n - 1; i++) {
if (slopes[i] === 0) {
tangents[i] = 0;
tangents[i + 1] = 0;
continue;
}
const a = tangents[i] / slopes[i];
const b = tangents[i + 1] / slopes[i];
const h = a * a + b * b;
if (h > 9) {
const tau = 3 / Math.sqrt(h);
tangents[i] = tau * a * slopes[i];
tangents[i + 1] = tau * b * slopes[i];
}
}
for (let i = 0; i < n - 1; i++) {
const dx = (xs[i + 1] - xs[i]) / 3;
d += `C${f1(xs[i] + dx)},${f1(ys[i] + tangents[i] * dx)} ${f1(xs[i + 1] - dx)},${f1(ys[i + 1] - tangents[i + 1] * dx)} ${f1(xs[i + 1])},${f1(ys[i + 1])}`;
}
return d;
}
/* ---------------------------------- ticks ---------------------------------- */
const fmtMonthDay = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short", day: "numeric" });
const fmtMonth = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short" });
const fmtYear = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", year: "numeric" });
const fmtHour = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
const fmtFullDay = new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short", day: "numeric", year: "numeric" });
const fmtFullHour = new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
const fmtNumber = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
type Interval = { ms: number; months?: number };
const INTERVALS: Interval[] = [
{ ms: HOUR },
{ ms: 3 * HOUR },
{ ms: 6 * HOUR },
{ ms: 12 * HOUR },
{ ms: DAY },
{ ms: 2 * DAY },
{ ms: 7 * DAY },
{ ms: 14 * DAY },
{ ms: 30 * DAY, months: 1 },
{ ms: 91 * DAY, months: 3 },
{ ms: 182 * DAY, months: 6 },
{ ms: 365 * DAY, months: 12 },
];
/** UTC-aligned time ticks for [d0, d1], about `count` of them, with labels. */
function timeTicks(d0: number, d1: number, count: number): { t: number; label: string }[] {
const span = d1 - d0;
const interval = INTERVALS.find((i) => span / i.ms <= count) ?? INTERVALS[INTERVALS.length - 1];
const out: { t: number; label: string }[] = [];
if (interval.months) {
const k = interval.months;
const start = new Date(d0);
let y = start.getUTCFullYear();
let m = Math.floor(start.getUTCMonth() / k) * k;
for (let guard = 0; guard < 60; guard++) {
const t = Date.UTC(y, m, 1);
if (t > d1) break;
if (t >= d0) out.push({ t, label: m === 0 || k === 12 ? fmtYear.format(t) : fmtMonth.format(t) });
m += k;
if (m >= 12) {
y += Math.floor(m / 12);
m %= 12;
}
}
return out;
}
// Weeks start on Monday (the epoch was a Thursday).
const offset = interval.ms >= 7 * DAY ? 4 * DAY : 0;
let t = Math.ceil((d0 - offset) / interval.ms) * interval.ms + offset;
for (let guard = 0; t <= d1 && guard < 60; guard++, t += interval.ms) {
const label = interval.ms < DAY && t % DAY !== 0 ? fmtHour.format(t) : fmtMonthDay.format(t);
out.push({ t, label });
}
return out;
}
function valueTicks(y0: number, y1: number, count: number) {
const step = niceStep((y1 - y0) / count);
const out: number[] = [];
for (let v = Math.ceil(y0 / step) * step; v <= y1 + step * 1e-6 && out.length < Y_SLOTS; v += step) out.push(Number(v.toFixed(10)));
return out;
}
/* -------------------------------- component -------------------------------- */
interface Domain {
d0: number;
d1: number;
y0: number;
y1: number;
}
/** Everything the imperative drawing code needs, refreshed after every render. */
interface Model {
series: Series;
full: [number, number];
minSpan: number;
width: number;
plotW: number;
plotH: number;
ovH: number;
type: "area" | "line";
curve: "monotone" | "linear";
includeZero: boolean;
tau: number;
fmtValue: (v: number) => string;
fmtTime: (t: number) => string;
}
type BrushDrag = { kind: "start" | "end" | "window"; startX: number; d0: number; d1: number } | null;
/**
* A time-series chart with an overview brush. Drag or resize the brush window (or wheel and drag
* on the main chart) and the main chart glides to the new domain. Drawing is imperative — paths,
* ticks, brush and crosshair are written to the DOM from refs — so gestures never re-render React;
* state only changes when a range settles.
*/
export function BrushChart({
data,
label,
type = "area",
curve = "monotone",
height = 260,
overviewHeight = 56,
defaultSpan = 0.35,
defaultRange,
duration = 450,
showGrid = true,
includeZero = false,
accent = "#FF7448",
formatValue,
formatTime,
onRangeChange,
className = "",
style,
...props
}: BrushChartProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId().replace(/:/g, "");
const summaryId = `${uid}-summary`;
const hintId = `${uid}-hint`;
const rootRef = React.useRef<HTMLDivElement>(null);
const mainRef = React.useRef<HTMLDivElement>(null);
const areaRef = React.useRef<SVGPathElement>(null);
const lineRef = React.useRef<SVGPathElement>(null);
const xTicksRef = React.useRef<SVGGElement>(null);
const yTicksRef = React.useRef<SVGGElement>(null);
const crossRef = React.useRef<SVGGElement>(null);
const tipRef = React.useRef<HTMLDivElement>(null);
const liveRef = React.useRef<HTMLSpanElement>(null);
const windowRef = React.useRef<SVGRectElement>(null);
const shadeLRef = React.useRef<SVGRectElement>(null);
const shadeRRef = React.useRef<SVGRectElement>(null);
const handleStartRef = React.useRef<SVGGElement>(null);
const handleEndRef = React.useRef<SVGGElement>(null);
const modelRef = React.useRef<Model | null>(null);
const currentRef = React.useRef<Domain | null>(null);
const targetRef = React.useRef<Domain | null>(null);
const frameRef = React.useRef(0);
const lastFrameRef = React.useRef(0);
const cursorRef = React.useRef<number | null>(null);
const brushDragRef = React.useRef<BrushDrag>(null);
const panRef = React.useRef<{ x: number; d0: number; d1: number; moved: boolean } | null>(null);
const committedKeyRef = React.useRef("");
const commitTimer = React.useRef<number | undefined>(undefined);
const series = React.useMemo(() => toSeries(data), [data]);
const n = series.ts.length;
const full = React.useMemo<[number, number]>(() => {
const ts = series.ts;
if (!ts.length) return [0, 1];
return [ts[0], ts[ts.length - 1] > ts[0] ? ts[ts.length - 1] : ts[0] + 1];
}, [series]);
const minSpan = Math.min(full[1] - full[0], series.step * 4);
const clampRange = (d0: number, d1: number): [number, number] => {
const span = Math.min(full[1] - full[0], Math.max(minSpan, d1 - d0));
const start = Math.min(full[1] - span, Math.max(full[0], d0));
return [start, start + span];
};
const initialRange = (): [number, number] =>
defaultRange
? clampRange(defaultRange[0], defaultRange[1])
: clampRange(full[1] - (full[1] - full[0]) * Math.min(1, Math.max(0.02, defaultSpan)), full[1]);
const [range, setRange] = React.useState<[number, number]>(initialRange);
const [width, setWidth] = React.useState(0);
const [prevSeries, setPrevSeries] = React.useState(series);
// New data: keep the window if it still fits, otherwise clamp it into the new domain.
if (series !== prevSeries) {
setPrevSeries(series);
setRange(clampRange(range[0], range[1]));
}
const fmtValue = formatValue ?? ((v: number) => fmtNumber.format(v));
const fmtTime = formatTime ?? ((t: number) => (series.step < DAY ? fmtFullHour.format(t) : fmtFullDay.format(t)));
const plotW = Math.max(0, width - MARGIN.left - MARGIN.right);
const plotH = Math.max(0, height - MARGIN.top - MARGIN.bottom);
const ovH = overviewHeight;
/* ---------------------------- imperative drawing ---------------------------- */
const draw = () => {
const m = modelRef.current;
const cur = currentRef.current;
const tgt = targetRef.current;
if (!m || !cur || !tgt || m.plotW <= 0) return;
const { ts, vs } = m.series;
const sx = (t: number) => MARGIN.left + ((t - cur.d0) / (cur.d1 - cur.d0)) * m.plotW;
const sy = (v: number) => MARGIN.top + (1 - (v - cur.y0) / (cur.y1 - cur.y0 || 1)) * m.plotH;
// Main series: the visible points plus one beyond each edge (clipped).
const from = Math.max(0, lowerBound(ts, cur.d0) - 1);
const to = Math.min(ts.length, lowerBound(ts, cur.d1) + 1);
const xs: number[] = [];
const ys: number[] = [];
for (let i = from; i < to; i++) {
xs.push(sx(ts[i]));
ys.push(sy(vs[i]));
}
const d = linePath(xs, ys, m.curve);
lineRef.current?.setAttribute("d", d);
if (areaRef.current) {
const base = MARGIN.top + m.plotH;
areaRef.current.setAttribute("d", xs.length ? `${d}L${f1(xs[xs.length - 1])},${base}L${f1(xs[0])},${base}Z` : "");
}
// Axis tick slots: show, move and relabel; hide the spare ones.
const xt = timeTicks(cur.d0, cur.d1, Math.max(2, Math.floor(m.plotW / 96)));
xTicksRef.current?.querySelectorAll<SVGGElement>(":scope > g").forEach((g, i) => {
const tick = xt[i];
g.style.display = tick ? "" : "none";
if (!tick) return;
g.setAttribute("transform", `translate(${f1(sx(tick.t))},0)`);
const text = g.querySelector("text");
if (text && text.textContent !== tick.label) text.textContent = tick.label;
});
const yt = valueTicks(cur.y0, cur.y1, Math.max(2, Math.floor(m.plotH / 52)));
yTicksRef.current?.querySelectorAll<SVGGElement>(":scope > g").forEach((g, i) => {
const v = yt[i];
g.style.display = v === undefined ? "none" : "";
if (v === undefined) return;
g.setAttribute("transform", `translate(0,${f1(sy(v))})`);
const text = g.querySelector("text");
const label = m.fmtValue(v);
if (text && text.textContent !== label) text.textContent = label;
});
// Brush follows the target directly (it is what the user is holding).
const ox = (t: number) => MARGIN.left + ((t - m.full[0]) / (m.full[1] - m.full[0])) * m.plotW;
const bx0 = ox(tgt.d0);
const bx1 = ox(tgt.d1);
windowRef.current?.setAttribute("x", f1(bx0));
windowRef.current?.setAttribute("width", f1(Math.max(1, bx1 - bx0)));
shadeLRef.current?.setAttribute("width", f1(Math.max(0, bx0 - MARGIN.left)));
shadeRRef.current?.setAttribute("x", f1(bx1));
shadeRRef.current?.setAttribute("width", f1(Math.max(0, MARGIN.left + m.plotW - bx1)));
const sliders: [SVGGElement | null, number, number, number][] = [
[handleStartRef.current, tgt.d0, m.full[0], tgt.d1 - m.minSpan],
[handleEndRef.current, tgt.d1, tgt.d0 + m.minSpan, m.full[1]],
];
for (const [el, t, min, max] of sliders) {
if (!el) continue;
el.setAttribute("transform", `translate(${f1(ox(t))},0)`);
el.setAttribute("aria-valuenow", String(Math.round(t)));
el.setAttribute("aria-valuemin", String(Math.round(min)));
el.setAttribute("aria-valuemax", String(Math.round(max)));
el.setAttribute("aria-valuetext", m.fmtTime(t));
}
drawCursor();
};
const drawCursor = () => {
const m = modelRef.current;
const cur = currentRef.current;
const cross = crossRef.current;
const tip = tipRef.current;
if (!m || !cur || !cross || !tip) return;
const i = cursorRef.current;
const t = i === null ? NaN : m.series.ts[i];
if (i === null || t < cur.d0 || t > cur.d1) {
cross.style.display = "none";
tip.style.opacity = "0";
return;
}
const x = MARGIN.left + ((t - cur.d0) / (cur.d1 - cur.d0)) * m.plotW;
const y = MARGIN.top + (1 - (m.series.vs[i] - cur.y0) / (cur.y1 - cur.y0 || 1)) * m.plotH;
cross.style.display = "";
cross.setAttribute("transform", `translate(${f1(x)},0)`);
cross.querySelector("circle")?.setAttribute("cy", f1(y));
tip.style.opacity = "1";
// Keep the tooltip inside the chart: flip to the left of the crosshair near the right edge.
const flip = x > MARGIN.left + m.plotW * 0.66;
tip.style.transform = `translate(${f1(flip ? x - 10 : x + 10)}px, ${f1(Math.max(MARGIN.top, y - 46))}px) translateX(${flip ? "-100%" : "0"})`;
const [timeEl, valueEl] = Array.from(tip.children);
if (timeEl) timeEl.textContent = m.fmtTime(t);
if (valueEl) valueEl.textContent = m.fmtValue(m.series.vs[i]);
};
/** Chases the target domain with an exponential ease; stops when it arrives. */
const tick = (now: number) => {
const m = modelRef.current;
const cur = currentRef.current;
const tgt = targetRef.current;
if (!m || !cur || !tgt) {
frameRef.current = 0;
return;
}
const dt = lastFrameRef.current ? Math.min(64, now - lastFrameRef.current) : 16;
lastFrameRef.current = now;
const k = m.tau <= 0 ? 1 : 1 - Math.exp(-dt / m.tau);
const keys = ["d0", "d1", "y0", "y1"] as const;
let done = true;
for (const key of keys) {
cur[key] += (tgt[key] - cur[key]) * k;
const scale = key[0] === "d" ? tgt.d1 - tgt.d0 : tgt.y1 - tgt.y0 || 1;
if (Math.abs(tgt[key] - cur[key]) > scale * 0.0005) done = false;
}
if (done) Object.assign(cur, tgt);
draw();
if (done) {
frameRef.current = 0;
lastFrameRef.current = 0;
} else {
frameRef.current = requestAnimationFrame(tick);
}
};
const kick = () => {
if (!frameRef.current) frameRef.current = requestAnimationFrame(tick);
};
const commit = () => {
window.clearTimeout(commitTimer.current);
const tgt = targetRef.current;
if (!tgt) return;
const next: [number, number] = [Math.round(tgt.d0), Math.round(tgt.d1)];
const key = next.join(":");
if (key === committedKeyRef.current) return;
committedKeyRef.current = key;
setRange(next);
onRangeChange?.(next);
};
const scheduleCommit = () => {
window.clearTimeout(commitTimer.current);
commitTimer.current = window.setTimeout(commit, 200);
};
/** Moves the target window (clamped) and starts the glide. */
const setTarget = (d0: number, d1: number) => {
const m = modelRef.current;
if (!m) return;
const span = Math.min(m.full[1] - m.full[0], Math.max(m.minSpan, d1 - d0));
const start = Math.min(m.full[1] - span, Math.max(m.full[0], d0));
const [y0, y1] = valueDomain(m.series, start, start + span, m.includeZero);
targetRef.current = { d0: start, d1: start + span, y0, y1 };
if (!currentRef.current) currentRef.current = { ...targetRef.current };
if (m.tau <= 0) Object.assign(currentRef.current, targetRef.current);
draw();
kick();
};
/* --------------------------------- effects --------------------------------- */
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const ro = new ResizeObserver((entries) => setWidth(Math.round(entries[0].contentRect.width)));
ro.observe(root);
return () => ro.disconnect();
}, []);
// Refresh the model after every render; sync the target when the committed range changed
// from outside a gesture (initial mount, new data).
React.useLayoutEffect(() => {
modelRef.current = {
series,
full,
minSpan,
width,
plotW,
plotH,
ovH,
type,
curve,
includeZero,
tau: reduceMotion ? 0 : duration / 4,
fmtValue,
fmtTime,
};
const key = `${Math.round(range[0])}:${Math.round(range[1])}`;
const tgt = targetRef.current;
if (!tgt || key !== committedKeyRef.current) {
committedKeyRef.current = key;
setTarget(range[0], range[1]);
} else {
// Same window, maybe new data or size: refresh the value domain and redraw.
setTarget(tgt.d0, tgt.d1);
}
});
React.useEffect(
() => () => {
cancelAnimationFrame(frameRef.current);
window.clearTimeout(commitTimer.current);
},
[],
);
const onWheel = React.useEffectEvent((event: WheelEvent) => {
const m = modelRef.current;
const tgt = targetRef.current;
const el = mainRef.current;
if (!m || !tgt || !el) return;
event.preventDefault();
const rect = el.getBoundingClientRect();
const span = tgt.d1 - tgt.d0;
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) {
// Horizontal scroll pans.
const shift = ((event.shiftKey ? event.deltaY : event.deltaX) / m.plotW) * span;
setTarget(tgt.d0 + shift, tgt.d1 + shift);
} else {
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left - MARGIN.left) / m.plotW));
const at = tgt.d0 + ratio * span;
const next = Math.min(m.full[1] - m.full[0], Math.max(m.minSpan, span * Math.exp(event.deltaY * 0.0018)));
setTarget(at - ratio * next, at - ratio * next + next);
}
scheduleCommit();
});
React.useEffect(() => {
const el = mainRef.current;
if (!el) return;
const handler = (event: WheelEvent) => onWheel(event);
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, []);
/* ------------------------------ main chart input ----------------------------- */
const timeAtMain = (clientX: number) => {
const m = modelRef.current;
const cur = currentRef.current;
const el = mainRef.current;
if (!m || !cur || !el) return null;
const x = clientX - el.getBoundingClientRect().left - MARGIN.left;
return cur.d0 + (x / m.plotW) * (cur.d1 - cur.d0);
};
const showCursorAt = (clientX: number) => {
const m = modelRef.current;
const t = timeAtMain(clientX);
if (!m || t === null || !m.series.ts.length) return;
cursorRef.current = nearestIndex(m.series.ts, t);
drawCursor();
};
const onMainPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const tgt = targetRef.current;
if (!tgt || (event.pointerType === "mouse" && event.button !== 0)) return;
event.currentTarget.setPointerCapture(event.pointerId);
panRef.current = { x: event.clientX, d0: tgt.d0, d1: tgt.d1, moved: false };
};
const onMainPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const pan = panRef.current;
const m = modelRef.current;
if (pan && m) {
const dx = event.clientX - pan.x;
if (Math.abs(dx) > 3) pan.moved = true;
if (pan.moved) {
const shift = (-dx / m.plotW) * (pan.d1 - pan.d0);
setTarget(pan.d0 + shift, pan.d1 + shift);
}
}
if (event.pointerType === "mouse") showCursorAt(event.clientX);
};
const onMainPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const pan = panRef.current;
panRef.current = null;
if (!pan) return;
if (pan.moved) commit();
// A tap (touch or pen) shows the crosshair at that point.
else showCursorAt(event.clientX);
};
const announce = (text: string) => {
if (liveRef.current) liveRef.current.textContent = text;
};
const onMainKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const m = modelRef.current;
const tgt = targetRef.current;
if (!m || !tgt || !m.series.ts.length) return;
const ts = m.series.ts;
const firstVisible = Math.min(ts.length - 1, lowerBound(ts, tgt.d0));
const lastVisible = Math.max(0, lowerBound(ts, tgt.d1 + 1) - 1);
let i = cursorRef.current ?? lastVisible;
const span = tgt.d1 - tgt.d0;
switch (event.key) {
case "ArrowLeft":
i = Math.max(0, i - (event.shiftKey ? 10 : 1));
break;
case "ArrowRight":
i = Math.min(ts.length - 1, i + (event.shiftKey ? 10 : 1));
break;
case "Home":
i = firstVisible;
break;
case "End":
i = lastVisible;
break;
case "+":
case "=":
case "-":
case "_": {
const at = ts[i];
const next = span * (event.key === "+" || event.key === "=" ? 0.7 : 1 / 0.7);
const ratio = (at - tgt.d0) / span;
setTarget(at - ratio * next, at - ratio * next + next);
commit();
event.preventDefault();
return;
}
case "Escape":
cursorRef.current = null;
drawCursor();
return;
default:
return;
}
event.preventDefault();
cursorRef.current = i;
// Walking off the visible window pans it along.
if (ts[i] < tgt.d0) setTarget(ts[i], ts[i] + span);
else if (ts[i] > tgt.d1) setTarget(ts[i] - span, ts[i]);
else drawCursor();
if (ts[i] < tgt.d0 || ts[i] > tgt.d1) commit();
announce(`${m.fmtTime(ts[i])}: ${m.fmtValue(m.series.vs[i])}`);
};
/* ------------------------------- brush input -------------------------------- */
const timeAtOverview = (clientX: number, el: Element) => {
const m = modelRef.current;
if (!m) return 0;
const x = clientX - el.getBoundingClientRect().left - MARGIN.left;
return m.full[0] + (x / m.plotW) * (m.full[1] - m.full[0]);
};
const onBrushPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
const tgt = targetRef.current;
if (!tgt || (event.pointerType === "mouse" && event.button !== 0)) return;
const part = (event.target as Element).closest<SVGElement>("[data-brush]")?.dataset.brush;
const svg = event.currentTarget;
svg.setPointerCapture(event.pointerId);
if (part === "start" || part === "end" || part === "window") {
brushDragRef.current = { kind: part, startX: event.clientX, d0: tgt.d0, d1: tgt.d1 };
if (part !== "window") (event.target as Element).closest<SVGGElement>("g[role=slider]")?.focus({ preventScroll: true });
} else {
// Outside the window: centre it on the pointer, then keep dragging it.
const t = timeAtOverview(event.clientX, svg);
const span = tgt.d1 - tgt.d0;
setTarget(t - span / 2, t + span / 2);
const next = targetRef.current ?? tgt;
brushDragRef.current = { kind: "window", startX: event.clientX, d0: next.d0, d1: next.d1 };
}
rootRef.current?.setAttribute("data-brushing", "true");
event.preventDefault();
};
const onBrushPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
const drag = brushDragRef.current;
const m = modelRef.current;
if (!drag || !m) return;
const dt = ((event.clientX - drag.startX) / m.plotW) * (m.full[1] - m.full[0]);
if (drag.kind === "window") setTarget(drag.d0 + dt, drag.d1 + dt);
else if (drag.kind === "start") setTarget(Math.min(drag.d0 + dt, drag.d1 - m.minSpan), drag.d1);
else setTarget(drag.d0, Math.max(drag.d1 + dt, drag.d0 + m.minSpan));
};
const onBrushPointerUp = () => {
if (!brushDragRef.current) return;
brushDragRef.current = null;
rootRef.current?.removeAttribute("data-brushing");
commit();
};
const onHandleKeyDown = (event: React.KeyboardEvent<SVGGElement>, edge: "start" | "end") => {
const m = modelRef.current;
const tgt = targetRef.current;
if (!m || !tgt) return;
const stepMs = m.series.step;
const big = (m.full[1] - m.full[0]) / 10;
let delta = 0;
let to: number | null = null;
switch (event.key) {
case "ArrowLeft":
case "ArrowDown":
delta = -(event.shiftKey ? big : stepMs);
break;
case "ArrowRight":
case "ArrowUp":
delta = event.shiftKey ? big : stepMs;
break;
case "PageDown":
delta = -big;
break;
case "PageUp":
delta = big;
break;
case "Home":
to = edge === "start" ? m.full[0] : tgt.d0 + m.minSpan;
break;
case "End":
to = edge === "start" ? tgt.d1 - m.minSpan : m.full[1];
break;
default:
return;
}
event.preventDefault();
if (edge === "start") setTarget(Math.min(to ?? tgt.d0 + delta, tgt.d1 - m.minSpan), tgt.d1);
else setTarget(tgt.d0, Math.max(to ?? tgt.d1 + delta, tgt.d0 + m.minSpan));
commit();
};
/* ---------------------------------- render ---------------------------------- */
// Overview path over the full domain (static per data and size, so React renders it).
const overview = React.useMemo(() => {
if (plotW <= 0 || !n) return { line: "", area: "" };
let min = Infinity;
let max = -Infinity;
for (const v of series.vs) {
min = Math.min(min, v);
max = Math.max(max, v);
}
const pad = (max - min || 1) * 0.08;
const xs = series.ts.map((t) => MARGIN.left + ((t - full[0]) / (full[1] - full[0])) * plotW);
const ys = series.vs.map((v) => 4 + (1 - (v - min + pad) / (max - min + pad * 2)) * (ovH - 8));
const line = linePath(xs, ys, curve);
return { line, area: `${line}L${f1(xs[xs.length - 1])},${ovH}L${f1(xs[0])},${ovH}Z` };
}, [series, plotW, ovH, curve, n, full]);
// Screen-reader summary of the committed range.
const summary = (() => {
if (!n) return `${label}: no data.`;
const from = lowerBound(series.ts, range[0]);
const to = Math.max(from, lowerBound(series.ts, range[1] + 1) - 1);
let lo = from;
let hi = from;
let sum = 0;
for (let i = from; i <= to; i++) {
if (series.vs[i] < series.vs[lo]) lo = i;
if (series.vs[i] > series.vs[hi]) hi = i;
sum += series.vs[i];
}
const count = to - from + 1;
return `${label}. Showing ${fmtTime(range[0])} to ${fmtTime(range[1])}: ${count} points, from ${fmtValue(series.vs[from])} to ${fmtValue(
series.vs[to],
)}, average ${fmtValue(sum / count)}, lowest ${fmtValue(series.vs[lo])} on ${fmtTime(series.ts[lo])}, highest ${fmtValue(series.vs[hi])} on ${fmtTime(
series.ts[hi],
)}.`;
})();
const gradientId = `${uid}-fill`;
const clipId = `${uid}-clip`;
const ready = width > 0;
return (
<div
ref={rootRef}
role="group"
aria-label={label}
className={`group/bc w-full min-w-0 text-zinc-950 select-none dark:text-zinc-50 ${className}`}
style={{ ["--bc-accent" as string]: accent, ...style }}
{...props}
>
{/* Main chart */}
<div
ref={mainRef}
tabIndex={0}
role="group"
aria-roledescription="interactive chart"
aria-label={`${label}, detail view`}
aria-describedby={`${summaryId} ${hintId}`}
onKeyDown={onMainKeyDown}
onPointerDown={onMainPointerDown}
onPointerMove={onMainPointerMove}
onPointerUp={onMainPointerUp}
onPointerCancel={() => {
panRef.current = null;
}}
onPointerLeave={(e) => {
if (e.pointerType === "mouse" && !panRef.current) {
cursorRef.current = null;
drawCursor();
}
}}
onBlur={() => {
cursorRef.current = null;
drawCursor();
}}
className="relative cursor-crosshair touch-pan-y rounded-lg outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--bc-accent)] active:cursor-grabbing"
style={{ height }}
>
{ready && (
<svg width={width} height={height} className="absolute inset-0 overflow-visible" aria-hidden="true">
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={accent} stopOpacity="0.24" />
<stop offset="1" stopColor={accent} stopOpacity="0" />
</linearGradient>
<clipPath id={clipId}>
<rect x={MARGIN.left} y={0} width={plotW} height={height} />
</clipPath>
</defs>
<g ref={yTicksRef}>
{Array.from({ length: Y_SLOTS }, (_, i) => (
<g key={i} style={{ display: "none" }}>
{showGrid && <line x1={MARGIN.left} x2={MARGIN.left + plotW} className="stroke-zinc-900/[0.07] dark:stroke-white/[0.07]" />}
<text x={MARGIN.left - 10} dy="0.32em" textAnchor="end" className="fill-zinc-500 font-mono text-[10px] tabular-nums dark:fill-zinc-400" />
</g>
))}
</g>
<line
x1={MARGIN.left}
x2={MARGIN.left + plotW}
y1={MARGIN.top + plotH}
y2={MARGIN.top + plotH}
className="stroke-zinc-900/20 dark:stroke-white/20"
/>
<g ref={xTicksRef} clipPath={`url(#${clipId})`}>
{Array.from({ length: X_SLOTS }, (_, i) => (
<g key={i} style={{ display: "none" }}>
<line y1={MARGIN.top + plotH} y2={MARGIN.top + plotH + 4} className="stroke-zinc-900/25 dark:stroke-white/25" />
<text y={MARGIN.top + plotH + 17} textAnchor="middle" className="fill-zinc-500 font-mono text-[10px] dark:fill-zinc-400" />
</g>
))}
</g>
<g clipPath={`url(#${clipId})`}>
{type === "area" && <path ref={areaRef} fill={`url(#${gradientId})`} />}
<path ref={lineRef} fill="none" stroke={accent} strokeWidth={1.75} strokeLinejoin="round" strokeLinecap="round" />
</g>
<g ref={crossRef} style={{ display: "none" }} className="pointer-events-none">
<line y1={MARGIN.top} y2={MARGIN.top + plotH} strokeDasharray="3 3" className="stroke-zinc-900/35 dark:stroke-white/35" />
<circle r={4.5} fill={accent} className="stroke-white dark:stroke-zinc-950" strokeWidth={2} />
</g>
</svg>
)}
<div
ref={tipRef}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 flex flex-col gap-0.5 rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-1.5 whitespace-nowrap opacity-0 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.35)] transition-opacity duration-150 dark:border-white/10 dark:bg-zinc-900/95"
>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400" />
<span className="text-sm font-semibold tabular-nums tracking-[-0.01em]" />
</div>
</div>
{/* Overview + brush */}
<div className="relative mt-3" style={{ height: ovH }}>
{ready && (
<svg
width={width}
height={ovH}
className="absolute inset-0 touch-none overflow-visible"
onPointerDown={onBrushPointerDown}
onPointerMove={onBrushPointerMove}
onPointerUp={onBrushPointerUp}
onPointerCancel={onBrushPointerUp}
>
<rect
x={MARGIN.left}
y={0}
width={plotW}
height={ovH}
rx={6}
className="cursor-pointer fill-zinc-900/[0.025] stroke-zinc-900/10 dark:fill-white/[0.03] dark:stroke-white/10"
/>
<path d={overview.area} className="pointer-events-none fill-zinc-900/[0.07] dark:fill-white/[0.08]" />
<path d={overview.line} fill="none" strokeWidth={1} className="pointer-events-none stroke-zinc-900/35 dark:stroke-white/35" />
<rect ref={shadeLRef} x={MARGIN.left} y={0} height={ovH} className="pointer-events-none fill-zinc-100/60 dark:fill-zinc-950/55" />
<rect ref={shadeRRef} y={0} height={ovH} className="pointer-events-none fill-zinc-100/60 dark:fill-zinc-950/55" />
<rect
ref={windowRef}
data-brush="window"
y={0.5}
height={ovH - 1}
fill={accent}
fillOpacity={0.1}
stroke={accent}
strokeWidth={1}
className="cursor-grab group-data-[brushing=true]/bc:cursor-grabbing"
/>
{(["start", "end"] as const).map((edge) => (
<g
key={edge}
ref={edge === "start" ? handleStartRef : handleEndRef}
data-brush={edge}
role="slider"
tabIndex={0}
aria-label={edge === "start" ? "Range start" : "Range end"}
aria-orientation="horizontal"
aria-controls={summaryId}
onKeyDown={(e) => onHandleKeyDown(e, edge)}
className="group/h cursor-ew-resize outline-hidden"
>
<rect x={-10} y={0} width={20} height={ovH} fill="transparent" />
<rect
x={-4}
y={ovH / 2 - 13}
width={8}
height={26}
rx={3}
className="fill-white stroke-zinc-900/30 group-focus-visible/h:stroke-[color:var(--bc-accent)] dark:fill-zinc-800 dark:stroke-white/40"
strokeWidth={1}
/>
<rect
x={-8}
y={ovH / 2 - 17}
width={16}
height={34}
rx={6}
fill="none"
strokeWidth={2}
className="stroke-transparent group-focus-visible/h:stroke-[color:var(--bc-accent)]"
/>
<line x1={-1.25} x2={-1.25} y1={ovH / 2 - 5} y2={ovH / 2 + 5} className="stroke-zinc-900/40 dark:stroke-white/50" />
<line x1={1.25} x2={1.25} y1={ovH / 2 - 5} y2={ovH / 2 + 5} className="stroke-zinc-900/40 dark:stroke-white/50" />
</g>
))}
</svg>
)}
</div>
<p id={summaryId} className="sr-only">
{summary}
</p>
<p id={hintId} className="sr-only">
Left and right arrow keys move a crosshair between points, Home and End jump to the window edges, plus and minus zoom, Escape hides the crosshair.
</p>
<span ref={liveRef} className="sr-only" aria-live="polite" />
</div>
);
}