feat(prototype): 2D swarm data-plane visualization

Top-down SVG view: patrol flight with separation and obstacle avoidance,
mesh links appearing within radio range, pose-broadcast flashes, bulk
sync transfers with live rate and cumulative up/down counters, channel
hopping and battery per drone. Pure client-side simulation (Vite/React/TS).
This commit is contained in:
2026-07-08 13:17:49 +01:00
parent 3deaa382e1
commit c0b2ee806b
10 changed files with 2475 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
AREA,
fmtBytes,
makeWorld,
seed,
tick,
type World,
} from "./sim";
const VIEW = 1000;
export default function App(): JSX.Element {
const [droneCount, setDroneCount] = useState(8);
const [speedup, setSpeedup] = useState(1);
const [paused, setPaused] = useState(false);
const [world, setWorld] = useState<World>(() => {
seed(42);
return makeWorld(8);
});
const raf = useRef(0);
const last = useRef(performance.now());
useEffect(() => {
seed(42);
setWorld(makeWorld(droneCount));
}, [droneCount]);
useEffect(() => {
const loop = (now: number): void => {
const dt = Math.min(0.05, (now - last.current) / 1000);
last.current = now;
if (!paused && dt > 0) {
setWorld((w) => tick(w, dt * speedup));
}
raf.current = requestAnimationFrame(loop);
};
raf.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf.current);
}, [paused, speedup]);
const activeLinks = useMemo(
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
[world.links],
);
const busiest = activeLinks.slice(0, 6);
return (
<div style={styles.shell}>
<header style={styles.header}>
<div>
<div style={styles.title}>SWARM HOUSE</div>
<div style={styles.subtitle}>
swarm data plane pose broadcast + opportunistic bulk sync
</div>
</div>
<div style={styles.controls}>
<label style={styles.label}>
drones
<input
type="range"
min={3}
max={24}
value={droneCount}
onChange={(e) => setDroneCount(Number(e.target.value))}
/>
<span style={styles.value}>{droneCount}</span>
</label>
<label style={styles.label}>
speed
<input
type="range"
min={0.5}
max={6}
step={0.5}
value={speedup}
onChange={(e) => setSpeedup(Number(e.target.value))}
/>
<span style={styles.value}>{speedup}x</span>
</label>
<button style={styles.button} onClick={() => setPaused((p) => !p)}>
{paused ? "resume" : "pause"}
</button>
</div>
</header>
<div style={styles.main}>
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
style={styles.canvas}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
<stop offset="0%" stopColor="#101826" />
<stop offset="100%" stopColor="#0a0f18" />
</radialGradient>
</defs>
<rect width={VIEW} height={VIEW} fill="url(#ground)" />
{gridLines()}
{/* Mesh links */}
{activeLinks.map((l) => {
const a = world.drones[l.a];
const b = world.drones[l.b];
if (!a || !b) return null;
const mx = (a.pos.x + b.pos.x) / 2;
const my = (a.pos.y + b.pos.y) / 2;
const hot = l.rate > 20_000;
return (
<g key={l.key}>
<line
x1={sx(a.pos.x)}
y1={sy(a.pos.y)}
x2={sx(b.pos.x)}
y2={sy(b.pos.y)}
stroke={hot ? "#39d98a" : "#3d7bd9"}
strokeWidth={hot ? 2.2 : 1}
opacity={0.25 + l.flash * 0.75}
/>
{hot && (
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
</text>
)}
</g>
);
})}
{/* Obstacles */}
{world.obstacles.map((ob) => (
<g key={ob.id}>
<circle
cx={sx(ob.pos.x)}
cy={sy(ob.pos.y)}
r={(ob.radius / AREA) * VIEW}
fill={ob.moving ? "#5a2a35" : "#2a3245"}
stroke={ob.moving ? "#c05a6a" : "#46536e"}
strokeWidth={1.5}
opacity={0.85}
/>
{ob.moving && (
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
mobile
</text>
)}
</g>
))}
{/* Drones */}
{world.drones.map((d) => (
<g
key={d.id}
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
>
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
dr-{String(d.id + 1).padStart(2, "0")} · ch{d.channel} · {d.battery.toFixed(0)}%
</text>
</g>
))}
</svg>
<aside style={styles.panel}>
<Section title="Mission">
<Stat name="sim time" value={`${world.t.toFixed(0)} s`} />
<Stat name="drones" value={String(world.drones.length)} />
<Stat name="active links" value={String(world.links.size)} />
<Stat name="pose broadcasts" value={String(world.broadcasts)} />
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
</Section>
<Section title="Busiest links">
{busiest.map((l) => (
<div key={l.key} style={styles.linkRow}>
<span style={styles.linkName}>
dr-{String(l.a + 1).padStart(2, "0")} dr-{String(l.b + 1).padStart(2, "0")}
</span>
<span style={styles.linkStat}>
{fmtBytes(l.rate)}/s · {fmtBytes(l.totalUp)} {fmtBytes(l.totalDown)}
</span>
</div>
))}
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
</Section>
<Section title="Legend">
<div style={styles.dim}>blue line pose broadcast (5 Hz, ~46 B)</div>
<div style={styles.dim}>green line bulk sync (sealed partitions)</div>
<div style={styles.dim}>flash broadcast event delivered</div>
<div style={styles.dim}>red circle mobile obstacle</div>
</Section>
</aside>
</div>
</div>
);
}
function sx(x: number): number {
return (x / AREA) * VIEW;
}
function sy(y: number): number {
return (y / AREA) * VIEW;
}
function gridLines(): JSX.Element {
const lines = [];
for (let i = 1; i < 10; i++) {
const p = (i / 10) * VIEW;
lines.push(
<line key={`v${i}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />,
<line key={`h${i}`} x1={0} y1={p} x2={VIEW} y2={p} stroke="#1a2436" strokeWidth={1} />,
);
}
return <g>{lines}</g>;
}
function Section({ title, children }: { title: string; children: React.ReactNode }): JSX.Element {
return (
<div style={styles.section}>
<div style={styles.sectionTitle}>{title}</div>
{children}
</div>
);
}
function Stat({ name, value }: { name: string; value: string }): JSX.Element {
return (
<div style={styles.statRow}>
<span style={styles.dim}>{name}</span>
<span style={styles.statValue}>{value}</span>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
shell: {
minHeight: "100vh",
background: "#070b12",
color: "#dde4f0",
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
display: "flex",
flexDirection: "column",
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "14px 22px",
borderBottom: "1px solid #1a2436",
flexWrap: "wrap",
gap: 12,
},
title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" },
subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 },
controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" },
label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" },
value: { minWidth: 30, color: "#dde4f0" },
button: {
background: "#16233a",
color: "#8fb4e8",
border: "1px solid #2a3c5c",
borderRadius: 6,
padding: "6px 16px",
fontFamily: "inherit",
fontSize: 12,
cursor: "pointer",
},
main: { display: "flex", flex: 1, gap: 0, minHeight: 0 },
canvas: { flex: 1, minWidth: 0, display: "block" },
panel: {
width: 320,
borderLeft: "1px solid #1a2436",
padding: 16,
overflowY: "auto",
background: "#0a0f18",
},
section: { marginBottom: 22 },
sectionTitle: {
fontSize: 11,
letterSpacing: 2,
color: "#5a6a85",
textTransform: "uppercase",
marginBottom: 10,
borderBottom: "1px solid #1a2436",
paddingBottom: 5,
},
statRow: { display: "flex", justifyContent: "space-between", padding: "3px 0", fontSize: 12 },
statValue: { color: "#8fb4e8" },
linkRow: {
display: "flex",
flexDirection: "column",
padding: "4px 0",
fontSize: 11,
borderBottom: "1px dotted #141d2e",
},
linkName: { color: "#c8d2e4" },
linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 },
dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" },
linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" },
droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
};