146 lines
7.9 KiB
TypeScript
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'})
|
|
}
|