Import source: web/src/components/StatusRing

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:20:09 +03:30
parent 854634d1b5
commit f8ad8d916c
+237
View File
@@ -0,0 +1,237 @@
import { memo, useMemo } from "react";
import { motion } from "motion/react";
import type { LiveTarget } from "../lib/types";
import { arcPath, circumference, latencyScale, polar } from "../lib/geometry";
import { fmtLatency, fmtUptime, pctSign } from "../lib/format";
import { springs, STATE_COLOR, useUI } from "../lib/ui";
import { useSpringNumber } from "../hooks/useSpringNumber";
const C = 60; // center
const R_TRACK = 44; // uptime ring radius
const R_LAT = 36; // latency gauge radius
const LAT_SWEEP = 270; // latency gauge max sweep, degrees
/** 60 watch-face ticks; every fifth is longer — the "fine instrument" detail. */
function Ticks() {
const ticks = useMemo(() => {
const lines = [];
for (let i = 0; i < 60; i++) {
const major = i % 5 === 0;
const a = i * 6;
const p1 = polar(C, C, major ? 50 : 51.4, a);
const p2 = polar(C, C, 53, a);
lines.push(
<line
key={i}
x1={p1.x}
y1={p1.y}
x2={p2.x}
y2={p2.y}
stroke="var(--color-ink-700)"
strokeWidth={major ? 1 : 0.5}
opacity={major ? 0.9 : 0.45}
/>,
);
}
return lines;
}, []);
return <g>{ticks}</g>;
}
interface StatusRingProps {
target: LiveTarget;
/** Uptime fraction 0..100 for the selected window. */
uptime?: number;
size?: number;
/** Mount-stagger delay in seconds (boot sequence). */
delay?: number;
}
/**
* Circular visualization #1 — the radial status ring.
* Uptime fills the outer arc; latency reads on an inner brass gauge;
* every completed probe lands as a visible pulse.
*/
export const StatusRing = memo(function StatusRing({
target,
uptime,
size = 150,
delay = 0,
}: StatusRingProps) {
const { lang, reduced } = useUI();
const color = STATE_COLOR[target.state];
const frac = (uptime ?? 0) / 100;
const circ = circumference(R_TRACK);
const latMS = target.last?.latency_ms ?? target.latency.p50_ms ?? 0;
const latSweep = latencyScale(latMS) * LAT_SWEEP;
const latCirc = circumference(R_LAT);
const displayUptime = useSpringNumber(uptime ?? 0);
const arcTransition = reduced
? { duration: 0 }
: { ...springs.arc, delay };
return (
<svg
viewBox="0 0 120 120"
width={size}
height={size}
role="img"
aria-label={`${target.name}: ${target.state}, ${
uptime !== undefined ? fmtUptime(lang, uptime) + "%" : "—"
} uptime`}
>
<Ticks />
{/* track */}
<circle
cx={C}
cy={C}
r={R_TRACK}
fill="none"
stroke="var(--color-ink-800)"
strokeWidth={1}
/>
{/* soft glow under the arc — a wide faint twin, no filters */}
<motion.circle
cx={C}
cy={C}
r={R_TRACK}
fill="none"
stroke={color}
strokeWidth={7}
strokeLinecap="round"
strokeDasharray={circ}
transform={`rotate(-90 ${C} ${C})`}
initial={{ strokeDashoffset: circ, opacity: 0 }}
animate={{
strokeDashoffset: circ * (1 - frac),
opacity: 0.16,
stroke: color,
}}
transition={arcTransition}
/>
{/* uptime arc — springs to its value, never snaps */}
<motion.circle
cx={C}
cy={C}
r={R_TRACK}
fill="none"
stroke={color}
strokeWidth={2.75}
strokeLinecap="round"
strokeDasharray={circ}
transform={`rotate(-90 ${C} ${C})`}
initial={{ strokeDashoffset: circ, opacity: 0 }}
animate={{
strokeDashoffset: circ * (1 - frac),
opacity: 1,
stroke: color,
}}
transition={arcTransition}
/>
{/* latency gauge — brass, log scale 1ms..2.5s */}
<g opacity={0.9}>
<path
d={arcPath(C, C, R_LAT, 0, LAT_SWEEP)}
fill="none"
stroke="var(--color-ink-800)"
strokeWidth={0.75}
/>
<motion.circle
cx={C}
cy={C}
r={R_LAT}
fill="none"
stroke="var(--color-brass)"
strokeWidth={1.5}
strokeLinecap="round"
strokeDasharray={latCirc}
transform={`rotate(-90 ${C} ${C})`}
initial={{ strokeDashoffset: latCirc, opacity: 0 }}
animate={{
strokeDashoffset: latCirc * (1 - latSweep / 360),
opacity: 0.95,
}}
transition={
reduced ? { duration: 0 } : { ...springs.gentle, delay: delay + 0.15 }
}
/>
</g>
{/* live pulse — a probe just landed */}
{!reduced && target.pulse > 0 && (
<motion.circle
key={target.pulse}
cx={C}
cy={C}
r={R_TRACK}
fill="none"
stroke={color}
initial={{ r: R_TRACK, opacity: 0.55, strokeWidth: 2 }}
animate={{ r: R_TRACK + 11, opacity: 0, strokeWidth: 0.4 }}
transition={{ duration: 1.1, ease: "easeOut" }}
/>
)}
{/* state ripple — a confirmed transition radiates outward */}
{!reduced && target.stateChangedAt > 0 && (
<motion.circle
key={`state-${target.stateChangedAt}`}
cx={C}
cy={C}
r={R_TRACK}
fill="none"
stroke={color}
initial={{ r: 10, opacity: 0.8, strokeWidth: 3 }}
animate={{ r: 58, opacity: 0, strokeWidth: 0.5 }}
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
/>
)}
{/* center figures */}
<motion.g
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={reduced ? { duration: 0 } : { duration: 0.6, delay: delay + 0.3 }}
>
<text
x={C}
y={C + 3}
textAnchor="middle"
fontFamily="var(--font-display)"
fontSize={uptime !== undefined && uptime < 100 ? 21 : 24}
fill="var(--color-paper)"
className="tabular"
>
{uptime !== undefined ? fmtUptime(lang, displayUptime) : "—"}
<tspan fontSize={10} fill="var(--color-paper-faint)" dx={1}>
{pctSign(lang)}
</tspan>
</text>
{/* re-keyed per probe so fresh data visibly lands */}
<motion.text
key={target.pulse}
x={C}
y={C + 17}
textAnchor="middle"
fontFamily="var(--font-mono)"
fontSize={7.5}
fill="var(--color-brass)"
className="tabular"
initial={reduced ? false : { opacity: 0, y: 3 }}
animate={{ opacity: 0.95, y: 0 }}
transition={reduced ? { duration: 0 } : springs.snappy}
>
{target.last ? fmtLatency(lang, latMS) : "·"}
</motion.text>
</motion.g>
</svg>
);
});