3

Stop vs Light vs Roundabout

drag Y to change arrival rate

Three intersections, one arrival process. Cars Poisson-arrive from N/E/S/W at total rate (split evenly across the four approaches) and hit a 4-way stop, a 2-phase signalized intersection, and a single-lane roundabout. The stop serves one car at a time in arrival order; the signal alternates green per NS/EW phase with a brief yellow; the roundabout lets entrants merge whenever they see an upstream gap of more than rad on the ring and never makes anyone come to a full stop once moving. HUDs report rolling throughput (cars/min) and average wait (with standard deviation) over a window. Drag the mouse up/down to push from light to saturating traffic. The classic result you should see: at low all three perform similarly; in the moderate regime the roundabout pulls ahead on throughput because it never burns the intersection on an empty green or a four-way deadlock; at saturation everything queues but the roundabout's continuous-flow design keeps wait-time variance lowest. Cars are colored by their direction of origin so you can watch each approach's queue grow or drain.

idle
556 lines · vanilla
view source
// Stop vs Light vs Roundabout — three intersections, same arrival process.
//
// Cars Poisson-arrive from N/E/S/W at rate lambda (cars/sec total, split
// evenly across the four approaches). Each car has a random destination
// (straight across, mostly) and we measure throughput + average wait.
//
// Coordinate convention per panel: local (lx, ly) in [0..panelW] x [0..H].
// Lanes are at fixed offsets from the panel centerline.

const LANE_W = 14;             // half-width of a lane (visual)
const CAR_LEN = 10;
const CAR_WID = 6;
const APPROACH_LEN = 110;      // how long the visible approach is
const CRUISE_V = 60;           // px/sec free-flow speed
const STOP_GAP = 16;           // following distance at full stop
const ENTER_GAP = 26;          // gap car wants to see before entering

const LIGHT_GREEN_S = 10;      // green per phase
const LIGHT_YELLOW_S = 1.5;

// Roundabout geometry
const RB_OUTER = 56;
const RB_INNER = 30;
const RB_LANE = (RB_OUTER + RB_INNER) / 2;

const STATS_WIN = 90;          // seconds for the trailing throughput window

let W, H;
let panelW;
let lastLambda = 0;
let lambda = 0.6;              // cars/sec, total across 4 approaches
let lambdaSmooth = 0.6;
let time = 0;
let lastMouseY = -1;

let panels;                    // [stop, light, roundabout]

function rand() { return Math.random(); }
function expRand(rate) { return -Math.log(1 - Math.random()) / Math.max(1e-6, rate); }

// ---------- car factory ----------
let nextCarId = 1;

function spawnCar(panel, dir) {
  // dir: 0=N (coming from top, going down), 1=E (from right, going left),
  //      2=S (from bottom, going up), 3=W (from left, going right)
  const c = {
    id: nextCarId++,
    dir,
    s: 0,                      // progress along its own approach -> exit, in px
    v: CRUISE_V,
    state: 'approach',         // approach | queued | crossing | exiting | done
    color: laneColor(dir),
    tSpawn: time,
    tCleared: 0,
    waitAccum: 0,
    // roundabout-specific:
    theta: 0,                  // current angle on the ring
    targetExitDir: (dir + 2) % 4, // go straight across
  };
  panel.cars.push(c);
}

function laneColor(dir) {
  const hues = [200, 30, 140, 320];
  return `hsl(${hues[dir]},80%,65%)`;
}

// ---------- per-panel state ----------

function makePanel(kind, originX) {
  return {
    kind,                      // 'stop' | 'light' | 'rb'
    originX,
    cars: [],
    arrivalTimers: [0, 0, 0, 0], // next arrival time per direction
    // stop:
    stopQueue: [],             // FIFO of car ids waiting at stop line
    occupant: null,            // car currently crossing the intersection
    // light:
    phase: 0,                  // 0 = NS green, 1 = EW green
    phaseT: 0,
    yellow: false,
    // roundabout: cars on ring are part of .cars with state 'crossing'
    // stats:
    throughputBuckets: [],     // {t, count}
    completedCount: 0,
    waitSum: 0,
    waitN: 0,
    waitVarSum: 0,             // running sum of (wait - mean)^2 via Welford
    waitMean: 0,
  };
}

