/* Mini interactive signal-phase demo. User adjusts: - NS green duration - EW green duration - Cycle offset And sees: a small junction with vehicles + a phase ring showing when each direction is green/amber/red, plus computed throughput. */ function SignalDemo() { const [ns, setNs] = React.useState(28); const [ew, setEw] = React.useState(22); const [arrival, setArrival] = React.useState(60); // arrivals/min total const canvasRef = React.useRef(null); const ringRef = React.useRef(null); const cars = React.useRef([]); const phaseRef = React.useRef({ phase: 0, t: 0 }); const arrivalRef = React.useRef(0); const [stats, setStats] = React.useState({ throughput: 0, avgDelay: 0, queue: 0 }); // Phase durations (s): NS green, NS amber (3), EW green, EW amber (3) const durs = [ns, 3, ew, 3]; const cycle = ns + ew + 6; React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); let raf, 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); let lastT = performance.now(); let totalPassed = 0; let waitSum = 0; let waitCt = 0; 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 phaseRef.current.t += dt; const cd = durs; while (phaseRef.current.t > cd[phaseRef.current.phase]) { phaseRef.current.t -= cd[phaseRef.current.phase]; phaseRef.current.phase = (phaseRef.current.phase + 1) % 4; } // spawn arrivalRef.current += dt; const spawnInt = 60 / Math.max(1, arrival); while (arrivalRef.current > spawnInt) { arrivalRef.current -= spawnInt; const dir = Math.floor(Math.random() * 4); const off = 11; let car = { dir, speed: 50, target: 50, color: ["#e9bf4a","#5fb1d0","#f1e8d6","#a8b3cc"][Math.floor(Math.random()*4)] , passed: false, waitStart: 0, totalWait: 0 }; if (dir === 0) Object.assign(car, { x: cx + off, y: -16, vx: 0, vy: 1 }); if (dir === 2) Object.assign(car, { x: cx - off, y: H + 16, vx: 0, vy: -1 }); if (dir === 1) Object.assign(car, { x: W + 16, y: cy + off, vx: -1, vy: 0 }); if (dir === 3) Object.assign(car, { x: -16, y: cy - off, vx: 1, vy: 0 }); if (cars.current.length < 80) cars.current.push(car); } // bg ctx.fillStyle = "#0d1320"; ctx.fillRect(0, 0, W, H); const roadWidth = 50; ctx.fillStyle = "#13192a"; ctx.fillRect(0, cy - roadWidth/2, W, roadWidth); ctx.fillRect(cx - roadWidth/2, 0, roadWidth, H); // dashes ctx.strokeStyle = "#475377"; ctx.setLineDash([5, 7]); 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([]); // signals const ph = phaseRef.current.phase; const nsGo = ph === 0; const nsAm = ph === 1; const ewGo = ph === 2; const ewAm = ph === 3; const colorFor = (go, am) => go ? "#3aa766" : am ? "#e9bf4a" : "#e8473a"; const drawLight = (x, y, c) => { ctx.fillStyle = "#1c2236"; ctx.fillRect(x-5, y-5, 10, 10); ctx.fillStyle = c; ctx.beginPath(); ctx.arc(x, y, 2.4, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = c + "44"; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI*2); ctx.fill(); }; drawLight(cx + roadWidth/2 + 10, cy - roadWidth/2 - 10, colorFor(ewGo, ewAm)); drawLight(cx - roadWidth/2 - 10, cy + roadWidth/2 + 10, colorFor(ewGo, ewAm)); drawLight(cx + roadWidth/2 + 10, cy + roadWidth/2 + 10, colorFor(nsGo, nsAm)); drawLight(cx - roadWidth/2 - 10, cy - roadWidth/2 - 10, colorFor(nsGo, nsAm)); // update cars let queueCt = 0; const stopN = cy - roadWidth/2 - 4; const stopS = cy + roadWidth/2 + 4; const stopE = cx + roadWidth/2 + 4; const stopW = cx - roadWidth/2 - 4; for (const c of cars.current) { const isNS = c.dir === 0 || c.dir === 2; const greenForMe = (isNS && nsGo) || (!isNS && ewGo); const amberForMe = (isNS && nsAm) || (!isNS && ewAm); let toStop = 9999; if (c.dir === 0) toStop = stopN - c.y; if (c.dir === 2) toStop = c.y - stopS; if (c.dir === 1) toStop = c.x - stopE; if (c.dir === 3) toStop = stopW - c.x; let aheadGap = 9999; for (const o of cars.current) { if (o === c || o.dir !== c.dir) continue; if (c.dir === 0 && o.y > c.y && Math.abs(o.x - c.x) < 6) aheadGap = Math.min(aheadGap, o.y - c.y); if (c.dir === 2 && o.y < c.y && Math.abs(o.x - c.x) < 6) aheadGap = Math.min(aheadGap, c.y - o.y); if (c.dir === 1 && o.x < c.x && Math.abs(o.y - c.y) < 6) aheadGap = Math.min(aheadGap, c.x - o.x); if (c.dir === 3 && o.x > c.x && Math.abs(o.y - c.y) < 6) aheadGap = Math.min(aheadGap, o.x - c.x); } let target = 50; if (toStop > 0 && toStop < 45 && !greenForMe) target = Math.min(target, Math.max(0, (toStop - 4) * 5)); if (amberForMe && toStop > 25) target = 0; if (aheadGap < 22) target = Math.min(target, Math.max(0, (aheadGap - 16) * 5)); c.speed += (target - c.speed) * Math.min(1, dt * 5); if (c.speed < 1) { 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; totalPassed++; waitSum += c.totalWait; waitCt++; } } } cars.current = cars.current.filter(c => c.x > -30 && c.x < W + 30 && c.y > -30 && c.y < H + 30); // draw cars for (const c of cars.current) { ctx.save(); ctx.translate(c.x, c.y); const horiz = c.dir === 1 || c.dir === 3; const w = horiz ? 16 : 8; const h = horiz ? 8 : 16; ctx.fillStyle = c.color; ctx.fillRect(-w/2, -h/2, w, h); ctx.restore(); } // phase ring const ringCanvas = ringRef.current; if (ringCanvas) { const rctx = ringCanvas.getContext("2d"); const rr = ringCanvas.getBoundingClientRect(); const dpr = Math.min(2, window.devicePixelRatio || 1); if (ringCanvas.width !== rr.width * dpr) { ringCanvas.width = rr.width * dpr; ringCanvas.height = rr.height * dpr; rctx.setTransform(dpr, 0, 0, dpr, 0, 0); } const RW = rr.width, RH = rr.height; rctx.clearRect(0, 0, RW, RH); const RC = Math.min(RW, RH) / 2 - 6; const rcx = RW / 2, rcy = RH / 2; let acc = 0; const seg = [ { dur: ns, color: "#3aa766", label: "NS GO" }, { dur: 3, color: "#e9bf4a", label: "AMBER" }, { dur: ew, color: "#3aa766", label: "EW GO" }, { dur: 3, color: "#e9bf4a", label: "AMBER" }, ]; for (const s of seg) { const start = (acc / cycle) * Math.PI * 2 - Math.PI / 2; const end = ((acc + s.dur) / cycle) * Math.PI * 2 - Math.PI / 2; rctx.strokeStyle = s.color; rctx.lineWidth = 12; rctx.beginPath(); rctx.arc(rcx, rcy, RC, start, end); rctx.stroke(); acc += s.dur; } // current phase indicator const phaseDurs = [ns, 3, ew, 3]; let elapsed = 0; for (let i = 0; i < phaseRef.current.phase; i++) elapsed += phaseDurs[i]; elapsed += phaseRef.current.t; const angle = (elapsed / cycle) * Math.PI * 2 - Math.PI / 2; const pX = rcx + Math.cos(angle) * RC; const pY = rcy + Math.sin(angle) * RC; rctx.fillStyle = "#0d1320"; rctx.beginPath(); rctx.arc(pX, pY, 7, 0, Math.PI*2); rctx.fill(); rctx.fillStyle = "#f1e8d6"; rctx.beginPath(); rctx.arc(pX, pY, 3.5, 0, Math.PI*2); rctx.fill(); // center text rctx.fillStyle = "#f1e8d6"; rctx.font = "500 22px 'Space Grotesk', sans-serif"; rctx.textAlign = "center"; rctx.textBaseline = "middle"; rctx.fillText(`${cycle}s`, rcx, rcy - 4); rctx.font = "10px 'IBM Plex Mono', monospace"; rctx.fillStyle = "#94a3c4"; rctx.fillText("CYCLE", rcx, rcy + 14); } // throughput estimation: cars passed in last 60s window approximated // simple: capacity per cycle = (ns + ew) * arrivalSat, here we use observed const expectedThroughput = Math.round((ns + ew) / cycle * arrival); if (Math.floor(t / 250) !== Math.floor(lastT / 250 - 1)) { setStats({ throughput: expectedThroughput, avgDelay: waitCt ? +(waitSum / waitCt).toFixed(1) : 0, queue: queueCt, }); } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { mounted = false; cancelAnimationFrame(raf); ro.disconnect(); }; }, [ns, ew, arrival]); return (
◉ JUNCTION SANDBOX · {arrival} arrivals/min
Try it

Tune a signal cycle. Watch flow respond.

A working sandbox of one of our signal-design models. Adjust phases and arrival rate; throughput, queue, and average delay update in real time.

North–South green {ns}s setNs(+e.target.value)} />
East–West green {ew}s setEw(+e.target.value)} />
Arrival rate {arrival}/min setArrival(+e.target.value)} />
Throughput
{stats.throughput}veh/min
Avg delay
{stats.avgDelay}s
Queue (live)
{stats.queue}
); } window.SignalDemo = SignalDemo;