/* Animated junction simulation for the hero. A 4-way intersection seen from above. Cars (rectangles) move along four lanes; signals cycle NS / EW; cars stop at stop-line on red. */ function JunctionSim({ density = 0.7, speedMul = 1.0 }) { const canvasRef = React.useRef(null); const stateRef = React.useRef({ cars: [], phase: 0, // 0 = NS green, 1 = NS amber, 2 = EW green, 3 = EW amber phaseT: 0, lastSpawn: 0, fps: 60, counted: 0, avgWait: 0.0, waiting: 0, }); const [readout, setReadout] = React.useState({ flow: 0, queue: 0, phase: "NS-GO" }); React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); let raf = 0; let mounted = true; const resize = () => { const r = canvas.getBoundingClientRect(); const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = r.width * dpr; canvas.height = r.height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; resize(); const ro = new ResizeObserver(resize); ro.observe(canvas); // ---- helpers const COLORS = { bg: "#0d1320", road: "#13192a", lane: "#2a3553", laneStripe: "#475377", stopLine: "#94a3c4", car: ["#e9bf4a", "#f1e8d6", "#5fb1d0", "#a8b3cc", "#f4d77a"], red: "#e8473a", green: "#3aa766", amber: "#e9bf4a", signalShell: "#1c2236", }; const PHASE_DURATIONS = [4.5, 1.0, 4.5, 1.0]; // seconds const CAR_W = 9, CAR_L = 18; const spawnCar = () => { const r = canvas.getBoundingClientRect(); // 4 directions: 0=N->S (top->bottom), 1=E->W, 2=S->N, 3=W->E const dir = Math.floor(Math.random() * 4); const cx = r.width / 2, cy = r.height / 2; const laneOff = 13; let x, y, vx = 0, vy = 0; if (dir === 0) { x = cx + laneOff; y = -CAR_L; vy = 1; } if (dir === 1) { x = r.width + CAR_L; y = cy + laneOff; vx = -1; } if (dir === 2) { x = cx - laneOff; y = r.height + CAR_L; vy = -1; } if (dir === 3) { x = -CAR_L; y = cy - laneOff; vx = 1; } stateRef.current.cars.push({ x, y, vx, vy, dir, speed: 60 * speedMul, target: 60 * speedMul, color: COLORS.car[Math.floor(Math.random() * COLORS.car.length)], bornAt: performance.now(), waitStart: 0, totalWait: 0, passed: false, }); }; const drawSignal = (x, y, state) => { // shell ctx.fillStyle = COLORS.signalShell; ctx.fillRect(x - 5, y - 5, 10, 10); // light const fill = state === "green" ? COLORS.green : state === "amber" ? COLORS.amber : COLORS.red; ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, 2.6, 0, Math.PI * 2); ctx.fill(); // glow ctx.fillStyle = fill + "55"; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); }; let lastT = performance.now(); const tick = (t) => { if (!mounted) return; const dt = Math.min(0.05, (t - lastT) / 1000); lastT = t; const r = canvas.getBoundingClientRect(); const W = r.width, H = r.height; const cx = W / 2, cy = H / 2; // Phase update const s = stateRef.current; s.phaseT += dt; if (s.phaseT > PHASE_DURATIONS[s.phase]) { s.phaseT = 0; s.phase = (s.phase + 1) % 4; } const nsGreen = s.phase === 0; const nsAmber = s.phase === 1; const ewGreen = s.phase === 2; const ewAmber = s.phase === 3; // Spawn s.lastSpawn += dt; const spawnInterval = 0.7 / density; if (s.lastSpawn > spawnInterval) { s.lastSpawn = 0; if (s.cars.length < 60) spawnCar(); } // ---- draw bg ctx.fillStyle = COLORS.bg; ctx.fillRect(0, 0, W, H); // dim grid ctx.strokeStyle = "rgba(180,200,255,0.04)"; ctx.lineWidth = 1; for (let i = 0; i < W; i += 40) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, H); ctx.stroke(); } for (let j = 0; j < H; j += 40) { ctx.beginPath(); ctx.moveTo(0, j); ctx.lineTo(W, j); ctx.stroke(); } // roads const roadWidth = 60; ctx.fillStyle = COLORS.road; ctx.fillRect(0, cy - roadWidth / 2, W, roadWidth); // horizontal ctx.fillRect(cx - roadWidth / 2, 0, roadWidth, H); // vertical // lane center stripes (dashed) ctx.strokeStyle = COLORS.laneStripe; ctx.lineWidth = 1; ctx.setLineDash([6, 8]); ctx.beginPath(); ctx.moveTo(0, cy); ctx.lineTo(cx - roadWidth / 2, cy); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx + roadWidth / 2, cy); ctx.lineTo(W, cy); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, cy - roadWidth / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, cy + roadWidth / 2); ctx.lineTo(cx, H); ctx.stroke(); ctx.setLineDash([]); // stop lines ctx.fillStyle = COLORS.stopLine; ctx.fillRect(cx + 2, cy - roadWidth / 2 - 3, roadWidth / 2 - 2, 2); // N approach (right of vertical, top of horizontal) ctx.fillRect(cx - roadWidth / 2, cy - roadWidth / 2 - 3, 0.0001, 0.0001); // noop ctx.fillRect(cx + roadWidth / 2 + 1, cy + 2, 2, roadWidth / 2 - 2); // E approach ctx.fillRect(cx - roadWidth / 2 + 0, cy + roadWidth / 2 + 1, roadWidth / 2 - 2, 2); // S approach ctx.fillRect(cx - roadWidth / 2 - 3, cy - roadWidth / 2, 2, roadWidth / 2 - 2); // W approach // ---- update cars const stopLineN = cy - roadWidth / 2 - 4; const stopLineS = cy + roadWidth / 2 + 4; const stopLineE = cx + roadWidth / 2 + 4; const stopLineW = cx - roadWidth / 2 - 4; let queueCt = 0; for (const c of s.cars) { // determine if this car must stop const nsCar = c.dir === 0 || c.dir === 2; const ewCar = c.dir === 1 || c.dir === 3; const greenForMe = (nsCar && nsGreen) || (ewCar && ewGreen); const amberForMe = (nsCar && nsAmber) || (ewCar && ewAmber); // forward distance ahead lookup (simple): find closest car ahead in same lane let aheadGap = 9999; for (const o of s.cars) { if (o === c || o.dir !== c.dir) continue; if (c.dir === 0 && o.y > c.y && Math.abs(o.x - c.x) < 8) aheadGap = Math.min(aheadGap, o.y - c.y); if (c.dir === 2 && o.y < c.y && Math.abs(o.x - c.x) < 8) aheadGap = Math.min(aheadGap, c.y - o.y); if (c.dir === 1 && o.x < c.x && Math.abs(o.y - c.y) < 8) aheadGap = Math.min(aheadGap, c.x - o.x); if (c.dir === 3 && o.x > c.x && Math.abs(o.y - c.y) < 8) aheadGap = Math.min(aheadGap, o.x - c.x); } // distance to stop line let toStop = 9999; if (c.dir === 0) toStop = stopLineN - c.y; if (c.dir === 2) toStop = c.y - stopLineS; if (c.dir === 1) toStop = c.x - stopLineE; if (c.dir === 3) toStop = stopLineW - c.x; let mustStopAtSignal = false; if (toStop > 0 && toStop < 50 && !greenForMe) mustStopAtSignal = true; // amber: only if not too close if (amberForMe && toStop > 30) mustStopAtSignal = true; let target = 60 * speedMul; if (aheadGap < 26) target = Math.max(0, (aheadGap - 18) * 6); if (mustStopAtSignal && toStop < 45) target = Math.min(target, Math.max(0, (toStop - 4) * 6)); // smooth speed c.speed += (target - c.speed) * Math.min(1, dt * 4); if (c.speed < 1 && (mustStopAtSignal || aheadGap < 24)) { if (!c.waitStart) c.waitStart = t; queueCt++; } else if (c.waitStart) { c.totalWait += (t - c.waitStart) / 1000; c.waitStart = 0; } c.x += c.vx * c.speed * dt; c.y += c.vy * c.speed * dt; if (!c.passed) { if ((c.dir === 0 && c.y > cy) || (c.dir === 2 && c.y < cy) || (c.dir === 1 && c.x < cx) || (c.dir === 3 && c.x > cx)) { c.passed = true; s.counted++; s.avgWait = s.avgWait * 0.9 + c.totalWait * 0.1; } } } // remove offscreen s.cars = s.cars.filter(c => c.x > -40 && c.x < W + 40 && c.y > -40 && c.y < H + 40 ); // draw cars for (const c of s.cars) { ctx.save(); ctx.translate(c.x, c.y); const horiz = c.dir === 1 || c.dir === 3; const w = horiz ? CAR_L : CAR_W; const h = horiz ? CAR_W : CAR_L; ctx.fillStyle = c.color; ctx.fillRect(-w / 2, -h / 2, w, h); // windshield ctx.fillStyle = "rgba(0,0,0,0.35)"; if (c.dir === 0) ctx.fillRect(-w / 2 + 1, -h / 2 + 4, w - 2, 4); if (c.dir === 2) ctx.fillRect(-w / 2 + 1, h / 2 - 8, w - 2, 4); if (c.dir === 1) ctx.fillRect(-w / 2 + 4, -h / 2 + 1, 4, h - 2); if (c.dir === 3) ctx.fillRect(w / 2 - 8, -h / 2 + 1, 4, h - 2); ctx.restore(); } // signals (4) const nsState = nsGreen ? "green" : nsAmber ? "amber" : "red"; const ewState = ewGreen ? "green" : ewAmber ? "amber" : "red"; drawSignal(cx + roadWidth / 2 + 10, cy - roadWidth / 2 - 10, ewState); // NE drawSignal(cx - roadWidth / 2 - 10, cy + roadWidth / 2 + 10, ewState); // SW drawSignal(cx + roadWidth / 2 + 10, cy + roadWidth / 2 + 10, nsState); // SE drawSignal(cx - roadWidth / 2 - 10, cy - roadWidth / 2 - 10, nsState); // NW // overlay HUD lines (data overlays — flow vectors) ctx.strokeStyle = "rgba(105,180,220,0.25)"; ctx.lineWidth = 1; ctx.setLineDash([2, 4]); ctx.beginPath(); ctx.arc(cx, cy, roadWidth / 2 + 4, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); // Update readout occasionally if (Math.floor(t / 500) !== Math.floor(lastT / 500 - 1)) { const flow = Math.round(s.counted / Math.max(1, (t / 1000)) * 60); setReadout({ flow, queue: queueCt, phase: nsGreen ? "NS · GO" : nsAmber ? "NS · AMBER" : ewGreen ? "EW · GO" : "EW · AMBER", }); } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { mounted = false; cancelAnimationFrame(raf); ro.disconnect(); }; }, [density, speedMul]); return (