// ---------- arrivals ----------
function maybeArrive(panel, dt) {
  for (let d = 0; d < 4; d++) {
    panel.arrivalTimers[d] -= dt;
    while (panel.arrivalTimers[d] <= 0) {
      // per-direction rate is lambda/4
      const per = Math.max(0.01, lambdaSmooth / 4);
      panel.arrivalTimers[d] += expRand(per);
      // only spawn if there's room at the head of the approach
      if (approachHeadFree(panel, d)) {
        spawnCar(panel, d);
      }
    }
  }
}

function approachHeadFree(panel, dir) {
  // No car within CAR_LEN of s=0 in this direction
  for (let i = 0; i < panel.cars.length; i++) {
    const c = panel.cars[i];
    if (c.dir !== dir) continue;
    if (c.state !== 'approach' && c.state !== 'queued') continue;
    if (c.s < CAR_LEN * 1.5) return false;
  }
  return true;
}

// ---------- helpers: lane geometry ----------
// In each panel, the intersection center is at (panelW/2, H/2).
// Approach length is APPROACH_LEN. s=0 is the entry point of the approach
// (outside the panel); s=APPROACH_LEN puts the car at the stop line.
// Cars on the left half of their direction (right-hand drive).
function laneOffset(dir) {
  // perpendicular offset of the inbound lane (cars drive on the right)
  // returns dx, dy to shift the lane off the panel centerline
  switch (dir) {
    case 0: return { ox:  LANE_W * 0.6, oy: 0 };   // N approach: car drives down on the east half
    case 1: return { ox: 0, oy:  LANE_W * 0.6 };    // E approach: car drives left on the south half
    case 2: return { ox: -LANE_W * 0.6, oy: 0 };   // S approach: car drives up on the west half
    case 3: return { ox: 0, oy: -LANE_W * 0.6 };   // W approach: car drives right on the north half
  }
}

// Returns world XY for a car at progress s along approach->exit,
// given panel origin and direction. Path length total = 2*APPROACH_LEN.
function approachPos(panel, c) {
  const cx = panel.originX + panelW / 2;
  const cy = H / 2;
  const off = laneOffset(c.dir);
  // entry point is APPROACH_LEN outside center along the inbound dir
  // direction vector (pointing toward center)
  let dx = 0, dy = 0;
  switch (c.dir) {
    case 0: dx = 0; dy = 1; break;
    case 1: dx = -1; dy = 0; break;
    case 2: dx = 0; dy = -1; break;
    case 3: dx = 1; dy = 0; break;
  }
  // entry = center - APPROACH_LEN * dir
  const ex = cx - dx * APPROACH_LEN;
  const ey = cy - dy * APPROACH_LEN;
  const x = ex + dx * c.s + off.ox;
  const y = ey + dy * c.s + off.oy;
  return { x, y, dx, dy };
}

// distance from approach entry where the stop line sits
const STOP_LINE_S = APPROACH_LEN - 8;
const CROSS_END_S = APPROACH_LEN + 14;
const EXIT_END_S = APPROACH_LEN * 2;

// Find the car directly ahead (same dir, larger s) within the approach segment.
function leaderOnApproach(panel, c) {
  let best = null;
  for (let i = 0; i < panel.cars.length; i++) {
    const o = panel.cars[i];
    if (o === c) continue;
    if (o.dir !== c.dir) continue;
    if (o.state !== 'approach' && o.state !== 'queued') continue;
    if (o.s <= c.s) continue;
    if (!best || o.s < best.s) best = o;
  }
  return best;
}

