Files
floci-ui/packages/frontend/src/components/MetricChart.tsx
T
2026-10-24 22:48:41 -04:00

146 lines
7.9 KiB
TypeScript

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<HTMLDivElement>(null)
const [width, setWidth] = useState(640)
const [active, setActive] = useState<number | null>(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 <div className="metric-chart metric-chart--empty" ref={containerRef}><p className="muted">No data points in this time range.</p></div>
}
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<SVGSVGElement>) => setActive(nearest(event.clientX, event.currentTarget))
const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
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 <div className="metric-chart" ref={containerRef}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-labelledby={titleId}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={() => setActive(null)}
onFocus={() => setActive(last)}
onBlur={() => setActive(null)}
onKeyDown={onKeyDown}
>
<title id={titleId}>{summary}</title>
{ticks.map((tick) => (
<g key={tick}>
<line className="metric-chart-grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(tick)} y2={y(tick)}/>
<text className="metric-chart-axis" x={MARGIN.left - 8} y={y(tick)} dy="0.32em" textAnchor="end">{formatValue(tick)}</text>
</g>
))}
{xTicks.map((tick) => (
<text key={tick} className="metric-chart-axis" x={x(tick)} y={height - 8} textAnchor="middle">{formatTime(tick, spansDays)}</text>
))}
{threshold !== null && <g>
<line className="metric-chart-threshold" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(threshold)} y2={y(threshold)}/>
<text className="metric-chart-label" x={width - MARGIN.right} y={y(threshold) - 6} textAnchor="end">{`Threshold ${formatValue(threshold)}`}</text>
</g>}
<path className="metric-chart-line" d={path}/>
{points.length <= MARKER_LIMIT && points.map((point, index) => (
<circle key={point.timestamp} className="metric-chart-marker" cx={x(times[index])} cy={y(point.value)} r={4}/>
))}
<text className="metric-chart-label metric-chart-label--strong" x={x(times[last]) + 8} y={y(points[last].value)} dy="0.32em">{formatValue(points[last].value)}</text>
{shown !== null && <g pointerEvents="none">
<line className="metric-chart-crosshair" x1={x(times[shown])} x2={x(times[shown])} y1={MARGIN.top} y2={height - MARGIN.bottom}/>
<circle className="metric-chart-marker metric-chart-marker--active" cx={x(times[shown])} cy={y(points[shown].value)} r={5}/>
</g>}
</svg>
{shown !== null && <div className="metric-chart-tooltip" role="status" style={tooltipPosition(x(times[shown]), width)}>
<strong>{formatValue(points[shown].value)}</strong>
<span><span className="metric-chart-key" aria-hidden="true"/>{series.label} · {series.statistic}</span>
<span className="muted">{formatTime(times[shown], true)}</span>
</div>}
<details className="metric-chart-table">
<summary>Data table ({points.length} points)</summary>
<table className="table"><thead><tr><th>Time</th><th>{series.statistic}</th></tr></thead><tbody>{points.slice().reverse().map((point) => (
<tr key={point.timestamp}><td>{new Date(point.timestamp).toLocaleString()}</td><td className="mono">{formatValue(point.value)}</td></tr>
))}</tbody></table>
</details>
</div>
}
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'})
}