diff --git a/prototype/src/App.tsx b/prototype/src/App.tsx index ff0dfaa..24233fa 100644 --- a/prototype/src/App.tsx +++ b/prototype/src/App.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { AREA, + MAX_ASPECT, + areaWidth, fmtBytes, makeWorld, seed, @@ -9,22 +11,48 @@ import { } from "./sim"; 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 { const [droneCount, setDroneCount] = useState(8); const [speedup, setSpeedup] = useState(1); const [paused, setPaused] = useState(false); + const [aspect, setAspect] = useState(displayAspect); const [world, setWorld] = useState(() => { seed(42); - return makeWorld(8); + return makeWorld(8, displayAspect()); }); const raf = useRef(0); const last = useRef(performance.now()); useEffect(() => { seed(42); - setWorld(makeWorld(droneCount)); - }, [droneCount]); + setWorld(makeWorld(droneCount, aspect)); + }, [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(() => { const loop = (now: number): void => { @@ -86,7 +114,7 @@ export default function App(): JSX.Element {
@@ -96,7 +124,7 @@ export default function App(): JSX.Element { - + {gridLines()} {/* Mesh links */} @@ -202,12 +230,17 @@ function sy(y: number): number { } function gridLines(): JSX.Element { + const width = sx(areaWidth()); + const step = VIEW / 10; // one cell per 100 m in both directions const lines = []; - for (let i = 1; i < 10; i++) { - const p = (i / 10) * VIEW; + for (let p = step; p < width; p += step) { lines.push( - , - , + , + ); + } + for (let p = step; p < VIEW; p += step) { + lines.push( + , ); } return {lines}; diff --git a/prototype/src/sim.ts b/prototype/src/sim.ts index f57f815..1970620 100644 --- a/prototype/src/sim.ts +++ b/prototype/src/sim.ts @@ -45,7 +45,14 @@ export interface World { 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; const CRUISE = 28; const SEPARATION = 55; @@ -68,21 +75,26 @@ function rnd(): number { // 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. -const PERIMETER: Vec[] = [ - { x: 120, y: 120 }, - { x: AREA - 120, y: 150 }, - { x: AREA - 150, y: AREA - 120 }, - { x: 150, y: AREA - 150 }, -]; -const CROSS: Vec[] = [ - { x: 150, y: 150 }, - { x: AREA - 150, y: AREA - 150 }, - { x: AREA - 150, y: 150 }, - { x: 150, y: AREA - 150 }, -]; +// 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: areaX - 120, y: 150 }, + { x: areaX - 150, y: AREA - 120 }, + { 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 ? PERIMETER : CROSS; + return d.id % 2 === 0 ? perimeterRoute() : crossRoute(); } // Transit objects enter at one edge, cross the whole area, and respawn at @@ -91,14 +103,15 @@ function routeOf(d: Drone): Vec[] { const TRANSIT_MARGIN = 120; function spawnTransit(id: number): Obstacle { const speed = 26 + rnd() * 30; - const along = 120 + rnd() * (AREA - 240); + 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: along, y: -TRANSIT_MARGIN + 20 }, vel: { x: skew, y: speed } }, - { pos: { x: along, y: AREA + TRANSIT_MARGIN - 20 }, vel: { x: skew, y: -speed } }, - { pos: { x: -TRANSIT_MARGIN + 20, y: along }, vel: { x: speed, y: skew } }, - { pos: { x: AREA + TRANSIT_MARGIN - 20, y: along }, vel: { x: -speed, y: skew } }, + { 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 }; @@ -106,32 +119,35 @@ function spawnTransit(id: number): Obstacle { function outOfTransit(ob: Obstacle): boolean { return ( ob.pos.x < -TRANSIT_MARGIN || - ob.pos.x > AREA + TRANSIT_MARGIN || + ob.pos.x > areaX + TRANSIT_MARGIN || ob.pos.y < -TRANSIT_MARGIN || ob.pos.y > AREA + TRANSIT_MARGIN ); } -export function makeWorld(droneCount: number): World { +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 angle = (i / droneCount) * Math.PI * 2; return { id: i, 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), }, vel: { x: 0, y: 0 }, heading: angle, - waypoint: i % PERIMETER.length, + waypoint: i % 4, battery: 90 + rnd() * 10, 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[] = [ - { 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: 0, pos: { x: areaX * 0.34, y: 420 }, radius: 60, 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: areaX * 0.56, y: 720 }, radius: 70, moving: false, vel: { x: 0, y: 0 } }, spawnTransit(3), spawnTransit(4), spawnTransit(5), @@ -189,7 +205,7 @@ function steer(d: Drone, world: World, dt: number): Drone { vel: { x: vx, y: vy }, heading, 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)), }, battery: Math.max(0, d.battery - dt * 0.05),