// ---------- update: 4-way stop ----------
function updateStop(panel, dt) {
  for (let i = 0; i < panel.cars.length; i++) {
    const c = panel.cars[i];

    if (c.state === 'approach' || c.state === 'queued') {
      const leader = leaderOnApproach(panel, c);
      const target = leader ? leader.s - (CAR_LEN + STOP_GAP) : STOP_LINE_S;

      // Are we at the head of our lane?
      const isHead = !leader;
      if (isHead && c.s >= STOP_LINE_S - 0.5) {
        // queue up if not already
        if (c.state !== 'queued') {
          c.state = 'queued';
          if (!panel.stopQueue.includes(c)) panel.stopQueue.push(c);
        }
        c.v = 0;
        c.waitAccum += dt;
        continue;
      }

      // crawl toward target
      const desired = Math.min(CRUISE_V, Math.max(0, (target - c.s) * 4));
      c.v = desired;
      const newS = c.s + c.v * dt;
      if (newS >= STOP_LINE_S && isHead) {
        c.s = STOP_LINE_S;
        c.v = 0;
      } else {
        c.s = newS;
        if (c.v < 1 && c.s < STOP_LINE_S - 1) c.waitAccum += dt;
      }
    } else if (c.state === 'crossing') {
      c.v = CRUISE_V * 0.8;
      c.s += c.v * dt;
      if (c.s >= CROSS_END_S) {
        c.state = 'exiting';
        if (panel.occupant === c) panel.occupant = null;
        recordCompletion(panel, c);
      }
    } else if (c.state === 'exiting') {
      c.v = CRUISE_V;
      c.s += c.v * dt;
      if (c.s >= EXIT_END_S) c.state = 'done';
    }
  }

  // dispatch: if no occupant, the next queued car goes
  if (!panel.occupant && panel.stopQueue.length) {
    // remove any dropped/dead entries from queue
    while (panel.stopQueue.length && panel.stopQueue[0].state !== 'queued') {
      panel.stopQueue.shift();
    }
    if (panel.stopQueue.length) {
      const next = panel.stopQueue.shift();
      next.state = 'crossing';
      panel.occupant = next;
    }
  }
}

// ---------- update: signalized ----------
function updateLight(panel, dt) {
  panel.phaseT += dt;
  const totalGreen = LIGHT_GREEN_S;
  const totalYellow = LIGHT_YELLOW_S;
  const cycle = totalGreen + totalYellow;
  panel.yellow = panel.phaseT > totalGreen;
  if (panel.phaseT >= cycle) {
    panel.phaseT -= cycle;
    panel.phase ^= 1;
    panel.yellow = false;
  }

  const greenForNS = (panel.phase === 0 && !panel.yellow);
  const greenForEW = (panel.phase === 1 && !panel.yellow);

  for (let i = 0; i < panel.cars.length; i++) {
    const c = panel.cars[i];
    const isNS = (c.dir === 0 || c.dir === 2);
    const greenForMe = isNS ? greenForNS : greenForEW;

    if (c.state === 'approach' || c.state === 'queued') {
      const leader = leaderOnApproach(panel, c);
      let target;
      if (leader) {
        target = leader.s - (CAR_LEN + STOP_GAP);
      } else if (greenForMe) {
        target = EXIT_END_S; // pass right through
      } else {
        target = STOP_LINE_S;
      }
      const desired = Math.min(CRUISE_V, Math.max(0, (target - c.s) * 4));
      c.v = desired;
      const newS = c.s + c.v * dt;

      // crossing: cars that pass STOP_LINE_S while green become 'crossing'
      if (newS >= STOP_LINE_S && c.state !== 'crossing' && greenForMe && !leader) {
        c.state = 'crossing';
      } else if (newS >= STOP_LINE_S - 0.5 && !greenForMe && !leader) {
        c.s = STOP_LINE_S - 0.5;
        c.v = 0;
        c.state = 'queued';
        c.waitAccum += dt;
        continue;
      }
      c.s = newS;
      if (c.v < 1 && c.s < STOP_LINE_S - 1) c.waitAccum += dt;
    } else if (c.state === 'crossing') {
      c.v = CRUISE_V * 0.9;
      c.s += c.v * dt;
      if (c.s >= CROSS_END_S) {
        c.state = 'exiting';
        recordCompletion(panel, c);
      }
    } else if (c.state === 'exiting') {
      c.v = CRUISE_V;
      c.s += c.v * dt;
      if (c.s >= EXIT_END_S) c.state = 'done';
    }
  }
}

