diff --git a/web/src/components/OrbitalOverview.tsx b/web/src/components/OrbitalOverview.tsx new file mode 100644 index 0000000..991cc2a --- /dev/null +++ b/web/src/components/OrbitalOverview.tsx @@ -0,0 +1,429 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { motion, useAnimationFrame } from "motion/react"; +import type { LiveTarget, TargetState } from "../lib/types"; +import { arcPath, polar } from "../lib/geometry"; +import { fmtUptime, pctSign } from "../lib/format"; +import { STATE_COLOR, useUI } from "../lib/ui"; +import { useSpringNumber } from "../hooks/useSpringNumber"; +import dialTexture from "../assets/dial-texture.webp"; + +const CX = 200; +const CY = 200; +const R_MIN = 100; +const R_MAX = 172; +const R_EJECT = 184; // a down node escapes to the outer rim +const PATH_UPDATE_MS = 80; + +/** Static backdrop: orbit hairlines, compass ticks, crosshairs. */ +const Field = memo(function Field({ radii }: { radii: number[] }) { + return ( + + {/* crosshairs */} + + + {/* orbit bands */} + {radii.map((r, i) => ( + + ))} + {/* compass ticks on the outer rim */} + {[0, 90, 180, 270].map((a) => { + const p1 = polar(CX, CY, 186, a); + const p2 = polar(CX, CY, 192, a); + return ( + + ); + })} + + + ); +}); + +interface NodeSim { + angle: number; + radius: number; +} + +interface NodeEls { + g: SVGGElement | null; + trail: SVGPathElement | null; + scar: SVGPathElement | null; + dot: SVGCircleElement | null; + halo1: SVGCircleElement | null; + halo2: SVGCircleElement | null; + flare: SVGCircleElement | null; +} + +function overallState(targets: LiveTarget[]): TargetState { + if (targets.some((t) => t.state === "down")) return "down"; + if (targets.some((t) => t.state === "degraded")) return "degraded"; + if (targets.every((t) => t.state === "unknown")) return "unknown"; + return "up"; +} + +interface OrbitalOverviewProps { + targets: LiveTarget[]; + onSelect: (id: string) => void; +} + +/** + * Circular visualization #2 — the orbital overview. + * + * Continuous motion runs entirely outside React: one animation frame loop + * mutates transforms/attributes on refs, so React only renders when data + * or hover changes. This is what keeps it smooth at any target count. + */ +export function OrbitalOverview({ targets, onSelect }: OrbitalOverviewProps) { + const { t, lang, window, reduced } = useUI(); + const [hovered, setHovered] = useState(null); + + const n = Math.max(targets.length, 1); + // Shared orbit bands: many targets per ring keeps the field legible. + const nBands = Math.max(3, Math.min(6, Math.ceil(n / 4))); + const bandRadii = useMemo( + () => + Array.from({ length: nBands }, (_, b) => + nBands === 1 ? (R_MIN + R_MAX) / 2 : R_MIN + (b * (R_MAX - R_MIN)) / (nBands - 1), + ), + [nBands], + ); + + const baseRadius = (i: number) => bandRadii[i % nBands]; + // Kepler-flavored: inner bands run faster; small per-node jitter keeps + // co-orbiting nodes from locking step. + const speeds = useMemo( + () => + targets.map((_, i) => { + const k = (i % nBands) / Math.max(1, nBands - 1); + const period = (90 + k * 130) * (1 + ((i * 37) % 11) * 0.012); + return 360 / period; + }), + [targets.length, nBands], + ); + + const sims = useRef>(new Map()); + const els = useRef>(new Map()); + const scannerRef = useRef(null); + const lastT = useRef(null); + const lastPathT = useRef(0); + + targets.forEach((target, i) => { + if (!sims.current.has(target.id)) { + sims.current.set(target.id, { + angle: (i * 137.5) % 360, + radius: baseRadius(i), + }); + } + if (!els.current.has(target.id)) { + els.current.set(target.id, { + g: null, trail: null, scar: null, dot: null, + halo1: null, halo2: null, flare: null, + }); + } + }); + + useEffect(() => { + const liveIds = new Set(targets.map((target) => target.id)); + for (const id of sims.current.keys()) { + if (!liveIds.has(id)) sims.current.delete(id); + } + for (const id of els.current.keys()) { + if (!liveIds.has(id)) els.current.delete(id); + } + }, [targets]); + + const refOf = (id: string) => els.current.get(id)!; + + useAnimationFrame((time) => { + if (reduced) return; + const dt = lastT.current === null ? 0 : Math.min(0.1, (time - lastT.current) / 1000); + lastT.current = time; + const now = time / 1000; + const updatePaths = time - lastPathT.current >= PATH_UPDATE_MS; + if (updatePaths) lastPathT.current = time; + + targets.forEach((target, i) => { + const sim = sims.current.get(target.id)!; + const el = els.current.get(target.id); + if (!el?.g) return; + + sim.angle = (sim.angle + speeds[i] * dt) % 360; + const wantR = target.state === "down" ? R_EJECT : baseRadius(i); + sim.radius += (wantR - sim.radius) * Math.min(1, dt * 2.6); + + const pos = polar(CX, CY, sim.radius, sim.angle); + el.g.setAttribute("transform", `translate(${pos.x.toFixed(2)} ${pos.y.toFixed(2)})`); + + if (updatePaths && el.trail) { + el.trail.setAttribute( + "d", + target.state === "unknown" ? "" : arcPath(CX, CY, sim.radius, sim.angle - 16, sim.angle), + ); + } + if (updatePaths && el.scar) { + el.scar.setAttribute( + "d", + target.state === "down" + ? arcPath(CX, CY, baseRadius(i), sim.angle - 11, sim.angle + 11) + : "", + ); + } + + // degraded breath: gentle 2s oscillation, down flare: harsher throb + if (el.dot) { + const breath = + target.state === "degraded" ? 1 + 0.3 * (0.5 + 0.5 * Math.sin(now * Math.PI)) : 1; + const r = (target.state === "down" ? 5 : 4) * breath * (hovered === target.id ? 1.3 : 1); + el.dot.setAttribute("r", r.toFixed(2)); + } + if (el.flare) { + if (target.state === "down") { + const flare = 0.5 + 0.5 * Math.abs(Math.sin(now * 2.6)); + el.flare.setAttribute("r", (11 + flare * 5).toFixed(2)); + el.flare.setAttribute("opacity", (0.18 + flare * 0.4).toFixed(3)); + } else { + el.flare.setAttribute("opacity", "0"); + } + } + }); + + if (scannerRef.current) { + const a = (now * 6) % 360; + scannerRef.current.setAttribute("transform", `rotate(${a.toFixed(2)} ${CX} ${CY})`); + } + }); + + const sys = overallState(targets); + const winKey = window === "7d" ? "uptime_7d" : "uptime_24h"; + const known = targets.filter((x) => x[winKey] !== undefined); + const meanUptime = + known.length > 0 + ? known.reduce((acc, x) => acc + (x[winKey] ?? 0), 0) / known.length + : undefined; + const displayMean = useSpringNumber(meanUptime ?? 0); + + return ( +
+
+ {t.orbit} + + {targets.length} +
+ +
+ {/* machined-dial backdrop — the orbit sits on real metal */} +
+ + + + {/* center-anchored trails/scars (updated imperatively) */} + {targets.map((target, i) => { + const sim = sims.current.get(target.id)!; + return ( + + { refOf(target.id).trail = node; }} + d={reduced || target.state === "unknown" ? "" : arcPath(CX, CY, sim.radius, sim.angle - 16, sim.angle)} + fill="none" + stroke={STATE_COLOR[target.state]} + strokeWidth={1} + opacity={0.22} + strokeLinecap="round" + /> + { refOf(target.id).scar = node; }} + d={target.state === "down" ? arcPath(CX, CY, baseRadius(i), sim.angle - 11, sim.angle + 11) : ""} + fill="none" + stroke="var(--color-down)" + strokeWidth={0.75} + strokeDasharray="2 3" + opacity={0.45} + /> + + ); + })} + + {/* ——— core ——— */} + + + + {/* scanner hand — sweeps once a minute, like a second hand */} + {!reduced && ( + + )} + + {meanUptime !== undefined ? fmtUptime(lang, displayMean) : "—"} + {pctSign(lang)} + + + {t.overall} + + + + {/* ——— orbiting bodies (positioned imperatively) ——— */} + {targets.map((target, i) => { + const sim = sims.current.get(target.id)!; + const color = STATE_COLOR[target.state]; + const isHover = hovered === target.id; + const pos = polar( + CX, + CY, + reduced ? baseRadius(i) + (target.state === "down" ? R_EJECT - baseRadius(i) : 0) : sim.radius, + reduced ? (i * 137.5) % 360 : sim.angle, + ); + + return ( + { refOf(target.id).g = node; }} + transform={`translate(${pos.x.toFixed(2)} ${pos.y.toFixed(2)})`} + > + {/* flare halo (down) — driven by the frame loop */} + { refOf(target.id).flare = node; }} + r={11} + fill="none" + stroke="var(--color-down)" + strokeWidth={0.75} + opacity={target.state === "down" && reduced ? 0.4 : 0} + /> + + {/* state-change ripple */} + {!reduced && target.stateChangedAt > 0 && ( + + )} + + {/* layered soft glow — no SVG filters, just cheap circles */} + { refOf(target.id).halo2 = node; }} + r={isHover ? 13 : 10} + fill={color} + opacity={0.07} + /> + { refOf(target.id).halo1 = node; }} + r={isHover ? 9 : 7} + fill={color} + opacity={0.16} + /> + + {/* the body itself */} + { refOf(target.id).dot = node; }} + r={target.state === "down" ? 5 : 4} + fill={color} + stroke="var(--color-ink-950)" + strokeWidth={1} + /> + {target.state === "unknown" && ( + + )} + + {/* label on hover, always for down */} + {(isHover || target.state === "down") && ( + + {target.name} + + )} + + {/* hit area */} + setHovered(target.id)} + onMouseLeave={() => setHovered(null)} + onClick={() => onSelect(target.id)} + /> + + ); + })} + +
+
+ ); +}