diff --git a/web/src/components/Sparkline.tsx b/web/src/components/Sparkline.tsx new file mode 100644 index 0000000..7418a9f --- /dev/null +++ b/web/src/components/Sparkline.tsx @@ -0,0 +1,164 @@ +import { useMemo, useState } from "react"; +import { motion } from "motion/react"; +import type { CheckPoint } from "../lib/types"; +import { fmtLatency, fmtTime } from "../lib/format"; +import { useUI } from "../lib/ui"; + +const W = 460; +const H = 90; +const PAD = 4; + +interface SparklineProps { + series: CheckPoint[]; + /** Re-keys the head pulse so each landing probe blips. */ + pulse: number; +} + +/** Latency over time — brass line, failures as red drops to the floor. */ +export function Sparkline({ series, pulse }: SparklineProps) { + const { t, lang, reduced } = useUI(); + const [probe, setProbe] = useState(null); + + const ok = series.filter((p) => p.ok); + const { path, area, posFor, maxLat } = useMemo(() => { + if (series.length < 2 || ok.length === 0) { + return { path: "", area: "", posFor: () => null, maxLat: 0 }; + } + const t0 = series[0].ts; + const t1 = series[series.length - 1].ts; + const span = Math.max(1, t1 - t0); + const maxLat = Math.max(...ok.map((p) => p.latency_ms)) * 1.15; + const x = (ts: number) => PAD + ((ts - t0) / span) * (W - PAD * 2); + const y = (lat: number) => H - PAD - (lat / maxLat) * (H - PAD * 2); + + let path = ""; + for (const p of ok) { + path += `${path ? "L" : "M"} ${x(p.ts).toFixed(2)} ${y(p.latency_ms).toFixed(2)} `; + } + const area = `${path} L ${x(ok[ok.length - 1].ts).toFixed(2)} ${H - PAD} L ${x(ok[0].ts).toFixed(2)} ${H - PAD} Z`; + const posFor = (p: CheckPoint) => ({ + x: x(p.ts), + y: p.ok ? y(p.latency_ms) : H - PAD, + }); + return { path, area, posFor, maxLat }; + }, [series, ok]); + + if (!path) { + return ( +
+ {t.noData} +
+ ); + } + + const head = posFor(series[series.length - 1]); + + const onMove = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const mx = ((e.clientX - rect.left) / rect.width) * W; + let best: CheckPoint | null = null; + let bestD = Infinity; + for (const p of series) { + const pos = posFor(p); + if (!pos) continue; + const d = Math.abs(pos.x - mx); + if (d < bestD) { + bestD = d; + best = p; + } + } + setProbe(best); + }; + + const probePos = probe ? posFor(probe) : null; + + return ( +
+ setProbe(null)} + > + + + + + + + + {/* latency grid: max line */} + + + {fmtLatency(lang, maxLat / 1.15)} + + + + + + {/* failures drop to the floor as red ticks */} + {series.filter((p) => !p.ok).map((p) => { + const pos = posFor(p)!; + return ( + + ); + })} + + {/* live head — blips when a probe lands */} + {!reduced && ( + + )} + + + {/* hover probe */} + {probe && probePos && ( + + + + + )} + + +
+ {probe ? ( + <> + + {probe.ok ? fmtLatency(lang, probe.latency_ms) : probe.error || t.failed} + + {fmtTime(lang, probe.ts)} + + ) : ( + <> + {fmtTime(lang, series[0].ts)} + {fmtTime(lang, series[series.length - 1].ts)} + + )} +
+
+ ); +}