"use client"; import { useMemo, useState } from "react"; import type { HistoryMetricKey } from "@/lib/client/metrics-history"; import type { MetricsHistoryPoint } from "@/lib/shared/contracts/system"; import { PANEL_INSET } from "@/lib/ui/surface-tokens"; import { cn } from "@/lib/utils"; const WIDTH = 300; const HEIGHT = 64; export type ChartSeries = { key: HistoryMetricKey; label: string; /** A text colour class; lines and fills use currentColor. */ className: string; }; type MetricHistoryChartProps = { points: MetricsHistoryPoint[]; series: ChartSeries[]; rangeMs: number; now: number; /** Lines break across gaps longer than this, such as a Homeio restart. */ gapMs: number; /** Fixed top of the scale, 100 for percentages; otherwise fitted to the data. */ max?: number; /** Fit the bottom of the scale to the data as well, for temperatures. */ fitMin?: boolean; formatValue: (value: number) => string; formatTime: (t: number) => string; rangeLabel: string; /** * Spread a history shorter than the range across the whole width (the 15 min * view). Otherwise the chart keeps the real time scale and greys out the * part the server has no data for yet, so 1 h and 24 h look different. */ fitToData?: boolean; /** Shown instead of the chart while there is no reading; "Collecting…" by default. */ emptyLabel?: string; }; type Segment = { x: number; y: number }[]; function buildSegments( points: MetricsHistoryPoint[], key: HistoryMetricKey, toX: (t: number) => number, toY: (value: number) => number, gapMs: number, ) { const segments: Segment[] = []; let current: Segment = []; let previousT: number | null = null; for (const point of points) { const value = point[key]; const gap = previousT !== null && point.t - previousT > gapMs; if (typeof value !== "number" || gap) { if (current.length > 0) segments.push(current); current = []; } if (typeof value === "number") current.push({ x: toX(point.t), y: toY(value) }); previousT = point.t; } if (current.length > 0) segments.push(current); // A lone point would draw nothing; give it a hair of width. return segments.map((segment) => segment.length === 1 ? [segment[0], { x: segment[0].x + 1, y: segment[0].y }] : segment, ); } const f = (n: number) => n.toFixed(1); /** * Tangents for a monotone cubic curve (Fritsch–Carlson): smooth corners that * never overshoot a peak or dip below zero between two samples, so the curve * stays honest about the data. */ function monotoneTangents(segment: Segment) { const n = segment.length; const slopes: number[] = []; for (let i = 0; i < n - 1; i += 1) { const dx = segment[i + 1].x - segment[i].x; slopes.push(dx === 0 ? 0 : (segment[i + 1].y - segment[i].y) / dx); } const tangents = new Array(n); tangents[0] = slopes[0]; tangents[n - 1] = slopes[n - 2]; for (let i = 1; i < n - 1; i += 1) { tangents[i] = slopes[i - 1] * slopes[i] <= 0 ? 0 : (slopes[i - 1] + slopes[i]) / 2; } for (let i = 0; i < n - 1; i += 1) { 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 length = a * a + b * b; if (length > 9) { const scale = 3 / Math.sqrt(length); tangents[i] = scale * a * slopes[i]; tangents[i + 1] = scale * b * slopes[i]; } } return tangents; } /** The curve through a segment's points, without its opening move. */ function curveThrough(segment: Segment) { if (segment.length < 3) { return segment.slice(1).map((p) => `L${f(p.x)},${f(p.y)}`).join(""); } const tangents = monotoneTangents(segment); let path = ""; for (let i = 0; i < segment.length - 1; i += 1) { const from = segment[i]; const to = segment[i + 1]; const third = (to.x - from.x) / 3; path += `C${f(from.x + third)},${f(from.y + tangents[i] * third)} ` + `${f(to.x - third)},${f(to.y - tangents[i + 1] * third)} ${f(to.x)},${f(to.y)}`; } return path; } function linePath(segments: Segment[]) { return segments.map((segment) => `M${f(segment[0].x)},${f(segment[0].y)}${curveThrough(segment)}`).join(""); } function areaPath(segments: Segment[]) { return segments .map((segment) => { const first = segment[0]; const last = segment[segment.length - 1]; return `M${f(first.x)},${HEIGHT}L${f(first.x)},${f(first.y)}${curveThrough(segment)}L${f(last.x)},${HEIGHT}Z`; }) .join(""); } export function MetricHistoryChart({ points, series, rangeMs, now, gapMs, max, fitMin, formatValue, formatTime, rangeLabel, fitToData = false, emptyLabel = "Collecting…", }: MetricHistoryChartProps) { const [hoverIndex, setHoverIndex] = useState(null); const firstT = points[0]?.t; // The right edge is the latest sample, not the clock: a 24 h point is the // start of its minute, so ending at "now" left an empty strip on the right. const end = points.at(-1)?.t ?? now; const rangeStart = end - rangeMs; const partial = firstT !== undefined && firstT - rangeStart > 2 * gapMs; const start = fitToData && partial && firstT !== undefined ? firstT : rangeStart; const span = Math.max(end - start, 1); const scale = useMemo(() => { const values = points.flatMap((point) => series.map((s) => point[s.key]).filter((v): v is number => typeof v === "number"), ); if (values.length === 0) return null; const dataMax = Math.max(...values); const dataMin = Math.min(...values); const bottom = fitMin ? Math.floor(dataMin - 2) : 0; let top = max ?? dataMax * 1.15; if (top <= bottom) top = bottom + 1; return { bottom, top }; }, [points, series, max, fitMin]); const toX = (t: number) => ((t - start) / span) * WIDTH; const toY = (value: number) => scale ? HEIGHT - ((value - scale.bottom) / (scale.top - scale.bottom)) * HEIGHT : HEIGHT; const hovered = hoverIndex !== null ? points[hoverIndex] : null; function handleMove(event: React.MouseEvent) { if (points.length === 0) return; const rect = event.currentTarget.getBoundingClientRect(); const t = start + ((event.clientX - rect.left) / rect.width) * span; let best = 0; for (let i = 1; i < points.length; i += 1) { if (Math.abs(points[i].t - t) < Math.abs(points[best].t - t)) best = i; } setHoverIndex(best); } return (
setHoverIndex(null)} > {scale === null ? ( {emptyLabel} ) : ( <> {!fitToData && partial && firstT !== undefined ? (
{toX(firstT) > WIDTH * 0.25 ? ( No data yet ) : null}
) : null} s.label).join(" and ")} over the last ${rangeLabel}`} > {series.map((s) => { const segments = buildSegments(points, s.key, toX, toY, gapMs); return ( ); })} {hovered ? ( ) : null} )} {hovered ? (
WIDTH / 2 ? { right: `${100 - (toX(hovered.t) / WIDTH) * 100 + 2}%` } : { left: `${(toX(hovered.t) / WIDTH) * 100 + 2}%` } } >
{formatTime(hovered.t)}
{series.map((s) => { const value = hovered[s.key] ?? null; return (
{series.length > 1 ? {s.label} : null} {value === null ? "--" : formatValue(value)}
); })}
) : null}
{partial && firstT !== undefined ? fitToData ? `Since ${formatTime(firstT)}` : `${rangeLabel} ago · data since ${formatTime(firstT)}` : `${rangeLabel} ago`} Now
); }