// ---------- update: roundabout ----------
// We model the ring as a 1D track parameterized by theta in [0, 2pi).
// Circulating cars have c.state='crossing' and c.theta set. They move
// CCW at a fixed angular speed. Entry checks for a gap on the ring
// in the conflict region near the car's entry angle.

const RB_ENTRY_ANGLE = [
  Math.PI * 1.5,   // N enters at top
  0,               // E enters at right
  Math.PI * 0.5,   // S enters at bottom
  Math.PI,         // W enters at left
];

const RB_OMEGA = CRUISE_V * 0.9 / RB_LANE; // rad/sec on ring

function angularDist(a, b) {
  let d = a - b;
  while (d > Math.PI) d -= Math.PI * 2;
  while (d < -Math.PI) d += Math.PI * 2;
  return d;
}

function ringGapClear(panel, entryAngle) {
  // Cars on ring at angle just upstream (smaller angle, since we move CCW
  // i.e. increasing theta). The threatening car is one whose theta is
  // "behind" entryAngle by less than CONFLICT.
  const CONFLICT = 0.55; // radians upstream gap required
  for (let i = 0; i < panel.cars.length; i++) {
    const o = panel.cars[i];
    if (o.state !== 'crossing') continue;
    // distance from o to entry going CCW: how far o still has to travel to
    // reach entryAngle.
    let d = entryAngle - o.theta;
    while (d < 0) d += Math.PI * 2;
    while (d > Math.PI * 2) d -= Math.PI * 2;
    if (d < CONFLICT) return false;
  }
  return true;
}

function updateRoundabout(panel, dt) {
  // 1) advance circulating cars
  for (let i = 0; i < panel.cars.length; i++) {
    const c = panel.cars[i];
    if (c.state !== 'crossing') continue;
    // check leader on ring: another circulating car ahead within small gap
    let minAhead = Infinity;
    for (let j = 0; j < panel.cars.length; j++) {
      const o = panel.cars[j];
      if (o === c || o.state !== 'crossing') continue;
      let d = o.theta - c.theta;
      while (d < 0) d += Math.PI * 2;
      if (d < minAhead) minAhead = d;
    }
    let omega = RB_OMEGA;
    if (minAhead < 0.25) omega *= Math.max(0, minAhead / 0.25);
    c.theta += omega * dt;
    if (c.theta >= Math.PI * 2) c.theta -= Math.PI * 2;

    // exit when we reach our exit angle
    const exitAngle = RB_ENTRY_ANGLE[c.targetExitDir];
    // We exit when theta has just crossed exitAngle going CCW
    // and we have traveled at least some minimum arc from entry.
    const entryAngle = RB_ENTRY_ANGLE[c.dir];
    let traveled = c.theta - entryAngle;
    while (traveled < 0) traveled += Math.PI * 2;
    let toExit = exitAngle - entryAngle;
    while (toExit <= 0) toExit += Math.PI * 2;
    if (traveled >= toExit - 0.05) {
      c.state = 'exiting';
      c.s = APPROACH_LEN + 2; // we'll route it outward along its exit dir
      c.dir = c.targetExitDir; // morph dir so exit lane uses the exit direction
      recordCompletion(panel, c);
    }
  }

  // 2) approaching cars
  for (let i = 0; i < panel.cars.length; i++) {
    const c = panel.cars[i];
    if (c.state === 'approach' || c.state === 'queued') {
      const leader = leaderOnApproach(panel, c);
      let target;
      if (leader) {
        target = leader.s - (CAR_LEN + STOP_GAP);
      } else {
        // wait at stop line until gap on ring
        const entryAngle = RB_ENTRY_ANGLE[c.dir];
        if (ringGapClear(panel, entryAngle)) {
          // enter the ring
          c.state = 'crossing';
          c.theta = entryAngle;
          continue;
        } else {
          target = STOP_LINE_S;
        }
      }
      const desired = Math.min(CRUISE_V, Math.max(0, (target - c.s) * 4));
      c.v = desired;
      c.s += c.v * dt;
      if (c.s > STOP_LINE_S) c.s = STOP_LINE_S;
      if (c.v < 1 && c.s >= STOP_LINE_S - 1) c.waitAccum += dt;
    } else if (c.state === 'exiting') {
      c.v = CRUISE_V;
      c.s += c.v * dt;
      if (c.s >= EXIT_END_S) c.state = 'done';
    }
  }
}

