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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user