Import source: web/src/components/OrbitalOverview

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:04 +03:30
parent 5d8c88c151
commit c983747ef0
+429
View File
@@ -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 (
<g>
{/* crosshairs */}
<line x1={CX} y1={8} x2={CX} y2={392} stroke="var(--color-ink-800)" strokeWidth={0.5} opacity={0.25} />
<line x1={8} y1={CY} x2={392} y2={CY} stroke="var(--color-ink-800)" strokeWidth={0.5} opacity={0.25} />
{/* orbit bands */}
{radii.map((r, i) => (
<circle
key={i}
cx={CX}
cy={CY}
r={r}
fill="none"
stroke="var(--color-ink-800)"
strokeWidth={0.6}
opacity={0.55}
/>
))}
{/* 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 (
<line
key={a}
x1={p1.x}
y1={p1.y}
x2={p2.x}
y2={p2.y}
stroke="var(--color-ink-700)"
strokeWidth={1}
/>
);
})}
<circle cx={CX} cy={CY} r={189} fill="none" stroke="var(--color-ink-800)" strokeWidth={0.5} opacity={0.4} />
</g>
);
});
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<string | null>(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<Map<string, NodeSim>>(new Map());
const els = useRef<Map<string, NodeEls>>(new Map());
const scannerRef = useRef<SVGPathElement | null>(null);
const lastT = useRef<number | null>(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 (
<figure className="relative select-none">
<figcaption className="microlabel mb-3 flex items-baseline gap-4">
<span>{t.orbit}</span>
<span className="rule-fade flex-1" />
<span className="tabular">{targets.length}</span>
</figcaption>
<div className="orbital-stage relative w-full max-w-[600px] mx-auto">
{/* machined-dial backdrop — the orbit sits on real metal */}
<div
aria-hidden
className="dial-backdrop"
style={{ backgroundImage: `url(${dialTexture})` }}
/>
<svg
viewBox="0 0 400 400"
className="relative w-full block"
role="img"
aria-label={`${t.overall}: ${t.state[sys]}`}
>
<Field radii={bandRadii} />
{/* center-anchored trails/scars (updated imperatively) */}
{targets.map((target, i) => {
const sim = sims.current.get(target.id)!;
return (
<g key={`paths-${target.id}`}>
<path
ref={(node) => { 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"
/>
<path
ref={(node) => { 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}
/>
</g>
);
})}
{/* ——— core ——— */}
<motion.g
initial={reduced ? false : { opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 70, damping: 16, delay: 0.2 }}
style={{ transformOrigin: "200px 200px" }}
>
<circle cx={CX} cy={CY} r={46} fill="var(--color-ink-900)" stroke="var(--color-ink-700)" strokeWidth={0.75} />
<motion.circle
cx={CX}
cy={CY}
r={52}
fill="none"
strokeWidth={1.25}
animate={{ stroke: STATE_COLOR[sys] }}
transition={{ duration: 0.8 }}
opacity={0.9}
/>
{/* scanner hand — sweeps once a minute, like a second hand */}
{!reduced && (
<path
ref={scannerRef}
d={arcPath(CX, CY, 57, 0, 26)}
fill="none"
stroke="var(--color-brass)"
strokeWidth={1}
strokeLinecap="round"
opacity={0.8}
/>
)}
<text
x={CX}
y={CY + 4}
textAnchor="middle"
fontFamily="var(--font-display)"
fontSize={26}
fill="var(--color-paper)"
className="tabular"
>
{meanUptime !== undefined ? fmtUptime(lang, displayMean) : "—"}
<tspan fontSize={11} fill="var(--color-paper-faint)" dx={1.5}>{pctSign(lang)}</tspan>
</text>
<text
x={CX}
y={CY + 22}
textAnchor="middle"
fontFamily="var(--font-mono)"
fontSize={7}
letterSpacing={lang === "fa" ? 0 : 2}
fill="var(--color-paper-faint)"
style={{ textTransform: "uppercase" }}
>
{t.overall}
</text>
</motion.g>
{/* ——— 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 (
<g
key={target.id}
ref={(node) => { refOf(target.id).g = node; }}
transform={`translate(${pos.x.toFixed(2)} ${pos.y.toFixed(2)})`}
>
{/* flare halo (down) — driven by the frame loop */}
<circle
ref={(node) => { 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 && (
<motion.circle
key={`ripple-${target.stateChangedAt}`}
fill="none"
stroke={color}
initial={{ r: 5, opacity: 0.9, strokeWidth: 2 }}
animate={{ r: 30, opacity: 0, strokeWidth: 0.5 }}
transition={{ duration: 1.3, ease: [0.16, 1, 0.3, 1] }}
/>
)}
{/* layered soft glow — no SVG filters, just cheap circles */}
<circle
ref={(node) => { refOf(target.id).halo2 = node; }}
r={isHover ? 13 : 10}
fill={color}
opacity={0.07}
/>
<circle
ref={(node) => { refOf(target.id).halo1 = node; }}
r={isHover ? 9 : 7}
fill={color}
opacity={0.16}
/>
{/* the body itself */}
<circle
ref={(node) => { refOf(target.id).dot = node; }}
r={target.state === "down" ? 5 : 4}
fill={color}
stroke="var(--color-ink-950)"
strokeWidth={1}
/>
{target.state === "unknown" && (
<circle r={6.5} fill="none" stroke="var(--color-idle)" strokeWidth={0.5} opacity={0.6} />
)}
{/* label on hover, always for down */}
{(isHover || target.state === "down") && (
<motion.text
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.18 }}
y={-13}
textAnchor="middle"
fontFamily="var(--font-mono)"
fontSize={8.5}
fill={isHover ? "var(--color-paper)" : color}
paintOrder="stroke"
stroke="var(--color-ink-950)"
strokeWidth={3}
style={{ pointerEvents: "none" }}
>
{target.name}
</motion.text>
)}
{/* hit area */}
<circle
r={13}
fill="transparent"
style={{ cursor: "pointer" }}
onMouseEnter={() => setHovered(target.id)}
onMouseLeave={() => setHovered(null)}
onClick={() => onSelect(target.id)}
/>
</g>
);
})}
</svg>
</div>
</figure>
);
}