// ---------- stats ----------
function recordCompletion(panel, c) {
  c.tCleared = time;
  panel.completedCount++;
  panel.throughputBuckets.push({ t: time, w: c.waitAccum });
  // Welford
  const x = c.waitAccum;
  panel.waitN++;
  const delta = x - panel.waitMean;
  panel.waitMean += delta / panel.waitN;
  panel.waitVarSum += delta * (x - panel.waitMean);
  panel.waitSum += x;
}

function trimStats(panel) {
  const cutoff = time - STATS_WIN;
  while (panel.throughputBuckets.length && panel.throughputBuckets[0].t < cutoff) {
    panel.throughputBuckets.shift();
  }
}

function throughputCpm(panel) {
  const n = panel.throughputBuckets.length;
  if (n < 2) return 0;
  const span = Math.max(1, Math.min(STATS_WIN, time - panel.throughputBuckets[0].t));
  return n * 60 / span;
}

function avgWaitRecent(panel) {
  const n = panel.throughputBuckets.length;
  if (n === 0) return 0;
  let s = 0;
  for (let i = 0; i < n; i++) s += panel.throughputBuckets[i].w;
  return s / n;
}

function waitStdRecent(panel) {
  const n = panel.throughputBuckets.length;
  if (n < 2) return 0;
  const m = avgWaitRecent(panel);
  let v = 0;
  for (let i = 0; i < n; i++) { const d = panel.throughputBuckets[i].w - m; v += d * d; }
  return Math.sqrt(v / (n - 1));
}

// ---------- drawing ----------
function drawPanelBg(panel) {
  const x0 = panel.originX;
  const cx = x0 + panelW / 2;
  const cy = H / 2;

  // panel bg
  ctxBg(x0);

  // road strips: two crossing roads
  ctxSetFill('#222936');
  // vertical road
  fillRect(cx - LANE_W, 0, LANE_W * 2, H);
  // horizontal road
  fillRect(x0, cy - LANE_W, panelW, LANE_W * 2);

  // intersection square
  if (panel.kind === 'rb') {
    // donut: outer disc dark gray, inner island green
    ctxSetFill('#2a3140');
    circle(cx, cy, RB_OUTER + 6);
    ctxSetFill('#1a2230');
    circle(cx, cy, RB_OUTER);
    // grass island
    ctxSetFill('#2e6a3a');
    circle(cx, cy, RB_INNER);
  } else {
    ctxSetFill('#1a2230');
    fillRect(cx - LANE_W, cy - LANE_W, LANE_W * 2, LANE_W * 2);
  }

  // lane dividers (dashed white) on approaches
  _ctx.strokeStyle = 'rgba(220,220,200,0.4)';
  _ctx.setLineDash([6, 6]);
  _ctx.lineWidth = 1;
  _ctx.beginPath();
  // vertical centerline
  _ctx.moveTo(cx, 0); _ctx.lineTo(cx, cy - LANE_W);
  _ctx.moveTo(cx, cy + LANE_W); _ctx.lineTo(cx, H);
  // horizontal centerline
  _ctx.moveTo(x0, cy); _ctx.lineTo(cx - LANE_W, cy);
  _ctx.moveTo(cx + LANE_W, cy); _ctx.lineTo(x0 + panelW, cy);
  _ctx.stroke();
  _ctx.setLineDash([]);
}

