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" },
};
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing #root");
document.body.style.margin = "0";
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
);
+236
View File
@@ -0,0 +1,236 @@
// Swarm simulation core: pure functions over immutable-ish state.
// Models patrol flight, separation, obstacle avoidance, mesh links with
// pose-broadcast flashes and opportunistic bulk sync transfers.
export interface Vec {
x: number;
y: number;
}
export interface Drone {
id: number;
pos: Vec;
vel: Vec;
heading: number; // radians
waypoint: number;
battery: number; // 0..100
channel: number; // Wi-Fi channel currently in use
}
export interface Obstacle {
id: number;
pos: Vec;
radius: number;
moving: boolean;
vel: Vec;
}
export interface LinkStats {
key: string;
a: number;
b: number;
totalUp: number; // bytes, a -> b
totalDown: number; // bytes, b -> a
rate: number; // current bytes/s (both directions)
flash: number; // 0..1, decaying broadcast pulse
bulk: number; // remaining bytes of an in-flight bulk transfer
}
export interface World {
t: number;
drones: Drone[];
obstacles: Obstacle[];
links: Map<string, LinkStats>;
totalBytes: number;
broadcasts: number;
}
export const AREA = 1000; // meters, square side
export const LINK_RANGE = 320;
const CRUISE = 28;
const SEPARATION = 55;
const POSE_BYTES = 46;
const POSE_HZ = 5;
const CHANNELS = [1, 6, 11, 36, 40, 44, 149, 157];
let seedState = 1234;
export function seed(v: number): void {
seedState = v || 1;
}
function rnd(): number {
// xorshift32 — deterministic runs
seedState ^= seedState << 13;
seedState ^= seedState >>> 17;
seedState ^= seedState << 5;
return ((seedState >>> 0) % 100000) / 100000;
}
const WAYPOINTS: Vec[] = [
{ x: 120, y: 120 },
{ x: AREA - 120, y: 150 },
{ x: AREA - 150, y: AREA - 120 },
{ x: 150, y: AREA - 150 },
];
export function makeWorld(droneCount: number): World {
const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => {
const angle = (i / droneCount) * Math.PI * 2;
return {
id: i,
pos: {
x: AREA / 2 + Math.cos(angle) * (150 + rnd() * 120),
y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120),
},
vel: { x: 0, y: 0 },
heading: angle,
waypoint: i % WAYPOINTS.length,
battery: 90 + rnd() * 10,
channel: CHANNELS[i % CHANNELS.length],
};
});
const obstacles: Obstacle[] = [
{ id: 0, pos: { x: 340, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } },
{ id: 1, pos: { x: 700, y: 260 }, radius: 45, moving: false, vel: { x: 0, y: 0 } },
{ id: 2, pos: { x: 560, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } },
{ id: 3, pos: { x: 200, y: 800 }, radius: 30, moving: true, vel: { x: 14, y: -6 } },
{ id: 4, pos: { x: 820, y: 620 }, radius: 26, moving: true, vel: { x: -10, y: 9 } },
];
return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 };
}
function linkKey(a: number, b: number): string {
return a < b ? `${a}-${b}` : `${b}-${a}`;
}
function steer(d: Drone, world: World, dt: number): Drone {
const wp = WAYPOINTS[(d.waypoint + d.id) % WAYPOINTS.length];
let ax = wp.x - d.pos.x;
let ay = wp.y - d.pos.y;
const wpDist = Math.hypot(ax, ay);
let waypoint = d.waypoint;
if (wpDist < 90) waypoint = (d.waypoint + 1) % WAYPOINTS.length;
ax /= wpDist || 1;
ay /= wpDist || 1;
// Separation from peers
for (const other of world.drones) {
if (other.id === d.id) continue;
const dx = d.pos.x - other.pos.x;
const dy = d.pos.y - other.pos.y;
const dist = Math.hypot(dx, dy);
if (dist < SEPARATION && dist > 0.01) {
const push = (SEPARATION - dist) / SEPARATION;
ax += (dx / dist) * push * 2.4;
ay += (dy / dist) * push * 2.4;
}
}
// Obstacle avoidance
for (const ob of world.obstacles) {
const dx = d.pos.x - ob.pos.x;
const dy = d.pos.y - ob.pos.y;
const dist = Math.hypot(dx, dy);
const margin = ob.radius + 55;
if (dist < margin && dist > 0.01) {
const push = (margin - dist) / margin;
ax += (dx / dist) * push * 4.0;
ay += (dy / dist) * push * 4.0;
}
}
const mag = Math.hypot(ax, ay) || 1;
const vx = d.vel.x * 0.85 + (ax / mag) * CRUISE * 0.15;
const vy = d.vel.y * 0.85 + (ay / mag) * CRUISE * 0.15;
const heading = Math.atan2(vy, vx);
return {
...d,
waypoint,
vel: { x: vx, y: vy },
heading,
pos: {
x: Math.max(20, Math.min(AREA - 20, d.pos.x + vx * dt)),
y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)),
},
battery: Math.max(0, d.battery - dt * 0.05),
channel: rnd() < dt * 0.15 ? CHANNELS[Math.floor(rnd() * CHANNELS.length)] : d.channel,
};
}
export function tick(world: World, dt: number): World {
const drones = world.drones.map((d) => steer(d, world, dt));
const obstacles = world.obstacles.map((ob) =>
ob.moving
? {
...ob,
pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt },
vel: {
x: ob.pos.x < 60 || ob.pos.x > AREA - 60 ? -ob.vel.x : ob.vel.x,
y: ob.pos.y < 60 || ob.pos.y > AREA - 60 ? -ob.vel.y : ob.vel.y,
},
}
: ob,
);
const links = new Map(world.links);
let totalBytes = world.totalBytes;
let broadcasts = world.broadcasts;
// Decay all links; drop the ones out of range
for (const [key, l] of links) {
const a = drones[l.a];
const b = drones[l.b];
const inRange =
a && b && Math.hypot(a.pos.x - b.pos.x, a.pos.y - b.pos.y) <= LINK_RANGE;
if (!inRange) {
links.delete(key);
continue;
}
links.set(key, { ...l, flash: Math.max(0, l.flash - dt * 3), rate: l.rate * 0.9 });
}
for (let i = 0; i < drones.length; i++) {
for (let j = i + 1; j < drones.length; j++) {
const dist = Math.hypot(
drones[i].pos.x - drones[j].pos.x,
drones[i].pos.y - drones[j].pos.y,
);
if (dist > LINK_RANGE) continue;
const key = linkKey(i, j);
const link =
links.get(key) ??
({ key, a: i, b: j, totalUp: 0, totalDown: 0, rate: 0, flash: 0, bulk: 0 } as LinkStats);
// Pose broadcasts: 5 Hz each direction
const poseBytes = POSE_BYTES * POSE_HZ * dt;
let up = poseBytes;
let down = poseBytes;
if (rnd() < dt * POSE_HZ * 0.35) {
link.flash = 1;
broadcasts += 1;
}
// Occasional bulk sync (sealed partition replication)
if (link.bulk <= 0 && rnd() < dt * 0.02) {
link.bulk = 50_000 + rnd() * 450_000;
}
if (link.bulk > 0) {
const chunk = Math.min(link.bulk, 120_000 * dt);
link.bulk -= chunk;
if (rnd() < 0.5) up += chunk;
else down += chunk;
}
link.totalUp += up;
link.totalDown += down;
link.rate = link.rate * 0.9 + ((up + down) / dt) * 0.1;
totalBytes += up + down;
links.set(key, { ...link });
}
}
return { t: world.t + dt, drones, obstacles, links, totalBytes, broadcasts };
}
export function fmtBytes(n: number): string {
if (n < 1024) return `${n.toFixed(0)} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}