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