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