function drawStopSigns(panel) {
  const x0 = panel.originX;
  const cx = x0 + panelW / 2;
  const cy = H / 2;
  _ctx.fillStyle = '#d33';
  const r = 5;
  // 4 sign positions just outside the intersection corners
  const positions = [
    [cx + LANE_W + 6, cy - LANE_W - 6],
    [cx - LANE_W - 6, cy + LANE_W + 6],
    [cx - LANE_W - 6, cy - LANE_W - 6],
    [cx + LANE_W + 6, cy + LANE_W + 6],
  ];
  for (let i = 0; i < 4; i++) {
    octagon(positions[i][0], positions[i][1], r);
  }
}

function drawLights(panel) {
  const x0 = panel.originX;
  const cx = x0 + panelW / 2;
  const cy = H / 2;
  const greenNS = panel.phase === 0 && !panel.yellow;
  const yellowNS = panel.phase === 0 && panel.yellow;
  const greenEW = panel.phase === 1 && !panel.yellow;
  const yellowEW = panel.phase === 1 && panel.yellow;

  function lightAt(x, y, color) {
    _ctx.fillStyle = '#111';
    _ctx.beginPath();
    _ctx.arc(x, y, 4.5, 0, Math.PI * 2);
    _ctx.fill();
    _ctx.fillStyle = color;
    _ctx.beginPath();
    _ctx.arc(x, y, 3, 0, Math.PI * 2);
    _ctx.fill();
  }
  const cNS = greenNS ? '#3c3' : yellowNS ? '#ec3' : '#a22';
  const cEW = greenEW ? '#3c3' : yellowEW ? '#ec3' : '#a22';
  // NS lights at top-left and bottom-right of intersection
  lightAt(cx - LANE_W - 7, cy - LANE_W - 7, cNS);
  lightAt(cx + LANE_W + 7, cy + LANE_W + 7, cNS);
  // EW lights at top-right and bottom-left
  lightAt(cx + LANE_W + 7, cy - LANE_W - 7, cEW);
  lightAt(cx - LANE_W - 7, cy + LANE_W + 7, cEW);
}

function drawCars(panel) {
  const x0 = panel.originX;
  const cx = x0 + panelW / 2;
  const cy = H / 2;

  for (let i = 0; i < panel.cars.length; i++) {
    const c = panel.cars[i];
    if (panel.kind === 'rb' && c.state === 'crossing') {
      // on the ring
      const x = cx + Math.cos(c.theta) * RB_LANE;
      const y = cy + Math.sin(c.theta) * RB_LANE;
      const ang = c.theta + Math.PI / 2; // tangent CCW
      drawRect(x, y, CAR_LEN, CAR_WID, ang, c.color);
    } else {
      const p = approachPos(panel, c);
      const ang = Math.atan2(p.dy, p.dx);
      drawRect(p.x, p.y, CAR_LEN, CAR_WID, ang, c.color);
    }
  }
}

function drawRect(x, y, w, h, ang, color) {
  _ctx.save();
  _ctx.translate(x, y);
  _ctx.rotate(ang);
  _ctx.fillStyle = color;
  _ctx.fillRect(-w / 2, -h / 2, w, h);
  _ctx.restore();
}

function octagon(x, y, r) {
  _ctx.beginPath();
  for (let i = 0; i < 8; i++) {
    const a = (i / 8) * Math.PI * 2 + Math.PI / 8;
    const px = x + Math.cos(a) * r;
    const py = y + Math.sin(a) * r;
    if (i === 0) _ctx.moveTo(px, py); else _ctx.lineTo(px, py);
  }
  _ctx.closePath();
  _ctx.fill();
}

function circle(x, y, r) {
  _ctx.beginPath();
  _ctx.arc(x, y, r, 0, Math.PI * 2);
  _ctx.fill();
}

function fillRect(x, y, w, h) { _ctx.fillRect(x, y, w, h); }
function ctxBg(x0) {
  _ctx.fillStyle = '#0e1320';
  _ctx.fillRect(x0, 0, panelW, H);
}
function ctxSetFill(c) { _ctx.fillStyle = c; }

