import init, { SimulationWasm } from './pkg/flocking.js'; const BOIDS_PER_1920X1080 = 250; const MIN_INITIAL_BOIDS = 100; const MAX_INITIAL_BOIDS = 1000; const BOID_STEP = 10; let wasm = null; let sim = null; let canvas = null; let ctx = null; let controls = null; let controlsToggle = null; let controlsResizeObserver = null; let frameCount = 0; let lastFpsUpdate = Date.now(); let displayWidth = 1; let displayHeight = 1; let resizeAnimationFrame = 0; function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function getInitialBoidCount(width, height) { const areaScale = (width * height) / (1920 * 1080); const targetCount = Math.round(BOIDS_PER_1920X1080 * areaScale); const clampedCount = clamp(targetCount, MIN_INITIAL_BOIDS, MAX_INITIAL_BOIDS); return Math.round(clampedCount / BOID_STEP) * BOID_STEP; } function syncBoidCountToViewport(width, height) { const targetCount = getInitialBoidCount(width, height); let currentCount = sim.get_boid_count(); while (currentCount < targetCount) { sim.add_boid(); currentCount = sim.get_boid_count(); } while (currentCount > targetCount) { sim.remove_boid(); currentCount = sim.get_boid_count(); } updateBoidCount(); } function updateControlsCollapsedState(isCollapsed) { if (controls === null || controlsToggle === null) { return; } controls.classList.toggle('collapsed', isCollapsed); controls.setAttribute('aria-expanded', String(!isCollapsed)); controlsToggle.setAttribute('aria-expanded', String(!isCollapsed)); controlsToggle.setAttribute('aria-label', isCollapsed ? 'Expand controls' : 'Collapse controls'); controlsToggle.textContent = isCollapsed ? '‹' : '›'; } function sizeCanvasToViewport() { const dpr = window.devicePixelRatio || 1; const nextDisplayWidth = Math.max(1, Math.floor(canvas.clientWidth)); const nextDisplayHeight = Math.max(1, Math.floor(canvas.clientHeight)); const nextBufferWidth = Math.max(1, Math.round(nextDisplayWidth * dpr)); const nextBufferHeight = Math.max(1, Math.round(nextDisplayHeight * dpr)); if (canvas.width !== nextBufferWidth || canvas.height !== nextBufferHeight) { canvas.width = nextBufferWidth; canvas.height = nextBufferHeight; } displayWidth = nextDisplayWidth; displayHeight = nextDisplayHeight; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); document.getElementById('canvasSize').textContent = `${displayWidth}x${displayHeight}`; return { width: displayWidth, height: displayHeight }; } function scheduleResize() { if (resizeAnimationFrame !== 0) { return; } resizeAnimationFrame = window.requestAnimationFrame(() => { resizeAnimationFrame = 0; const { width, height } = sizeCanvasToViewport(); if (sim !== null) { sim.resize(width, height); syncBoidCountToViewport(width, height); } }); } function setupResizeObserver() { controlsResizeObserver = new ResizeObserver(() => { scheduleResize(); }); controlsResizeObserver.observe(document.getElementById('container')); controlsResizeObserver.observe(controls); } async function run() { wasm = await init(); canvas = document.getElementById('canvas'); ctx = canvas.getContext('2d'); controls = document.getElementById('controls'); controlsToggle = document.getElementById('controlsToggle'); updateControlsCollapsedState(false); const { width, height } = sizeCanvasToViewport(); sim = new SimulationWasm(width, height, getInitialBoidCount(width, height)); setupControls(); setupResizeObserver(); updateBoidCount(); window.addEventListener('resize', scheduleResize); animate(); } function setupControls() { const alignSlider = document.getElementById('alignSlider'); const cohesionSlider = document.getElementById('cohesionSlider'); const separationSlider = document.getElementById('separationSlider'); const addBtn = document.getElementById('addBtn'); const removeBtn = document.getElementById('removeBtn'); controlsToggle.addEventListener('click', () => { const isCollapsed = !controls.classList.contains('collapsed'); updateControlsCollapsedState(isCollapsed); scheduleResize(); }); alignSlider.addEventListener('input', (e) => { const value = Number.parseFloat(e.target.value); sim.set_align_mult(value); document.getElementById('alignValue').textContent = value.toFixed(2); }); cohesionSlider.addEventListener('input', (e) => { const value = Number.parseFloat(e.target.value); sim.set_cohesion_mult(value); document.getElementById('cohesionValue').textContent = value.toFixed(2); }); separationSlider.addEventListener('input', (e) => { const value = Number.parseFloat(e.target.value); sim.set_separation_mult(value); document.getElementById('separationValue').textContent = value.toFixed(2); }); addBtn.addEventListener('click', () => { sim.add_boid(); updateBoidCount(); }); removeBtn.addEventListener('click', () => { sim.remove_boid(); updateBoidCount(); }); } function updateBoidCount() { document.getElementById('boidCount').textContent = sim.get_boid_count(); } function getBoidView() { const ptr = sim.boid_buffer_ptr(); const len = sim.boid_buffer_len(); return new Float32Array(wasm.memory.buffer, ptr, len); } function animate() { ctx.fillStyle = '#121212'; ctx.fillRect(0, 0, displayWidth, displayHeight); sim.step(); const boidData = getBoidView(); const stride = sim.boid_buffer_stride(); for (let i = 0; i < boidData.length; i += stride) { const x = boidData[i]; const y = boidData[i + 1]; const vx = boidData[i + 2]; const vy = boidData[i + 3]; const r = boidData[i + 4]; const g = boidData[i + 5]; const b = boidData[i + 6]; const a = boidData[i + 7]; const speedSq = vx * vx + vy * vy; let dx; let dy; if (speedSq > 1e-6) { const invSpeed = 1 / Math.sqrt(speedSq); dx = vx * invSpeed; dy = vy * invSpeed; } else { dx = 0; dy = -1; } const size = 5; const headMult = 3; const px = -dy; const py = dx; const tipX = x + dx * size * headMult; const tipY = y + dy * size * headMult; const baseX = x - dx * size; const baseY = y - dy * size; const leftX = baseX + px * size; const leftY = baseY + py * size; const rightX = baseX - px * size; const rightY = baseY - py * size; ctx.strokeStyle = `rgba(${r},${g},${b},${a / 255})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(tipX, tipY); ctx.lineTo(leftX, leftY); ctx.lineTo(rightX, rightY); ctx.closePath(); ctx.stroke(); } frameCount += 1; const now = Date.now(); if (now - lastFpsUpdate >= 1000) { document.getElementById('fps').textContent = frameCount; frameCount = 0; lastFpsUpdate = now; } requestAnimationFrame(animate); } await run();