Import source: web/src/components/Sparkline
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -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<CheckPoint | null>(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 (
|
||||||
|
<div className="h-[90px] flex items-center justify-center microlabel">
|
||||||
|
{t.noData}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const head = posFor(series[series.length - 1]);
|
||||||
|
|
||||||
|
const onMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="relative" dir="ltr">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
className="w-full block"
|
||||||
|
onMouseMove={onMove}
|
||||||
|
onMouseLeave={() => setProbe(null)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--color-brass)" stopOpacity="0.16" />
|
||||||
|
<stop offset="100%" stopColor="var(--color-brass)" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* latency grid: max line */}
|
||||||
|
<line x1={PAD} y1={PAD + 6} x2={W - PAD} y2={PAD + 6} stroke="var(--color-ink-800)" strokeWidth={0.5} strokeDasharray="3 5" />
|
||||||
|
<text x={W - PAD} y={PAD + 3} textAnchor="end" fontSize={7.5} fontFamily="var(--font-mono)" fill="var(--color-paper-faint)" className="tabular">
|
||||||
|
{fmtLatency(lang, maxLat / 1.15)}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<path d={area} fill="url(#spark-fill)" />
|
||||||
|
<motion.path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-brass)"
|
||||||
|
strokeWidth={1.4}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
initial={reduced ? false : { pathLength: 0 }}
|
||||||
|
animate={{ pathLength: 1 }}
|
||||||
|
transition={reduced ? { duration: 0 } : { duration: 0.9, ease: [0.65, 0, 0.35, 1] }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* failures drop to the floor as red ticks */}
|
||||||
|
{series.filter((p) => !p.ok).map((p) => {
|
||||||
|
const pos = posFor(p)!;
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={p.ts}
|
||||||
|
x1={pos.x}
|
||||||
|
y1={H - PAD - 7}
|
||||||
|
x2={pos.x}
|
||||||
|
y2={H - PAD}
|
||||||
|
stroke="var(--color-down)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* live head — blips when a probe lands */}
|
||||||
|
{!reduced && (
|
||||||
|
<motion.circle
|
||||||
|
key={pulse}
|
||||||
|
cx={head?.x}
|
||||||
|
cy={head?.y}
|
||||||
|
fill="var(--color-brass)"
|
||||||
|
initial={{ r: 1.5, opacity: 1 }}
|
||||||
|
animate={{ r: 6, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.9, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<circle cx={head?.x} cy={head?.y} r={2} fill="var(--color-brass)" />
|
||||||
|
|
||||||
|
{/* hover probe */}
|
||||||
|
{probe && probePos && (
|
||||||
|
<g>
|
||||||
|
<line x1={probePos.x} y1={PAD} x2={probePos.x} y2={H - PAD} stroke="var(--color-paper-faint)" strokeWidth={0.5} opacity={0.5} />
|
||||||
|
<circle cx={probePos.x} cy={probePos.y} r={3} fill="none" stroke={probe.ok ? "var(--color-brass)" : "var(--color-down)"} strokeWidth={1} />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="mt-1 h-4 flex justify-between microlabel tabular">
|
||||||
|
{probe ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: probe.ok ? "var(--color-brass)" : "var(--color-down)" }}>
|
||||||
|
{probe.ok ? fmtLatency(lang, probe.latency_ms) : probe.error || t.failed}
|
||||||
|
</span>
|
||||||
|
<span>{fmtTime(lang, probe.ts)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{fmtTime(lang, series[0].ts)}</span>
|
||||||
|
<span>{fmtTime(lang, series[series.length - 1].ts)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user