27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
18:40ERNIE 5.5 Flash Preview vs GLM 5.3 Flash: Baidu's new model is very impressive!
Nov 11, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
14:03Gemini 4 Carbon: Can it handle my challenges? (First look with one-shot coding tests)
Nov 10, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
22:14GPT-6.1 Terra Pro vs Terra: How Much Better Is Pro?
Oct 27, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
You write crowd simulations where the behavior emerges from rules each person follows.
Build me a single HTML page: a section of a football stadium emptying through one exit gate.
#urgency=0.8&gate=1.2&column=1 starts simulation at load with those settings: urgency 0.0 to 1.0, gate width in yards, column 0 or 1. Any values work.data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.Before the file, two quantitative predictions: whether increasing urgency from 0.3 to 0.9 increases or decreases evacuation time at gate=1.2 and column=0, with the predicted difference in seconds; and whether enabling the column (column=1) increases or decreases evacuation time at urgency=0.9 and gate=1.2, with the predicted difference in seconds.
The single fenced html block.
Where your crowd behaves like a real one and where it does not. Name anything you special-cased: a nudge that breaks a jam, a speed cap at the gate, an allowance for overlap. If your page never jams at the gate, say so.
These are predictions, not measured results:
gate=1.2, column=0, increasing urgency from 0.3 to 0.9 will decrease evacuation time by about 25 seconds.urgency=0.9, gate=1.2, enabling the column will increase evacuation time by about 6 seconds.<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The last whistle — a crowd experiment</title>
<style>
:root {
--paper:#f1efe7; --ink:#203b33; --muted:#747b70;
--line:#d6d8cc; --orange:#d9623e; --green:#386c55;
}
* { box-sizing:border-box }
body { margin:0; background:var(--paper); color:var(--ink); font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif }
button,input { font:inherit }
button { cursor:pointer }
button:focus-visible,input:focus-visible { outline:3px solid #e7a941; outline-offset:4px }
.masthead { border-bottom:1px solid var(--line); padding:17px 5%; display:flex; align-items:center; justify-content:space-between; gap:20px }
.brand { font-size:13px; letter-spacing:.16em; font-weight:850; display:flex; gap:12px; align-items:center }
.mark { width:25px; height:29px; border:2px solid var(--ink); border-radius:3px; display:grid; place-items:center }
.mark:after { content:""; width:10px; height:10px; border:1px solid var(--ink); border-radius:50% }
.edition { color:var(--muted); font-size:11px; letter-spacing:.13em; text-transform:uppercase }
main { max-width:1440px; margin:auto; padding:34px 5% 26px }
.intro { display:flex; justify-content:space-between; gap:22px; align-items:end; margin-bottom:27px }
.eyebrow { font-size:10px; font-weight:750; letter-spacing:.17em; text-transform:uppercase; color:var(--muted) }
h1 { font:normal clamp(36px,4.7vw,61px)/1.08 Georgia,serif; letter-spacing:-.045em; margin:7px 0 10px }
.intro p { color:#667065; margin:0; max-width:490px; font-size:13px }
.live { white-space:nowrap; font-size:10px; letter-spacing:.12em; text-transform:uppercase; padding-bottom:5px }
.live i { display:inline-block; width:7px; height:7px; margin-right:8px; border-radius:50%; background:var(--green) }
.layout { display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:24px; align-items:start }
.sim { border:1px solid #bec7b9; background:#e4e9dd; overflow:hidden; border-radius:5px }
.metrics { background:#f9f8f1; display:grid; grid-template-columns:1fr 1fr 1fr; border-bottom:1px solid #c7cebf }
.metric { padding:17px 20px; border-right:1px solid var(--line) }
.metric:last-child { border:0 }
.metric label { display:block; font-size:9px; font-weight:750; letter-spacing:.13em; text-transform:uppercase; color:var(--muted) }
.value { font-size:29px; letter-spacing:-.055em; font-variant-numeric:tabular-nums; margin-top:3px; line-height:1.2 }
.value small { font-size:11px; letter-spacing:0; margin-left:4px; color:var(--muted) }
.metric .hint { font-size:10px; color:var(--muted); margin-top:3px }
.viewport { position:relative; aspect-ratio:32/27 }
canvas { display:block; width:100%; height:100% }
.map-footer { background:#dce3d5; border-top:1px solid #c6cebf; padding:10px 16px; display:flex; align-items:center; justify-content:space-between; font-size:10px; color:#52654e; gap:10px }
.legend { display:flex; gap:15px; align-items:center }
.legend span { display:flex; align-items:center; gap:5px }
.dot { width:7px; height:7px; border-radius:50%; background:#e1b86e; border:1px solid #4d6150 }
.square { width:7px; height:7px; background:#405749 }
.panel { background:#faf9f3; border:1px solid var(--line); border-radius:5px; padding:23px }
.panel-title { display:flex; justify-content:space-between; font-size:10px; font-weight:800; text-transform:uppercase; letter-spacing:.13em; border-bottom:1px solid var(--line); padding-bottom:17px; margin-bottom:22px }
.panel-title span { color:#9aa18f; font-weight:500 }
.control { margin-bottom:27px }
.control-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:11px }
.control label,.toggle-label { font-size:13px; font-weight:650 }
.readout { font-variant-numeric:tabular-nums; font-size:18px; color:var(--orange); line-height:1 }
input[type=range] { display:block; width:100%; margin:12px 0; accent-color:var(--green); cursor:pointer; height:16px }
#urgency { accent-color:var(--orange) }
.ends { display:flex; justify-content:space-between; font-size:10px; color:var(--muted) }
.help { color:var(--muted); font-size:11px; margin:9px 0 0; line-height:1.6 }
.number-wrap { display:flex; align-items:center; gap:5px; color:var(--muted); font-size:11px }
input[type=number] { width:72px; color:var(--ink); background:#efefe5; border:1px solid var(--line); border-radius:3px; text-align:right; padding:5px 6px; font-variant-numeric:tabular-nums }
.toggle-row { display:flex; justify-content:space-between; align-items:center; gap:15px }
.switch { position:relative; width:38px; height:23px; flex-shrink:0 }
.switch input { opacity:0; position:absolute; inset:0; width:100%; height:100%; margin:0; cursor:pointer; z-index:2 }
.slider { position:absolute; inset:0; border-radius:20px; background:#d3d8ca; transition:background .2s }
.slider:before { content:""; position:absolute; width:17px; height:17px; top:3px; left:3px; border-radius:50%; background:white; box-shadow:0 1px 3px #0002; transition:transform .2s }
.switch input:checked + .slider { background:var(--green) }
.switch input:checked + .slider:before { transform:translateX(15px) }
.switch input:focus-visible + .slider { outline:3px solid #e7a941; outline-offset:3px }
.rule { border-top:1px solid var(--line); margin:20px 0 }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:9px }
button { border-radius:3px; border:1px solid #bcc6b5; padding:11px 6px; background:transparent; color:var(--ink); font-size:12px; font-weight:650 }
button.primary { background:var(--ink); color:#fff; border-color:var(--ink) }
button:hover { filter:brightness(.95) }
.progress-title { display:flex; justify-content:space-between; margin:21px 0 8px; font-size:10px; color:var(--muted) }
.track { height:4px; border-radius:3px; background:#dfe3d6; overflow:hidden }
.fill { height:100%; width:0; background:var(--green) }
.note { font-size:11px; color:var(--muted); margin:18px 1px 0; line-height:1.7 }
.notes { margin-top:19px; border-top:1px solid var(--line); padding-top:16px }
.notes h2 { font:italic 21px Georgia,serif; margin:0 0 7px }
.notes p { font-size:11px; color:var(--muted); margin:0; line-height:1.8 }
.bottom { display:flex; justify-content:space-between; gap:15px; margin-top:18px; font-size:10px; color:var(--muted) }
@media(min-width:1350px) { .layout { grid-template-columns:minmax(0,1fr) 310px } }
@media(max-width:850px) {
main { padding:25px 4% }
.layout { grid-template-columns:minmax(0,1fr) 250px; gap:14px }
.panel { padding:17px }
.metric { padding:13px 10px }
.value { font-size:24px }
}
@media(max-width:650px) {
.masthead { padding:14px 4% }
.edition { font-size:9px }
.intro { align-items:start; margin-bottom:20px }
.intro p { max-width:260px; font-size:12px }
.live { font-size:8px; padding-top:12px }
.layout { grid-template-columns:1fr }
.panel { padding:21px }
.control { margin-bottom:23px }
.bottom { flex-direction:column; gap:4px }
}
</style>
</head>
<body>
<header class="masthead">
<div class="brand"><span class="mark" aria-hidden="true"></span>FIELD / NOTES</div>
<div class="edition">An interactive study · No. 08</div>
</header>
<main>
<section class="intro">
<div>
<div class="eyebrow">Crowd dynamics / the bottleneck</div>
<h1>The last whistle.</h1>
<p>300 supporters. One way out. Change the conditions and watch a crowd find its own flow.</p>
</div>
<div class="live"><i id="statusDot"></i><span id="statusText">Simulation live</span></div>
</section>
<div class="layout">
<section class="sim" aria-label="Live crowd simulation">
<div class="metrics">
<div class="metric">
<label>Outflow</label>
<div class="value"><span id="flow">0</span><small>people/min</small></div>
<div class="hint">Rolling 10 simulated seconds</div>
</div>
<div class="metric">
<label id="clockLabel">Elapsed time</label>
<div class="value" id="clock">0:00.0</div>
<div class="hint">Freezes when everyone is out</div>
</div>
<div class="metric">
<label>Still inside</label>
<div class="value"><span id="remaining">300</span><small>/ 300</small></div>
<div class="hint" id="departureText">0 supporters have left</div>
</div>
</div>
<div class="viewport">
<canvas id="scene" aria-label="Top-down view of supporters moving from terraces through the north exit"></canvas>
</div>
<div class="map-footer">
<div class="legend"><span><i class="dot"></i>Supporter</span><span><i class="square"></i>Solid barrier</span></div>
<span>Top-down · metres to scale</span>
</div>
</section>
<aside>
<section class="panel">
<div class="panel-title">Experiment controls <span>01—03</span></div>
<div class="control">
<div class="control-head">
<label for="urgency">01 / Urgency</label>
<output class="readout" id="urgencyValue" for="urgency">0.60</output>
</div>
<input id="urgency" type="range" min="0" max="1" step="0.01" value="0.6">
<div class="ends"><span>Unhurried</span><span>Full pressure</span></div>
<p class="help">More urgency means faster walking, stronger pushing and greater contact friction.</p>
</div>
<div class="control">
<div class="control-head">
<label for="gateNumber">02 / Gate width</label>
<div class="number-wrap"><input id="gateNumber" type="number" min="0.25" max="24" step="0.05" value="1.2" aria-label="Gate width in yards"><span>yd</span></div>
</div>
<input id="gate" type="range" min="0.25" max="24" step="0.05" value="1.2" aria-label="Gate width in yards">
<div class="ends"><span>0.25 yd</span><span>24 yd</span></div>
<p class="help">A narrow opening can form an arch—or stop the crowd entirely.</p>
</div>
<div class="control">
<div class="toggle-row">
<label class="toggle-label" for="column">03 / Add a column</label>
<label class="switch">
<input id="column" type="checkbox" aria-label="Place a column in front of the gate">
<span class="slider"></span>
</label>
</div>
<p class="help">A 1 m diameter obstacle, 2.3 m in front of the gate. Does splitting the crowd help?</p>
</div>
<div class="rule"></div>
<div class="actions">
<button id="pause" type="button">Ⅱ Pause</button>
<button id="reset" type="button" class="primary">↻ Refill stand</button>
</div>
<div class="progress-title"><span>Section cleared</span><span id="percent">0%</span></div>
<div class="track"><div id="progress" class="fill"></div></div>
<p class="note">Urgency changes live. Changing the gate or column refills the stand so geometry never appears inside a person.</p>
</section>
<section class="notes">
<h2>Small rules. Collective motion.</h2>
<p>Each disc seeks the exit, keeps personal space and exchanges pressure through contact. No queues, lanes or departure times are scripted.</p>
</section>
</aside>
</div>
<footer class="bottom">
<span>MODEL 08 / Hard-disc crowd · 60 Hz physics · 1 yd = 0.9144 m</span>
<span>An experiment, not an evacuation safety assessment.</span>
</footer>
</main>
<script>
"use strict";
(() => {
const $ = id => document.getElementById(id);
const canvas = $("scene"), ctx = canvas.getContext("2d");
const N = 300, R = 0.225, DT = 1 / 60, EPS = 0.00001;
const W = 32, H = 27, CX = 16, WALL_Y = 5.3;
const POST_Y = WALL_Y + 2.3, POST_R = 0.5, WALL_R = 0.15;
const LEFT = 1.2, RIGHT = 30.8, BOTTOM = 25.3, EXIT_Y = 2.2;
const CELL = 1, MAX_SPEED = 6;
const colors = ["#e2bb72","#f1e6c9","#9bbba3","#cb795b","#6d9781","#bfc7a7"];
let urgency = .6, gateYards = 1.2, hasColumn = false;
const params = new URLSearchParams(location.hash.slice(1));
function finiteParam(name, fallback, lo, hi) {
const raw = params.get(name);
if (raw === null || raw.trim() === "") return fallback;
const v = Number(raw);
return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fallback;
}
urgency = finiteParam("urgency", urgency, 0, 1);
gateYards = finiteParam("gate", gateYards, .25, 24);
hasColumn = finiteParam("column", 0, 0, 1) >= .5;
let people = [], walls = [], exitTimes = [], elapsed = 0, departed = 0;
let running = true, done = false, frame = 0, grid = new Map();
let gateL, gateR, seed = 87129, acc = 0, last = performance.now();
let viewWidth = 0, viewHeight = 0;
const clamp = (v,a,b) => Math.max(a,Math.min(b,v));
function random() {
seed = (Math.imul(seed,1664525) + 1013904223) >>> 0;
return seed / 4294967296;
}
function syncControls() {
$("urgency").value = urgency;
$("urgencyValue").textContent = urgency.toFixed(2);
$("gate").value = gateYards;
$("gateNumber").value = Number(gateYards.toFixed(3));
$("column").checked = hasColumn;
}
function buildWalls() {
const width = gateYards * .9144;
gateL = CX-width/2; gateR = CX+width/2;
// Capsule end caps are included in the measured clear gate width.
walls = [
{ax:LEFT,ay:WALL_Y,bx:gateL-WALL_R,by:WALL_Y},
{ax:gateR+WALL_R,ay:WALL_Y,bx:RIGHT,by:WALL_Y},
{ax:LEFT,ay:-3,bx:LEFT,by:BOTTOM},
{ax:RIGHT,ay:-3,bx:RIGHT,by:BOTTOM},
{ax:LEFT,ay:BOTTOM,bx:RIGHT,by:BOTTOM}
];
}
function reset() {
seed = 87129; people = []; exitTimes = [];
elapsed = departed = frame = 0; acc = 0; done = false; running = true;
buildWalls();
for (let row=0; row<12; row++) {
for (let col=0; col<25; col++) {
people.push({
x:2.7 + col*1.105 + (random()-.5)*.16,
y:10.2 + row*1.22 + (random()-.5)*.16,
vx:0, vy:0, fx:0, fy:0, active:true,
color:colors[Math.floor(random()*colors.length)], cell:""
});
}
}
updateMetrics();
}
function key(x,y) { return Math.floor(x/CELL)+","+Math.floor(y/CELL); }
function insert(i) {
const p=people[i], k=key(p.x,p.y);
if (!grid.has(k)) grid.set(k,new Set());
grid.get(k).add(i); p.cell=k;
}
function relocate(i) {
const p=people[i], k=key(p.x,p.y);
if(k!==p.cell) {
grid.get(p.cell).delete(i);
if(!grid.has(k)) grid.set(k,new Set());
grid.get(k).add(i); p.cell=k;
}
}
function nearby(x0,y0,x1,y1,fn) {
for(let y=Math.floor(y0/CELL); y<=Math.floor(y1/CELL); y++)
for(let x=Math.floor(x0/CELL); x<=Math.floor(x1/CELL); x++) {
const bucket=grid.get(x+","+y);
if(bucket) for(const i of bucket) fn(i);
}
}
function closestWall(p,w) {
const dx=w.bx-w.ax, dy=w.by-w.ay;
const t=clamp(((p.x-w.ax)*dx+(p.y-w.ay)*dy)/(dx*dx+dy*dy),0,1);
return {x:w.ax+t*dx,y:w.ay+t*dy};
}
function addObstacleForce(p,x,y,radius) {
const dx=p.x-x, dy=p.y-y, d=Math.hypot(dx,dy);
if(d < radius + R + .8 && d>1e-9) {
const gap=d-radius-R;
const f=Math.min(35,2.4*Math.exp((.14-gap)/.17));
p.fx+=f*dx/d; p.fy+=f*dy/d;
}
}
// Earliest point-versus-circle time of impact for a displacement.
function rayCircle(px,py,dx,dy,cx,cy,r) {
const ox=px-cx, oy=py-cy, a=dx*dx+dy*dy;
if(a<1e-15) return null;
const b=ox*dx+oy*dy, c=ox*ox+oy*oy-r*r;
if(b>=0) return null;
const disc=b*b-a*c;
if(disc<0) return null;
const t=c<=0 ? 0 : (-b-Math.sqrt(disc))/a;
if(t<0 || t>1) return null;
const nx=ox+dx*t, ny=oy+dy*t, len=Math.hypot(nx,ny);
if(len<1e-10) return null;
return {t,nx:nx/len,ny:ny/len,other:-1};
}
// Exact swept point-versus-capsule: segment sides plus circular ends.
function rayWall(px,py,dx,dy,w) {
const radius=R+WALL_R;
let best=rayCircle(px,py,dx,dy,w.ax,w.ay,radius);
const end=rayCircle(px,py,dx,dy,w.bx,w.by,radius);
if(end && (!best || end.t<best.t)) best=end;
const sx=w.bx-w.ax, sy=w.by-w.ay, len=Math.hypot(sx,sy);
const tx=sx/len, ty=sy/len, nx=-ty, ny=tx;
const dist=(px-w.ax)*nx+(py-w.ay)*ny, vel=dx*nx+dy*ny;
if(Math.abs(vel)>1e-12) {
for(const side of [-1,1]) {
if(vel*side>=0) continue;
const t=(side*radius-dist)/vel;
if(t<0 || t>1) continue;
const along=(px+dx*t-w.ax)*tx+(py+dy*t-w.ay)*ty;
if(along>=0 && along<=len && (!best || t<best.t))
best={t,nx:nx*side,ny:ny*side,other:-1};
}
}
return best;
}
function limitVelocity(p) {
const speed=Math.hypot(p.vx,p.vy);
if(speed>MAX_SPEED) { p.vx*=MAX_SPEED/speed; p.vy*=MAX_SPEED/speed; }
}
function movePerson(i) {
const p=people[i];
let dx=p.vx*DT, dy=p.vy*DT;
// Conservative collision-safe sliding. Unused motion is discarded after
// four contacts, never applied through a body or barrier.
for(let iteration=0; iteration<4; iteration++) {
const length=Math.hypot(dx,dy);
if(length<1e-9) break;
let hit=null;
const take=h => { if(h && (!hit || h.t<hit.t)) hit=h; };
for(const w of walls) take(rayWall(p.x,p.y,dx,dy,w));
if(hasColumn) take(rayCircle(p.x,p.y,dx,dy,CX,POST_Y,R+POST_R));
const reach=2*R+.002;
nearby(
Math.min(p.x,p.x+dx)-reach, Math.min(p.y,p.y+dy)-reach,
Math.max(p.x,p.x+dx)+reach, Math.max(p.y,p.y+dy)+reach,
j => {
if(i===j || !people[j].active) return;
const q=people[j];
const h=rayCircle(p.x,p.y,dx,dy,q.x,q.y,2*R);
if(h) { h.other=j; take(h); }
}
);
if(!hit) {
p.x+=dx; p.y+=dy; relocate(i); break;
}
const fraction=Math.max(0,hit.t-EPS/length);
p.x+=dx*fraction; p.y+=dy*fraction; relocate(i);
let rx=dx*(1-fraction), ry=dy*(1-fraction);
const inward=rx*hit.nx+ry*hit.ny;
if(inward<0) { rx-=inward*hit.nx; ry-=inward*hit.ny; }
const q=hit.other>=0 ? people[hit.other] : null;
const rvx=p.vx-(q?q.vx:0), rvy=p.vy-(q?q.vy:0);
const normalSpeed=rvx*hit.nx+rvy*hit.ny;
if(normalSpeed<0) {
// Equal-mass, inelastic normal impulse. Walls have infinite mass.
const invMass=q ? .5 : 1;
const impulse=-normalSpeed*invMass;
p.vx+=impulse*hit.nx; p.vy+=impulse*hit.ny;
if(q) { q.vx-=impulse*hit.nx; q.vy-=impulse*hit.ny; }
const tx=-hit.ny, ty=hit.nx;
const tangent=rvx*tx+rvy*ty;
const friction=clamp(tangent*invMass,-impulse*(.18+.62*urgency),impulse*(.18+.62*urgency));
p.vx-=friction*tx; p.vy-=friction*ty;
if(q) { q.vx+=friction*tx; q.vy+=friction*ty; limitVelocity(q); }
}
// Sliding displacement also loses momentum to contact friction.
const slideFactor=1-(.08+.30*urgency);
dx=rx*slideFactor; dy=ry*slideFactor;
}
limitVelocity(p);
if(p.y<EXIT_Y) {
p.active=false;
grid.get(p.cell).delete(i);
departed++;
exitTimes.push(elapsed);
}
}
function step() {
if(!running || done) return;
elapsed+=DT; frame++;
grid=new Map();
for(let i=0;i<N;i++) if(people[i].active) insert(i);
const desired=.8+2.7*urgency, tau=.58-.22*urgency;
for(let i=0;i<N;i++) {
const p=people[i];
if(!p.active) continue;
// Seek a feasible part of the opening, not a prescribed queue.
const low=gateL+R+.035, high=gateR-R-.035;
const tx=low<=high ? clamp(p.x,low,high) : CX;
const aligned=low<=high && p.x>=low && p.x<=high;
const ty=(aligned || p.y<WALL_Y) ? .5 : WALL_Y+.62;
const ax=tx-p.x, ay=ty-p.y, d=Math.hypot(ax,ay);
p.fx=((d>1e-9?ax/d*desired:0)-p.vx)/tau;
p.fy=((d>1e-9?ay/d*desired:0)-p.vy)/tau;
for(const wall of walls) {
const c=closestWall(p,wall);
addObstacleForce(p,c.x,c.y,WALL_R);
}
if(hasColumn) addObstacleForce(p,CX,POST_Y,POST_R);
}
// Pairwise social spacing and a short-range compressive contact force.
for(let i=0;i<N;i++) {
const p=people[i]; if(!p.active) continue;
nearby(p.x-1.25,p.y-1.25,p.x+1.25,p.y+1.25,j=>{
if(j<=i || !people[j].active) return;
const q=people[j], dx=p.x-q.x, dy=p.y-q.y;
const d=Math.hypot(dx,dy);
if(d<1e-8 || d>1.25) return;
const gap=d-2*R;
const social=Math.min(16,1.9*Math.exp((.17-gap)/.16));
const contact=(25+125*urgency)*Math.max(0,.035-gap);
const f=social+contact, fx=f*dx/d, fy=f*dy/d;
p.fx+=fx; p.fy+=fy; q.fx-=fx; q.fy-=fy;
});
}
for(const p of people) if(p.active) {
p.vx+=p.fx*DT; p.vy+=p.fy*DT; limitVelocity(p);
}
// Rotate/reverse update order to reduce systematic sequential bias.
const start=frame%N, direction=frame%2 ? 1 : -1;
for(let k=0;k<N;k++) {
const i=(start+direction*k+N*2)%N;
if(people[i].active) movePerson(i);
}
while(exitTimes.length && exitTimes[0]<elapsed-10) exitTimes.shift();
if(departed===N) { done=true; running=false; acc=0; }
}
function updateMetrics() {
const rate=elapsed>0 ? exitTimes.length/Math.min(10,elapsed)*60 : 0;
$("flow").textContent=Math.round(rate);
const mins=Math.floor(elapsed/60);
const sec=(elapsed%60).toFixed(1).padStart(4,"0");
$("clock").textContent=mins+":"+sec;
$("clockLabel").textContent=done?"Section emptied":"Elapsed time";
$("remaining").textContent=N-departed;
$("departureText").textContent=departed+" supporters have left";
const pc=Math.round(departed/N*100);
$("percent").textContent=pc+"%";
$("progress").style.width=pc+"%";
$("statusText").textContent=done?"Section clear":running?"Simulation live":"Simulation paused";
$("statusDot").style.background=done?"#386c55":running?"#386c55":"#d99b46";
$("pause").textContent=done?"✓ Cleared":running?"Ⅱ Pause":"▶ Resume";
$("pause").disabled=done;
}
function resize() {
const rect=canvas.getBoundingClientRect(), dpr=Math.min(2,devicePixelRatio||1);
viewWidth=rect.width; viewHeight=rect.height;
canvas.width=Math.round(rect.width*dpr);
canvas.height=Math.round(rect.height*dpr);
}
function line(x1,y1,x2,y2,color,width=.03) {
ctx.strokeStyle=color; ctx.lineWidth=width;
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
}
function text(str,x,y,size=.35,color="#62765d",align="left",font="system-ui") {
ctx.fillStyle=color; ctx.font=`${size}px ${font}`; ctx.textAlign=align;
ctx.fillText(str,x,y);
}
function circle(x,y,r,fill,stroke,width=.03) {
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2);
ctx.fillStyle=fill; ctx.fill();
if(stroke) { ctx.lineWidth=width; ctx.strokeStyle=stroke; ctx.stroke(); }
}
function draw() {
if(!viewWidth) return;
const dpr=canvas.width/viewWidth;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,viewWidth,viewHeight);
const scale=Math.min(viewWidth/W,viewHeight/H);
ctx.translate((viewWidth-W*scale)/2,(viewHeight-H*scale)/2);
ctx.scale(scale,scale);
ctx.fillStyle="#e4e9dd"; ctx.fillRect(0,0,W,H);
// Exit plaza and soft architectural grid.
ctx.fillStyle="#d4e1cf"; ctx.fillRect(LEFT,0,RIGHT-LEFT,WALL_Y);
for(let x=2;x<31;x+=1.5) line(x,0,x,WALL_Y,"#c9d7c2",.018);
for(let y=.5;y<WALL_Y;y+=1.5) line(LEFT,y,RIGHT,y,"#c9d7c2",.018);
const glow=ctx.createLinearGradient(0,WALL_Y,0,1.5);
glow.addColorStop(0,"#a9c79e88"); glow.addColorStop(1,"#b6cfab00");
ctx.fillStyle=glow; ctx.fillRect(gateL,1.5,gateR-gateL,WALL_Y-1.5);
text("NORTH EXIT",CX,1.15,.43,"#3d6147","center");
text("COUNTED AT THIS LINE",25.7,2.0,.24,"#7a8b71","center");
ctx.setLineDash([.13,.16]);
line(gateL-.6,EXIT_Y,gateR+.6,EXIT_Y,"#7f9f72",.04);
ctx.setLineDash([]);
for(const x of [CX-.65,CX,CX+.65]) {
line(x,3.15,x,2.65,"#6f9462",.045);
line(x,2.65,x-.15,2.83,"#6f9462",.045);
line(x,2.65,x+.15,2.83,"#6f9462",.045);
}
// Terraces are a floor graphic, not collision obstacles.
ctx.fillStyle="#d8dfcf"; ctx.fillRect(LEFT,9.15,RIGHT-LEFT,BOTTOM-9.15);
for(let row=0;row<12;row++) {
const y=9.65+row*1.22;
if(row%2===0) { ctx.fillStyle="#ced9c5"; ctx.fillRect(LEFT+.16,y,RIGHT-LEFT-.32,1.15); }
line(LEFT+.2,y,RIGHT-.2,y,"#b5c4a9",.035);
text(String(row+1).padStart(2,"0"),.7,y+.75,.29,"#89977e","center");
for(let col=0;col<25;col++) {
const x=2.7+col*1.105;
line(x-.27,y+.9,x+.27,y+.9,"#bdcbb2",.035);
}
}
text("SECTION 08",2.1,7.05,.43,"#829078");
text("UNOBSTRUCTED CONCOURSE",2.1,7.65,.26,"#93a087");
// Wall shadows and solid capsules.
ctx.lineCap="round";
for(const w of walls) {
line(w.ax+.055,w.ay+.12,w.bx+.055,w.by+.12,"#8c9b8155",WALL_R*2+.08);
line(w.ax,w.ay,w.bx,w.by,"#405749",WALL_R*2);
line(w.ax,w.ay-.045,w.bx,w.by-.045,"#6c8064",.045);
}
ctx.lineCap="butt";
line(gateL,4.15,gateR,4.15,"#67805a",.025);
line(gateL,3.99,gateL,4.32,"#67805a",.025);
line(gateR,3.99,gateR,4.32,"#67805a",.025);
// Background patch keeps the dimension text legible at narrow widths.
ctx.fillStyle="#d4e1cf"; ctx.fillRect(CX-1.1,3.7,2.2,.58);
text(gateYards.toFixed(2)+" yd",CX,4.1,.33,"#3d6147","center");
if(hasColumn) {
circle(CX+.10,POST_Y+.14,POST_R,"#80917455");
circle(CX,POST_Y,POST_R,"#58694e","#344a3e",.05);
circle(CX-.07,POST_Y-.08,POST_R-.16,"#718062");
line(CX+.65,POST_Y-.1,CX+1.45,POST_Y-.7,"#7d8e70",.025);
text("COLUMN",CX+1.6,POST_Y-.64,.25,"#708165");
}
// Discs are drawn at their collision radius.
for(const p of people) if(p.active) {
circle(p.x,p.y+.035,R,"#50604a25");
circle(p.x,p.y,R,p.color,"#435a48",.028);
// A small shirt highlight, contained within the body.
circle(p.x-.055,p.y-.06,.067,"#fff7");
}
text("STAND / 300 SUPPORTERS",2.1,26.3,.3,"#74856b");
line(25.3,26.05,30.3,26.05,"#63795a",.045);
line(25.3,25.9,25.3,26.2,"#63795a",.045);
line(30.3,25.9,30.3,26.2,"#63795a",.045);
text("5 m",27.8,26.65,.29,"#63795a","center");
if(done) {
ctx.fillStyle="#f7f6eee8"; ctx.fillRect(9.2,13.6,13.6,3.4);
text("Everyone is out.",16,15.05,.82,"#294c3c","center","Georgia");
text("Change the conditions. Run it again.",16,16.12,.34,"#6e7e64","center");
}
}
$("urgency").addEventListener("input",e=>{
urgency=clamp(Number(e.target.value),0,1);
$("urgencyValue").textContent=urgency.toFixed(2);
});
function changeGate(value) {
const n=Number(value);
if(!Number.isFinite(n)) { syncControls(); return; }
gateYards=clamp(n,.25,24);
syncControls(); reset();
}
$("gate").addEventListener("input",e=>changeGate(e.target.value));
$("gateNumber").addEventListener("change",e=>changeGate(e.target.value));
$("column").addEventListener("change",e=>{
hasColumn=e.target.checked; reset();
});
$("reset").addEventListener("click",reset);
$("pause").addEventListener("click",()=>{
if(done) return;
running=!running; acc=0; updateMetrics();
});
window.addEventListener("resize",resize);
syncControls(); reset(); resize();
let lastUI=0;
function animate(now) {
const delta=Math.min(.1,(now-last)/1000); last=now;
if(running && !done) {
acc+=delta;
let count=0;
while(acc>=DT && count<6 && running) { acc-=DT; step(); count++; }
} else acc=0;
if(now-lastUI>100) { updateMetrics(); lastUI=now; }
draw();
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
})();
</script>
</body>
</html>