import {useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent} from 'react' import {formatValue, niceTicks, timeTicks} from '@/lib/metricChart' import type {MetricSeries} from '@/types/resource' type Props = { series: MetricSeries /** The alarm threshold, drawn as a dashed reference line. */ threshold?: number | null height?: number } const MARGIN = {top: 16, right: 56, bottom: 28, left: 48} /** Few points read as a scatter of values, so each gets a marker. */ const MARKER_LIMIT = 24 /** * One metric over time: a 2px line, a solid hairline grid, the latest value labelled at the end, * an optional dashed threshold, a crosshair tooltip that snaps to the nearest point (keyboard too) * and a table view of the same values. */ export function MetricChart({series, threshold = null, height = 220}: Props) { const containerRef = useRef(null) const [width, setWidth] = useState(640) const [active, setActive] = useState(null) const titleId = useId() useEffect(() => { const element = containerRef.current // Without ResizeObserver (old browsers, tests) the chart keeps its default width. if (!element || typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, Math.round(entry.contentRect.width)))) observer.observe(element) return () => observer.disconnect() }, []) const points = series.points const geometry = useMemo(() => { const innerWidth = width - MARGIN.left - MARGIN.right const innerHeight = height - MARGIN.top - MARGIN.bottom const times = points.map((point) => Date.parse(point.timestamp)) const values = points.map((point) => point.value) const maxValue = Math.max(...values, threshold ?? -Infinity, 0) const minValue = Math.min(...values, threshold ?? Infinity, 0) const ticks = niceTicks(minValue, maxValue === minValue ? minValue + 1 : maxValue) const yMin = ticks[0] const yMax = ticks[ticks.length - 1] const tMin = times.length ? Math.min(...times) : 0 const tMax = times.length ? Math.max(...times) : 1 const x = (time: number) => MARGIN.left + (tMax === tMin ? innerWidth / 2 : ((time - tMin) / (tMax - tMin)) * innerWidth) const y = (value: number) => MARGIN.top + innerHeight - ((value - yMin) / (yMax - yMin)) * innerHeight return {innerWidth, innerHeight, times, ticks, x, y, tMin, tMax} }, [points, threshold, width, height]) if (points.length === 0) { return

No data points in this time range.

} const {times, ticks, x, y, tMin, tMax} = geometry const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(times[index]).toFixed(1)},${y(point.value).toFixed(1)}`).join(' ') const last = points.length - 1 const spansDays = tMax - tMin > 24 * 3600_000 const xTicks = timeTicks(tMin, tMax, Math.max(2, Math.floor(geometry.innerWidth / 120))) const shown = active ?? null const nearest = (clientX: number, svg: SVGSVGElement) => { const box = svg.getBoundingClientRect() const px = ((clientX - box.left) / box.width) * width let best = 0 for (let index = 1; index < times.length; index++) { if (Math.abs(x(times[index]) - px) < Math.abs(x(times[best]) - px)) best = index } return best } const onPointerMove = (event: PointerEvent) => setActive(nearest(event.clientX, event.currentTarget)) const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'ArrowLeft') { event.preventDefault(); setActive((index) => Math.max(0, (index ?? last) - 1)) } if (event.key === 'ArrowRight') { event.preventDefault(); setActive((index) => Math.min(last, (index ?? last) + 1)) } if (event.key === 'Home') { event.preventDefault(); setActive(0) } if (event.key === 'End') { event.preventDefault(); setActive(last) } if (event.key === 'Escape') setActive(null) } const summary = `${series.label} (${series.statistic}), ${points.length} points; latest ${formatValue(points[last].value)} at ${formatTime(times[last], spansDays)}${threshold === null ? '' : `; threshold ${formatValue(threshold)}`}` return
setActive(null)} onFocus={() => setActive(last)} onBlur={() => setActive(null)} onKeyDown={onKeyDown} > {summary} {ticks.map((tick) => ( {formatValue(tick)} ))} {xTicks.map((tick) => ( {formatTime(tick, spansDays)} ))} {threshold !== null && {`Threshold ${formatValue(threshold)}`} } {points.length <= MARKER_LIMIT && points.map((point, index) => ( ))} {formatValue(points[last].value)} {shown !== null && } {shown !== null &&
{formatValue(points[shown].value)} {formatTime(times[shown], true)}
}
Data table ({points.length} points) {points.slice().reverse().map((point) => ( ))}
Time{series.statistic}
{new Date(point.timestamp).toLocaleString()}{formatValue(point.value)}
} function tooltipPosition(px: number, width: number) { return px > width * 0.6 ? {right: width - px + 12} : {left: px + 12} } function formatTime(time: number, withDate: boolean): string { const date = new Date(time) return withDate ? date.toLocaleString(undefined, {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}) : date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'}) }