2 Commits
Author SHA1 Message Date
eSlider f008da1427 feat(prototype): adapt the patrol field to the display aspect ratio
CI & Release / Verify simulator (push) Successful in 7s
CI & Release / Trivy scan (push) Successful in 10s
CI & Release / Semantic Release (push) Successful in 1s
The field was a fixed square, leaving wide monitors with dead side
margins. Field width now follows the window aspect (capped at 2.2:1,
quantized to avoid rebuilds on minor resizes); routes, obstacles and
transit spawns stretch with it, and the grid keeps 100 m cells.
2026-07-08 14:51:33 +01:00
eSlider 407a70fef1 feat(prototype): crossing patrol routes and through-transit objects
CI & Release / Verify simulator (push) Successful in 7s
CI & Release / Trivy scan (push) Successful in 9s
CI & Release / Semantic Release (push) Successful in 2s
Mobile obstacles used to bounce until they wedged into far corners, and
a single perimeter route let the mesh stretch into segments that stopped
exchanging data. Even drones now fly the perimeter while odd drones fly
an X through the center, keeping bridge links alive, and transit objects
enter at one edge, cross the area, and respawn at a fresh edge.
2026-07-08 14:39:57 +01:00
2 changed files with 129 additions and 41 deletions
+44 -11
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
AREA, AREA,
MAX_ASPECT,
areaWidth,
fmtBytes, fmtBytes,
makeWorld, makeWorld,
seed, seed,
@@ -9,22 +11,48 @@ import {
} from "./sim"; } from "./sim";
const VIEW = 1000; const VIEW = 1000;
const PANEL_W = 320;
const HEADER_H = 70;
// Patrol field aspect ratio for the current window: wide monitors get a
// wider field instead of empty side margins. Quantized to 0.25 steps so
// minor resizes don't rebuild the world.
function displayAspect(): number {
const w = Math.max(320, window.innerWidth - PANEL_W);
const h = Math.max(320, window.innerHeight - HEADER_H);
const q = Math.round((w / h) * 4) / 4;
return Math.max(1, Math.min(MAX_ASPECT, q));
}
export default function App(): JSX.Element { export default function App(): JSX.Element {
const [droneCount, setDroneCount] = useState(8); const [droneCount, setDroneCount] = useState(8);
const [speedup, setSpeedup] = useState(1); const [speedup, setSpeedup] = useState(1);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [aspect, setAspect] = useState(displayAspect);
const [world, setWorld] = useState<World>(() => { const [world, setWorld] = useState<World>(() => {
seed(42); seed(42);
return makeWorld(8); return makeWorld(8, displayAspect());
}); });
const raf = useRef(0); const raf = useRef(0);
const last = useRef(performance.now()); const last = useRef(performance.now());
useEffect(() => { useEffect(() => {
seed(42); seed(42);
setWorld(makeWorld(droneCount)); setWorld(makeWorld(droneCount, aspect));
}, [droneCount]); }, [droneCount, aspect]);
useEffect(() => {
let timer = 0;
const onResize = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => setAspect(displayAspect()), 250);
};
window.addEventListener("resize", onResize);
return () => {
window.clearTimeout(timer);
window.removeEventListener("resize", onResize);
};
}, []);
useEffect(() => { useEffect(() => {
const loop = (now: number): void => { const loop = (now: number): void => {
@@ -86,7 +114,7 @@ export default function App(): JSX.Element {
<div style={styles.main}> <div style={styles.main}>
<svg <svg
viewBox={`0 0 ${VIEW} ${VIEW}`} viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
style={styles.canvas} style={styles.canvas}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
> >
@@ -96,7 +124,7 @@ export default function App(): JSX.Element {
<stop offset="100%" stopColor="#0a0f18" /> <stop offset="100%" stopColor="#0a0f18" />
</radialGradient> </radialGradient>
</defs> </defs>
<rect width={VIEW} height={VIEW} fill="url(#ground)" /> <rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
{gridLines()} {gridLines()}
{/* Mesh links */} {/* Mesh links */}
@@ -141,7 +169,7 @@ export default function App(): JSX.Element {
/> />
{ob.moving && ( {ob.moving && (
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}> <text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
mobile transit
</text> </text>
)} )}
</g> </g>
@@ -186,7 +214,7 @@ export default function App(): JSX.Element {
<div style={styles.dim}>blue line pose broadcast (5 Hz, ~46 B)</div> <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}>green line bulk sync (sealed partitions)</div>
<div style={styles.dim}>flash broadcast event delivered</div> <div style={styles.dim}>flash broadcast event delivered</div>
<div style={styles.dim}>red circle mobile obstacle</div> <div style={styles.dim}>red circle transit object crossing the area</div>
</Section> </Section>
</aside> </aside>
</div> </div>
@@ -202,12 +230,17 @@ function sy(y: number): number {
} }
function gridLines(): JSX.Element { function gridLines(): JSX.Element {
const width = sx(areaWidth());
const step = VIEW / 10; // one cell per 100 m in both directions
const lines = []; const lines = [];
for (let i = 1; i < 10; i++) { for (let p = step; p < width; p += step) {
const p = (i / 10) * VIEW;
lines.push( lines.push(
<line key={`v${i}`} x1={p} y1={0} x2={p} y2={VIEW} stroke="#1a2436" strokeWidth={1} />, <line key={`v${p}`} 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} />, );
}
for (let p = step; p < VIEW; p += step) {
lines.push(
<line key={`h${p}`} x1={0} y1={p} x2={width} y2={p} stroke="#1a2436" strokeWidth={1} />,
); );
} }
return <g>{lines}</g>; return <g>{lines}</g>;
+80 -25
View File
@@ -45,7 +45,14 @@ export interface World {
broadcasts: number; broadcasts: number;
} }
export const AREA = 1000; // meters, square side export const AREA = 1000; // meters, vertical extent of the field
export const MAX_ASPECT = 2.2;
// Horizontal extent follows the display aspect ratio (set via makeWorld),
// so wide monitors get a genuinely wider patrol area, not empty margins.
let areaX = AREA;
export function areaWidth(): number {
return areaX;
}
export const LINK_RANGE = 320; export const LINK_RANGE = 320;
const CRUISE = 28; const CRUISE = 28;
const SEPARATION = 55; const SEPARATION = 55;
@@ -65,35 +72,85 @@ function rnd(): number {
return ((seedState >>> 0) % 100000) / 100000; return ((seedState >>> 0) % 100000) / 100000;
} }
const WAYPOINTS: Vec[] = [ // Two interleaved patrol routes: even drones circle the perimeter, odd
// drones fly an X through the center. Paths cross mid-field, so the mesh
// keeps bridging between the front and the back of the formation instead
// of stretching into disconnected segments along one loop. Routes are
// derived from the current field width, so they stretch on wide displays.
function perimeterRoute(): Vec[] {
return [
{ x: 120, y: 120 }, { x: 120, y: 120 },
{ x: AREA - 120, y: 150 }, { x: areaX - 120, y: 150 },
{ x: AREA - 150, y: AREA - 120 }, { x: areaX - 150, y: AREA - 120 },
{ x: 150, y: AREA - 150 }, { x: 150, y: AREA - 150 },
]; ];
}
function crossRoute(): Vec[] {
return [
{ x: 150, y: 150 },
{ x: areaX - 150, y: AREA - 150 },
{ x: areaX - 150, y: 150 },
{ x: 150, y: AREA - 150 },
];
}
function routeOf(d: Drone): Vec[] {
return d.id % 2 === 0 ? perimeterRoute() : crossRoute();
}
export function makeWorld(droneCount: number): World { // Transit objects enter at one edge, cross the whole area, and respawn at
// a fresh edge once they leave — a stream of through-traffic instead of
// obstacles bouncing until they wedge into a corner.
const TRANSIT_MARGIN = 120;
function spawnTransit(id: number): Obstacle {
const speed = 26 + rnd() * 30;
const alongX = 120 + rnd() * (areaX - 240);
const alongY = 120 + rnd() * (AREA - 240);
const skew = (rnd() - 0.5) * speed * 0.9; // diagonal-ish crossings
const edge = Math.floor(rnd() * 4);
const table: { pos: Vec; vel: Vec }[] = [
{ pos: { x: alongX, y: -TRANSIT_MARGIN + 20 }, vel: { x: skew, y: speed } },
{ pos: { x: alongX, y: AREA + TRANSIT_MARGIN - 20 }, vel: { x: skew, y: -speed } },
{ pos: { x: -TRANSIT_MARGIN + 20, y: alongY }, vel: { x: speed, y: skew } },
{ pos: { x: areaX + TRANSIT_MARGIN - 20, y: alongY }, vel: { x: -speed, y: skew } },
];
const { pos, vel } = table[edge];
return { id, pos, radius: 22 + rnd() * 16, moving: true, vel };
}
function outOfTransit(ob: Obstacle): boolean {
return (
ob.pos.x < -TRANSIT_MARGIN ||
ob.pos.x > areaX + TRANSIT_MARGIN ||
ob.pos.y < -TRANSIT_MARGIN ||
ob.pos.y > AREA + TRANSIT_MARGIN
);
}
export function makeWorld(droneCount: number, aspect = 1): World {
areaX = AREA * Math.max(1, Math.min(MAX_ASPECT, aspect));
const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => { const drones: Drone[] = Array.from({ length: droneCount }, (_, i) => {
const angle = (i / droneCount) * Math.PI * 2; const angle = (i / droneCount) * Math.PI * 2;
return { return {
id: i, id: i,
pos: { pos: {
x: AREA / 2 + Math.cos(angle) * (150 + rnd() * 120), x: areaX / 2 + Math.cos(angle) * (150 + rnd() * 120),
y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120), y: AREA / 2 + Math.sin(angle) * (150 + rnd() * 120),
}, },
vel: { x: 0, y: 0 }, vel: { x: 0, y: 0 },
heading: angle, heading: angle,
waypoint: i % WAYPOINTS.length, waypoint: i % 4,
battery: 90 + rnd() * 10, battery: 90 + rnd() * 10,
channel: CHANNELS[i % CHANNELS.length], channel: CHANNELS[i % CHANNELS.length],
}; };
}); });
// Static obstacles sit at fixed fractions of the field, so they spread
// out instead of clustering left when the field widens
const obstacles: Obstacle[] = [ const obstacles: Obstacle[] = [
{ id: 0, pos: { x: 340, y: 420 }, radius: 60, moving: false, vel: { x: 0, y: 0 } }, { id: 0, pos: { x: areaX * 0.34, 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: 1, pos: { x: areaX * 0.7, 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: 2, pos: { x: areaX * 0.56, 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 } }, spawnTransit(3),
{ id: 4, pos: { x: 820, y: 620 }, radius: 26, moving: true, vel: { x: -10, y: 9 } }, spawnTransit(4),
spawnTransit(5),
]; ];
return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 }; return { t: 0, drones, obstacles, links: new Map(), totalBytes: 0, broadcasts: 0 };
} }
@@ -103,12 +160,13 @@ function linkKey(a: number, b: number): string {
} }
function steer(d: Drone, world: World, dt: number): Drone { function steer(d: Drone, world: World, dt: number): Drone {
const wp = WAYPOINTS[(d.waypoint + d.id) % WAYPOINTS.length]; const route = routeOf(d);
const wp = route[(d.waypoint + d.id) % route.length];
let ax = wp.x - d.pos.x; let ax = wp.x - d.pos.x;
let ay = wp.y - d.pos.y; let ay = wp.y - d.pos.y;
const wpDist = Math.hypot(ax, ay); const wpDist = Math.hypot(ax, ay);
let waypoint = d.waypoint; let waypoint = d.waypoint;
if (wpDist < 90) waypoint = (d.waypoint + 1) % WAYPOINTS.length; if (wpDist < 90) waypoint = (d.waypoint + 1) % route.length;
ax /= wpDist || 1; ax /= wpDist || 1;
ay /= wpDist || 1; ay /= wpDist || 1;
@@ -147,7 +205,7 @@ function steer(d: Drone, world: World, dt: number): Drone {
vel: { x: vx, y: vy }, vel: { x: vx, y: vy },
heading, heading,
pos: { pos: {
x: Math.max(20, Math.min(AREA - 20, d.pos.x + vx * dt)), x: Math.max(20, Math.min(areaX - 20, d.pos.x + vx * dt)),
y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)), y: Math.max(20, Math.min(AREA - 20, d.pos.y + vy * dt)),
}, },
battery: Math.max(0, d.battery - dt * 0.05), battery: Math.max(0, d.battery - dt * 0.05),
@@ -157,18 +215,15 @@ function steer(d: Drone, world: World, dt: number): Drone {
export function tick(world: World, dt: number): World { export function tick(world: World, dt: number): World {
const drones = world.drones.map((d) => steer(d, world, dt)); const drones = world.drones.map((d) => steer(d, world, dt));
const obstacles = world.obstacles.map((ob) => const obstacles = world.obstacles.map((ob) => {
ob.moving if (!ob.moving) return ob;
? { const moved = {
...ob, ...ob,
pos: { x: ob.pos.x + ob.vel.x * dt, y: ob.pos.y + ob.vel.y * dt }, 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, // Once a transit object leaves the area, respawn it at a random edge
y: ob.pos.y < 60 || ob.pos.y > AREA - 60 ? -ob.vel.y : ob.vel.y, return outOfTransit(moved) ? spawnTransit(ob.id) : moved;
}, });
}
: ob,
);
const links = new Map(world.links); const links = new Map(world.links);
let totalBytes = world.totalBytes; let totalBytes = world.totalBytes;