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
+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`;
}