// We stash ctx so drawing helpers don't need it passed.
let _ctx;

function drawHud(panel, label) {
  const x0 = panel.originX;
  _ctx.fillStyle = 'rgba(0,0,0,0.55)';
  _ctx.fillRect(x0 + 6, 6, panelW - 12, 56);
  _ctx.fillStyle = '#fff';
  _ctx.font = 'bold 12px sans-serif';
  _ctx.textBaseline = 'top';
  _ctx.fillText(label, x0 + 12, 10);
  _ctx.font = '11px sans-serif';
  _ctx.fillStyle = '#cfd8e6';
  const tput = throughputCpm(panel);
  const aw = avgWaitRecent(panel);
  const sd = waitStdRecent(panel);
  _ctx.fillText(`throughput: ${tput.toFixed(1)} cars/min`, x0 + 12, 26);
  _ctx.fillText(`avg wait: ${aw.toFixed(1)}s (σ ${sd.toFixed(1)})`, x0 + 12, 40);
}

function drawGlobalHud() {
  _ctx.fillStyle = 'rgba(0,0,0,0.55)';
  _ctx.fillRect(8, H - 30, 260, 22);
  _ctx.fillStyle = '#fff';
  _ctx.font = '11px sans-serif';
  _ctx.textBaseline = 'top';
  _ctx.fillText(
    `arrival rate λ = ${lambdaSmooth.toFixed(2)} cars/sec  (drag Y ↑↓)`,
    14, H - 26
  );
}

// ---------- lifecycle ----------
function init({ ctx, width, height }) {
  W = width;
  H = height;
  panelW = Math.floor(W / 3);
  panels = [
    makePanel('stop', 0),
    makePanel('light', panelW),
    makePanel('rb', panelW * 2),
  ];
  _ctx = ctx;
  ctx.fillStyle = '#0e1320';
  ctx.fillRect(0, 0, W, H);
  time = 0;
}

function tick({ ctx, dt, width, height, input }) {
  _ctx = ctx;
  if (width !== W || height !== H) {
    W = width; H = height;
    panelW = Math.floor(W / 3);
    panels[0].originX = 0;
    panels[1].originX = panelW;
    panels[2].originX = panelW * 2;
  }
  if (dt > 0.08) dt = 0.08;
  time += dt;

  // lambda from mouseY: top of canvas = high rate, bottom = low rate
  if (input && Number.isFinite(input.mouseY) && input.mouseY >= 0 && input.mouseY <= H) {
    const u = 1 - input.mouseY / H; // 0..1
    lambda = 0.1 + u * 2.0;          // up to ~2 cars/sec across 4 approaches
  }
  // smooth so cars don't jolt
  lambdaSmooth += (lambda - lambdaSmooth) * Math.min(1, dt * 2);

  // simulate
  for (let i = 0; i < panels.length; i++) {
    const p = panels[i];
    maybeArrive(p, dt);
    if (p.kind === 'stop') updateStop(p, dt);
    else if (p.kind === 'light') updateLight(p, dt);
    else updateRoundabout(p, dt);
    // sweep dead
    p.cars = p.cars.filter(c => c.state !== 'done');
    trimStats(p);
  }

  // draw
  ctx.fillStyle = '#0e1320';
  ctx.fillRect(0, 0, W, H);

  for (let i = 0; i < panels.length; i++) {
    drawPanelBg(panels[i]);
    if (panels[i].kind === 'stop') drawStopSigns(panels[i]);
    if (panels[i].kind === 'light') drawLights(panels[i]);
    drawCars(panels[i]);
  }

  // panel separator lines
  ctx.strokeStyle = 'rgba(255,255,255,0.08)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(panelW, 0); ctx.lineTo(panelW, H);
  ctx.moveTo(panelW * 2, 0); ctx.lineTo(panelW * 2, H);
  ctx.stroke();

  drawHud(panels[0], '4-way stop');
  drawHud(panels[1], 'Signalized');
  drawHud(panels[2], 'Roundabout');
  drawGlobalHud();
}

Comments (0)

Log in to comment.