Below is a twelve-floor building (floor 0 is the lobby). Blue dots are people waiting to go up, orange dots people waiting to go down, and each car shows its direction and how many passengers it carries (capacity 8). Choose a traffic pattern, a policy and a number of cars, then press Play.
<!-- Controls: traffic pattern, dispatch policy, number of cars and the random seed. -->
<div class="row">
<label>Traffic <select id="preset">
<option value="up">Up-peak (morning)</option>
<option value="lunch">Lunch</option>
<option value="down">Down-peak (evening)</option>
<option value="rand">Random</option>
</select></label>
<label>Policy <select id="policy">
<option value="fcfs">First come, first served</option>
<option value="nearest">Nearest car</option>
<option value="collective" selected>Collective (SCAN/LOOK)</option>
<option value="destination">Destination dispatch</option>
</select></label>
</div>
<div class="row">
<label>Cars <select id="cars"><option>1</option><option>2</option><option selected>3</option><option>4</option></select></label>
<label>Seed <input id="seed" type="number" value="7" min="1" max="99999"></label>
<button id="newseed" type="button" class="ghost">New seed</button>
<label>Speed <select id="speed"><option value="2">x2</option><option value="5" selected>x5</option><option value="20">x20</option></select></label>
</div>
<p class="hint" id="phint"></p>
<div class="row">
<button id="play" type="button">Play</button>
<button id="reset" type="button" class="ghost">Reset</button>
<button id="cmp" type="button" class="accent">Compare all policies</button>
</div>
<canvas id="cv" height="268" aria-label="A 12-floor building with elevator cars moving in shafts"></canvas>
<div class="legend"><span class="up">●</span> waiting to go up <span class="dn">●</span> waiting to go down</div>
<div class="status" id="status"></div>
<div id="out"></div>
/* Colour tokens with a dark-mode variant. */
:root { --fg:#222; --muted:#555; --bg:#fff; --line:#cdd9e3; --pane:#f1f5f9; --accent:#1d3557; --car:#457b9d; --open:#2a9d8f; --up:#2563eb; --dn:#e07a1f; --bar:#bcd0e3; --best:#0a7d33; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e6edf3; --muted:#9fb0c0; --bg:#10161d; --line:#2c3a48; --pane:#18212b; --accent:#7fb2e5; --car:#5b9bd0; --open:#3fc1b0; --up:#6ea0ff; --dn:#f0a04b; --bar:#2f4a63; --best:#5fd08a; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 2px; font-size: 14px; }
.row { display: flex; flex-wrap: wrap; gap: .45rem .7rem; align-items: center; margin: 0 0 .45rem; }
label { font-size: .82rem; color: var(--muted); display: flex; align-items: center; gap: .3rem; }
select, input { font: 600 13px system-ui, sans-serif; color: var(--fg); background: var(--pane); border: 1px solid var(--line); border-radius: 6px; padding: .3rem .4rem; max-width: 100%; }
input { width: 5.2rem; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .8rem; border: 1px solid var(--accent); background: var(--accent); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--accent); }
button.accent { background: var(--open); border-color: var(--open); color: #06231f; }
.hint { font-size: .8rem; color: var(--muted); margin: 0 0 .5rem; line-height: 1.4; min-height: 2.2em; }
canvas { width: 100%; height: 268px; display: block; background: var(--pane); border: 1px solid var(--line); border-radius: 8px; }
.legend { font-size: .75rem; color: var(--muted); margin: .25rem 0; }
.legend .up { color: var(--up); } .legend .dn { color: var(--dn); }
.status { font-size: .85rem; font-weight: 600; margin: .3rem 0; min-height: 1.3em; }
table { width: 100%; border-collapse: collapse; font-size: .8rem; margin-top: .4rem; }
caption { text-align: left; font-size: .78rem; color: var(--muted); padding-bottom: .3rem; }
th, td { text-align: left; padding: .3rem .35rem; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; }
td.m { position: relative; min-width: 64px; }
td.m .bar { position: absolute; left: 0; top: 3px; bottom: 3px; background: var(--bar); border-radius: 3px; }
td.m span { position: relative; font-variant-numeric: tabular-nums; padding-left: 3px; }
td.best span { color: var(--best); font-weight: 700; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Building and simulation parameters (times in seconds).
const FLOORS = 12, CAP = 8, T_FLOOR = 2, T_DWELL = 3, DURATION = 600, T_MAX = 12000;
// Traffic presets: arrivals per minute and the mix [from lobby, to lobby, between floors].
const PRESETS = {
up: { rate: 18, mix: [0.9, 0, 0.1] },
lunch: { rate: 14, mix: [0.35, 0.35, 0.3] },
down: { rate: 18, mix: [0, 0.9, 0.1] },
rand: { rate: 12, mix: [0, 0, 1] }
};
const POLICIES = ['fcfs', 'nearest', 'collective', 'destination'];
// Small seeded random generator (mulberry32), so every policy sees identical traffic.
function rng(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Poisson arrivals over ten minutes with the chosen origin/destination mix.
function makeTraffic(name, seed) {
const P = PRESETS[name], r = rng(seed), out = [];
let t = 0;
for (;;) {
t += -Math.log(1 - r()) * 60 / P.rate;
if (t >= DURATION) break;
const u = r();
let from, to;
if (u < P.mix[0]) { from = 0; to = 1 + Math.floor(r() * (FLOORS - 1)); }
else if (u < P.mix[0] + P.mix[1]) { to = 0; from = 1 + Math.floor(r() * (FLOORS - 1)); }
else { from = Math.floor(r() * FLOORS); do { to = Math.floor(r() * FLOORS); } while (to === from); }
out.push({ id: out.length, t: Math.ceil(t), from: from, to: to, dir: to > from ? 1 : -1, board: -1, drop: -1 });
}
return out;
}
function mkCar(id) {
return { id: id, floor: 0, dir: 0, mode: 0, prog: 0, timer: 0, onboard: [], queue: [] };
}
// Is there a stop strictly beyond this floor in the given direction?
function ahead(car, f, dir) {
for (const p of car.onboard) if ((p.to - f) * dir > 0) return true;
for (const p of car.queue) if ((p.from - f) * dir > 0) return true;
return false;
}
// Stop if someone leaves here, or someone going our way (or nobody beyond) waits here.
function wantStop(car) {
const f = car.floor;
if (car.onboard.some(p => p.to === f)) return true;
if (car.onboard.length >= CAP) return false;
const more = ahead(car, f, car.dir);
return car.queue.some(p => p.from === f && (car.dir === 0 || p.dir === car.dir || !more));
}
function doStop(car, now) {
const f = car.floor;
let n = 0;
car.onboard = car.onboard.filter(p => { if (p.to === f) { p.drop = now; n++; return false; } return true; });
const more = ahead(car, f, car.dir);
const rest = [];
for (const p of car.queue) {
if (p.from === f && car.onboard.length < CAP && (car.dir === 0 || p.dir === car.dir || !more)) {
p.board = now; car.onboard.push(p); n++;
} else rest.push(p);
}
car.queue = rest;
car.mode = 2;
car.timer = T_DWELL + Math.ceil(n / 2);
}
function decide(car, now) {
if (wantStop(car)) { doStop(car, now); return; }
const f = car.floor, tg = [];
car.onboard.forEach(p => tg.push(p.to));
car.queue.forEach(p => tg.push(p.from));
const up = tg.some(x => x > f), dn = tg.some(x => x < f);
if (car.dir > 0 && !up) car.dir = dn ? -1 : 0;
if (car.dir < 0 && !dn) car.dir = up ? 1 : 0;
if (car.dir === 0) {
if (up && dn) {
let du = 99, dd = 99;
tg.forEach(x => { if (x > f) du = Math.min(du, x - f); if (x < f) dd = Math.min(dd, f - x); });
car.dir = du <= dd ? 1 : -1;
} else car.dir = up ? 1 : dn ? -1 : 0;
}
if (car.dir === 0) { car.mode = 0; return; }
car.mode = 1; car.prog = 0;
}
function stepCar(car, now) {
if (car.mode === 2) { car.timer--; if (car.timer <= 0) decide(car, now); }
else if (car.mode === 1) {
car.prog++;
if (car.prog >= T_FLOOR) { car.floor += car.dir; car.prog = 0; decide(car, now); }
} else decide(car, now);
}
// Forecast: replay a copy of the car on its current list of passengers and record the times.
function plan(car, extra, now, ghost) {
const map = new Map();
const cp = p => { const q = Object.assign({}, p); map.set(p.id, q); return q; };
const c = {
id: car.id, floor: car.floor, dir: car.dir, mode: car.mode, prog: car.prog, timer: car.timer,
onboard: car.onboard.map(cp),
queue: car.queue.concat(extra ? [extra] : []).map(cp)
};
if (ghost) c.queue.forEach(q => { q.to = Math.max(0, Math.min(FLOORS - 1, q.from + 3 * q.dir)); });
let t = now, guard = 0;
while ((c.onboard.length || c.queue.length) && guard++ < 600) { t++; stepCar(c, t); }
return map;
}
function assign(S, p) {
const cars = S.cars;
let best = 0, bc = Infinity;
cars.forEach((car, i) => {
let cost;
if (S.policy === 'nearest') {
cost = Math.abs(car.floor - p.from) * 10 + car.queue.length + car.onboard.length;
} else if (S.policy === 'collective') {
// Collective: choose the car that minimises waiting time, destinations unknown (assume about 3 floors).
const a = plan(car, null, S.t, true), b = plan(car, p, S.t, true);
cost = b.get(p.id).board - S.t;
car.queue.forEach(q => { cost += b.get(q.id).board - a.get(q.id).board; });
} else {
// Destination dispatch: choose the car that adds least to the total journey time of everyone.
const a = plan(car, null, S.t, false), b = plan(car, p, S.t, false);
cost = b.get(p.id).drop - S.t;
car.queue.concat(car.onboard).forEach(q => { cost += b.get(q.id).drop - a.get(q.id).drop; });
}
cost += i * 1e-6;
if (cost < bc) { bc = cost; best = i; }
});
cars[best].queue.push(p);
}
function makeSim(traffic, policy, nCars) {
const cars = [];
for (let i = 0; i < nCars; i++) cars.push(mkCar(i));
return { t: 0, policy: policy, cars: cars, all: traffic.map(p => Object.assign({}, p)), next: 0, waiting: [], done: 0 };
}
function tick(S) {
S.t++;
while (S.next < S.all.length && S.all[S.next].t <= S.t) {
const p = S.all[S.next++];
if (S.policy === 'fcfs') S.waiting.push(p); else assign(S, p);
}
if (S.policy === 'fcfs') {
while (S.waiting.length) {
const p = S.waiting[0];
let best = null;
for (const car of S.cars) {
if (car.queue.length || car.onboard.length) continue;
if (!best || Math.abs(car.floor - p.from) < Math.abs(best.floor - p.from)) best = car;
}
if (!best) break;
best.queue.push(S.waiting.shift());
}
}
for (const car of S.cars) stepCar(car, S.t);
S.done = 0;
for (const p of S.all) if (p.drop >= 0) S.done++;
}
function finished(S) { return S.next >= S.all.length && S.done >= S.all.length; }
function runAll(traffic, policy, nCars) {
const S = makeSim(traffic, policy, nCars);
while (!finished(S) && S.t < T_MAX) tick(S);
return S;
}
function stats(S) {
let w = 0, j = 0, mx = 0, n = 0;
for (const p of S.all) if (p.drop >= 0) { n++; w += p.board - p.t; j += p.drop - p.t; mx = Math.max(mx, p.board - p.t); }
return { n: n, total: S.all.length, wait: n ? w / n : 0, journey: n ? j / n : 0, max: mx, end: S.t };
}
// User interface: drawing, controls and the comparison table.
const $ = id => document.getElementById(id);
const NAMES = { fcfs: "First come, first served", nearest: "Nearest car", collective: "Collective (SCAN/LOOK)", destination: "Destination dispatch" };
const HINTS = { fcfs: "One passenger per trip: the oldest waiting passenger is given to a free car, which carries only that person. No sharing at all.", nearest: "Each call is given to the car that is closest right now; every car then sweeps up and down serving its own list.", collective: "Calls go to the car that can reach them soonest. Cars sweep in one direction, stopping for everyone going that way (LOOK). Destinations are unknown until people board.", destination: "People key in their floor in the lobby, so the dispatcher knows every destination and picks the car that adds least to everyone's journey time." };
let S = null, timer = null, playing = false, acc = 0;
function params() {
return { preset: $('preset').value, policy: $('policy').value, n: +$('cars').value,
seed: Math.max(1, Math.floor(+$('seed').value) || 1), speed: +$('speed').value };
}
function waitingAt() {
const w = [];
for (let f = 0; f < FLOORS; f++) w.push([0, 0]);
for (const p of S.all) if (p.t <= S.t && p.board < 0) w[p.from][p.dir > 0 ? 0 : 1]++;
return w;
}
function draw() {
const cv = $('cv'), g = cv.getContext('2d');
const W = cv.clientWidth || 340, H = 268;
cv.width = W; cv.height = H;
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const fg = css('--muted'), line = css('--line'), car = css('--car'), open = css('--open'), up = css('--up'), dn = css('--dn');
const RH = (H - 4) / FLOORS, x0 = 100, n = S.cars.length, sw = (W - 6 - x0) / n;
const yTop = f => 2 + (FLOORS - 1 - f) * RH;
g.font = '11px system-ui, sans-serif'; g.textBaseline = 'middle';
const w = waitingAt();
for (let f = 0; f < FLOORS; f++) {
const y = yTop(f);
g.strokeStyle = line; g.beginPath(); g.moveTo(0, y + RH); g.lineTo(W, y + RH); g.stroke();
g.fillStyle = fg; g.textAlign = 'right'; g.fillText(String(f), 20, y + RH / 2);
let cx = 28;
for (let k = 0; k < 2; k++) {
const c = w[f][k];
g.fillStyle = k === 0 ? up : dn;
for (let i = 0; i < Math.min(c, 4); i++) { g.beginPath(); g.arc(cx + 4, y + RH / 2, 3.2, 0, 6.3); g.fill(); cx += 8; }
if (c > 4) { g.textAlign = 'left'; g.fillText('+' + (c - 4), cx, y + RH / 2); cx += 18; }
cx += 4;
}
}
for (let i = 0; i < n; i++) {
const c = S.cars[i], sx = x0 + i * sw;
g.fillStyle = css('--bar'); g.globalAlpha = 0.35; g.fillRect(sx + 2, 2, sw - 4, H - 4); g.globalAlpha = 1;
const pos = c.floor + (c.mode === 1 ? c.dir * c.prog / T_FLOOR : 0);
const cw = Math.min(sw - 8, 46), ch = RH - 3;
const cx = sx + (sw - cw) / 2, cy = yTop(pos) + 1.5;
g.fillStyle = c.mode === 2 ? open : car;
g.fillRect(cx, cy, cw, ch);
g.fillStyle = '#fff'; g.textAlign = 'center'; g.font = 'bold 11px system-ui, sans-serif';
g.fillText((c.dir > 0 ? '▲' : c.dir < 0 ? '▼' : '·') + c.onboard.length, cx + cw / 2, cy + ch / 2);
g.font = '11px system-ui, sans-serif';
}
}
function fmt1(v) { return v.toFixed(1); }
function updStatus() {
let waitN = 0;
for (const p of S.all) if (p.t <= S.t && p.board < 0) waitN++;
let txt = fmt("t = %t% s - waiting: %w% - delivered: %d% of %n%", { t: S.t, w: waitN, d: S.done, n: S.all.length });
if (finished(S)) {
const s = stats(S);
txt += ' ' + fmt("Finished: average wait %w% s, average journey %j% s, longest wait %m% s.", { w: fmt1(s.wait), j: fmt1(s.journey), m: s.max });
}
$('status').textContent = txt;
}
function stopTimer() { playing = false; if (timer) clearInterval(timer); timer = null; $('play').textContent = "Play"; }
function reset() {
stopTimer();
const q = params();
S = makeSim(makeTraffic(q.preset, q.seed), q.policy, q.n);
$('out').innerHTML = '';
$('phint').textContent = HINTS[q.policy];
draw(); updStatus();
}
function play() {
if (playing) { stopTimer(); return; }
if (finished(S) || S.t >= T_MAX) reset();
playing = true; $('play').textContent = "Pause";
timer = setInterval(() => {
acc += params().speed * 0.05;
while (acc >= 1) { acc -= 1; if (!finished(S) && S.t < T_MAX) tick(S); }
draw(); updStatus();
if (finished(S) || S.t >= T_MAX) stopTimer();
}, 50);
}
// Run every policy on the same traffic and tabulate the three metrics.
function compare() {
stopTimer();
const q = params(), tr = makeTraffic(q.preset, q.seed);
const rows = POLICIES.map(p => ({ p: p, s: stats(runAll(tr, p, q.n)) }));
const mx = k => Math.max.apply(null, rows.map(r => r.s[k]));
const mn = k => Math.min.apply(null, rows.map(r => r.s[k]));
const cell = (r, k) => {
const v = r.s[k], pct = mx(k) > 0 ? Math.round(100 * v / mx(k)) : 0;
return '<td class="m' + (v === mn(k) ? ' best' : '') + '"><div class="bar" style="width:' + pct + '%"></div><span>' + fmt1(v) + ' s</span></td>';
};
let h = '<table><caption>' + fmt("Same traffic (%n% passengers, seed %seed%) and %c% car(s) under every policy. Bold green = best in column.", { n: tr.length, c: q.n, seed: q.seed }) + '</caption>';
h += '<tr><th>Policy</th><th>Avg wait</th><th>Avg journey</th><th>Max wait</th></tr>';
for (const r of rows) h += '<tr><td>' + NAMES[r.p] + '</td>' + cell(r, 'wait') + cell(r, 'journey') + cell(r, 'max') + '</tr>';
$('out').innerHTML = h + '</table>';
}
['preset', 'policy', 'cars', 'seed'].forEach(id => $(id).addEventListener('change', reset));
$('newseed').addEventListener('click', () => { $('seed').value = 1 + Math.floor(Math.random() * 9999); reset(); });
$('play').addEventListener('click', play);
$('reset').addEventListener('click', reset);
$('cmp').addEventListener('click', compare);
window.addEventListener('resize', () => { if (S) draw(); });
reset();
<!-- Controls: traffic pattern, dispatch policy, number of cars and the random seed. -->
<div class="row">
<label>Traffic <select id="preset">
<option value="up">Up-peak (morning)</option>
<option value="lunch">Lunch</option>
<option value="down">Down-peak (evening)</option>
<option value="rand">Random</option>
</select></label>
<label>Policy <select id="policy">
<option value="fcfs">First come, first served</option>
<option value="nearest">Nearest car</option>
<option value="collective" selected>Collective (SCAN/LOOK)</option>
<option value="destination">Destination dispatch</option>
</select></label>
</div>
<div class="row">
<label>Cars <select id="cars"><option>1</option><option>2</option><option selected>3</option><option>4</option></select></label>
<label>Seed <input id="seed" type="number" value="7" min="1" max="99999"></label>
<button id="newseed" type="button" class="ghost">New seed</button>
<label>Speed <select id="speed"><option value="2">x2</option><option value="5" selected>x5</option><option value="20">x20</option></select></label>
</div>
<p class="hint" id="phint"></p>
<div class="row">
<button id="play" type="button">Play</button>
<button id="reset" type="button" class="ghost">Reset</button>
<button id="cmp" type="button" class="accent">Compare all policies</button>
</div>
<canvas id="cv" height="268" aria-label="A 12-floor building with elevator cars moving in shafts"></canvas>
<div class="legend"><span class="up">●</span> waiting to go up <span class="dn">●</span> waiting to go down</div>
<div class="status" id="status"></div>
<div id="out"></div>
/* Colour tokens with a dark-mode variant. */
:root { --fg:#222; --muted:#555; --bg:#fff; --line:#cdd9e3; --pane:#f1f5f9; --accent:#1d3557; --car:#457b9d; --open:#2a9d8f; --up:#2563eb; --dn:#e07a1f; --bar:#bcd0e3; --best:#0a7d33; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e6edf3; --muted:#9fb0c0; --bg:#10161d; --line:#2c3a48; --pane:#18212b; --accent:#7fb2e5; --car:#5b9bd0; --open:#3fc1b0; --up:#6ea0ff; --dn:#f0a04b; --bar:#2f4a63; --best:#5fd08a; }
}
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 2px; font-size: 14px; }
.row { display: flex; flex-wrap: wrap; gap: .45rem .7rem; align-items: center; margin: 0 0 .45rem; }
label { font-size: .82rem; color: var(--muted); display: flex; align-items: center; gap: .3rem; }
select, input { font: 600 13px system-ui, sans-serif; color: var(--fg); background: var(--pane); border: 1px solid var(--line); border-radius: 6px; padding: .3rem .4rem; max-width: 100%; }
input { width: 5.2rem; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .8rem; border: 1px solid var(--accent); background: var(--accent); color: var(--bg); border-radius: 8px; cursor: pointer; }
button.ghost { background: transparent; color: var(--accent); }
button.accent { background: var(--open); border-color: var(--open); color: #06231f; }
.hint { font-size: .8rem; color: var(--muted); margin: 0 0 .5rem; line-height: 1.4; min-height: 2.2em; }
canvas { width: 100%; height: 268px; display: block; background: var(--pane); border: 1px solid var(--line); border-radius: 8px; }
.legend { font-size: .75rem; color: var(--muted); margin: .25rem 0; }
.legend .up { color: var(--up); } .legend .dn { color: var(--dn); }
.status { font-size: .85rem; font-weight: 600; margin: .3rem 0; min-height: 1.3em; }
table { width: 100%; border-collapse: collapse; font-size: .8rem; margin-top: .4rem; }
caption { text-align: left; font-size: .78rem; color: var(--muted); padding-bottom: .3rem; }
th, td { text-align: left; padding: .3rem .35rem; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; }
td.m { position: relative; min-width: 64px; }
td.m .bar { position: absolute; left: 0; top: 3px; bottom: 3px; background: var(--bar); border-radius: 3px; }
td.m span { position: relative; font-variant-numeric: tabular-nums; padding-left: 3px; }
td.best span { color: var(--best); font-weight: 700; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Building and simulation parameters (times in seconds).
const FLOORS = 12, CAP = 8, T_FLOOR = 2, T_DWELL = 3, DURATION = 600, T_MAX = 12000;
// Traffic presets: arrivals per minute and the mix [from lobby, to lobby, between floors].
const PRESETS = {
up: { rate: 18, mix: [0.9, 0, 0.1] },
lunch: { rate: 14, mix: [0.35, 0.35, 0.3] },
down: { rate: 18, mix: [0, 0.9, 0.1] },
rand: { rate: 12, mix: [0, 0, 1] }
};
const POLICIES = ['fcfs', 'nearest', 'collective', 'destination'];
// Small seeded random generator (mulberry32), so every policy sees identical traffic.
function rng(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Poisson arrivals over ten minutes with the chosen origin/destination mix.
function makeTraffic(name, seed) {
const P = PRESETS[name], r = rng(seed), out = [];
let t = 0;
for (;;) {
t += -Math.log(1 - r()) * 60 / P.rate;
if (t >= DURATION) break;
const u = r();
let from, to;
if (u < P.mix[0]) { from = 0; to = 1 + Math.floor(r() * (FLOORS - 1)); }
else if (u < P.mix[0] + P.mix[1]) { to = 0; from = 1 + Math.floor(r() * (FLOORS - 1)); }
else { from = Math.floor(r() * FLOORS); do { to = Math.floor(r() * FLOORS); } while (to === from); }
out.push({ id: out.length, t: Math.ceil(t), from: from, to: to, dir: to > from ? 1 : -1, board: -1, drop: -1 });
}
return out;
}
function mkCar(id) {
return { id: id, floor: 0, dir: 0, mode: 0, prog: 0, timer: 0, onboard: [], queue: [] };
}
// Is there a stop strictly beyond this floor in the given direction?
function ahead(car, f, dir) {
for (const p of car.onboard) if ((p.to - f) * dir > 0) return true;
for (const p of car.queue) if ((p.from - f) * dir > 0) return true;
return false;
}
// Stop if someone leaves here, or someone going our way (or nobody beyond) waits here.
function wantStop(car) {
const f = car.floor;
if (car.onboard.some(p => p.to === f)) return true;
if (car.onboard.length >= CAP) return false;
const more = ahead(car, f, car.dir);
return car.queue.some(p => p.from === f && (car.dir === 0 || p.dir === car.dir || !more));
}
function doStop(car, now) {
const f = car.floor;
let n = 0;
car.onboard = car.onboard.filter(p => { if (p.to === f) { p.drop = now; n++; return false; } return true; });
const more = ahead(car, f, car.dir);
const rest = [];
for (const p of car.queue) {
if (p.from === f && car.onboard.length < CAP && (car.dir === 0 || p.dir === car.dir || !more)) {
p.board = now; car.onboard.push(p); n++;
} else rest.push(p);
}
car.queue = rest;
car.mode = 2;
car.timer = T_DWELL + Math.ceil(n / 2);
}
function decide(car, now) {
if (wantStop(car)) { doStop(car, now); return; }
const f = car.floor, tg = [];
car.onboard.forEach(p => tg.push(p.to));
car.queue.forEach(p => tg.push(p.from));
const up = tg.some(x => x > f), dn = tg.some(x => x < f);
if (car.dir > 0 && !up) car.dir = dn ? -1 : 0;
if (car.dir < 0 && !dn) car.dir = up ? 1 : 0;
if (car.dir === 0) {
if (up && dn) {
let du = 99, dd = 99;
tg.forEach(x => { if (x > f) du = Math.min(du, x - f); if (x < f) dd = Math.min(dd, f - x); });
car.dir = du <= dd ? 1 : -1;
} else car.dir = up ? 1 : dn ? -1 : 0;
}
if (car.dir === 0) { car.mode = 0; return; }
car.mode = 1; car.prog = 0;
}
function stepCar(car, now) {
if (car.mode === 2) { car.timer--; if (car.timer <= 0) decide(car, now); }
else if (car.mode === 1) {
car.prog++;
if (car.prog >= T_FLOOR) { car.floor += car.dir; car.prog = 0; decide(car, now); }
} else decide(car, now);
}
// Forecast: replay a copy of the car on its current list of passengers and record the times.
function plan(car, extra, now, ghost) {
const map = new Map();
const cp = p => { const q = Object.assign({}, p); map.set(p.id, q); return q; };
const c = {
id: car.id, floor: car.floor, dir: car.dir, mode: car.mode, prog: car.prog, timer: car.timer,
onboard: car.onboard.map(cp),
queue: car.queue.concat(extra ? [extra] : []).map(cp)
};
if (ghost) c.queue.forEach(q => { q.to = Math.max(0, Math.min(FLOORS - 1, q.from + 3 * q.dir)); });
let t = now, guard = 0;
while ((c.onboard.length || c.queue.length) && guard++ < 600) { t++; stepCar(c, t); }
return map;
}
function assign(S, p) {
const cars = S.cars;
let best = 0, bc = Infinity;
cars.forEach((car, i) => {
let cost;
if (S.policy === 'nearest') {
cost = Math.abs(car.floor - p.from) * 10 + car.queue.length + car.onboard.length;
} else if (S.policy === 'collective') {
// Collective: choose the car that minimises waiting time, destinations unknown (assume about 3 floors).
const a = plan(car, null, S.t, true), b = plan(car, p, S.t, true);
cost = b.get(p.id).board - S.t;
car.queue.forEach(q => { cost += b.get(q.id).board - a.get(q.id).board; });
} else {
// Destination dispatch: choose the car that adds least to the total journey time of everyone.
const a = plan(car, null, S.t, false), b = plan(car, p, S.t, false);
cost = b.get(p.id).drop - S.t;
car.queue.concat(car.onboard).forEach(q => { cost += b.get(q.id).drop - a.get(q.id).drop; });
}
cost += i * 1e-6;
if (cost < bc) { bc = cost; best = i; }
});
cars[best].queue.push(p);
}
function makeSim(traffic, policy, nCars) {
const cars = [];
for (let i = 0; i < nCars; i++) cars.push(mkCar(i));
return { t: 0, policy: policy, cars: cars, all: traffic.map(p => Object.assign({}, p)), next: 0, waiting: [], done: 0 };
}
function tick(S) {
S.t++;
while (S.next < S.all.length && S.all[S.next].t <= S.t) {
const p = S.all[S.next++];
if (S.policy === 'fcfs') S.waiting.push(p); else assign(S, p);
}
if (S.policy === 'fcfs') {
while (S.waiting.length) {
const p = S.waiting[0];
let best = null;
for (const car of S.cars) {
if (car.queue.length || car.onboard.length) continue;
if (!best || Math.abs(car.floor - p.from) < Math.abs(best.floor - p.from)) best = car;
}
if (!best) break;
best.queue.push(S.waiting.shift());
}
}
for (const car of S.cars) stepCar(car, S.t);
S.done = 0;
for (const p of S.all) if (p.drop >= 0) S.done++;
}
function finished(S) { return S.next >= S.all.length && S.done >= S.all.length; }
function runAll(traffic, policy, nCars) {
const S = makeSim(traffic, policy, nCars);
while (!finished(S) && S.t < T_MAX) tick(S);
return S;
}
function stats(S) {
let w = 0, j = 0, mx = 0, n = 0;
for (const p of S.all) if (p.drop >= 0) { n++; w += p.board - p.t; j += p.drop - p.t; mx = Math.max(mx, p.board - p.t); }
return { n: n, total: S.all.length, wait: n ? w / n : 0, journey: n ? j / n : 0, max: mx, end: S.t };
}
// User interface: drawing, controls and the comparison table.
const $ = id => document.getElementById(id);
const NAMES = { fcfs: "First come, first served", nearest: "Nearest car", collective: "Collective (SCAN/LOOK)", destination: "Destination dispatch" };
const HINTS = { fcfs: "One passenger per trip: the oldest waiting passenger is given to a free car, which carries only that person. No sharing at all.", nearest: "Each call is given to the car that is closest right now; every car then sweeps up and down serving its own list.", collective: "Calls go to the car that can reach them soonest. Cars sweep in one direction, stopping for everyone going that way (LOOK). Destinations are unknown until people board.", destination: "People key in their floor in the lobby, so the dispatcher knows every destination and picks the car that adds least to everyone's journey time." };
let S = null, timer = null, playing = false, acc = 0;
function params() {
return { preset: $('preset').value, policy: $('policy').value, n: +$('cars').value,
seed: Math.max(1, Math.floor(+$('seed').value) || 1), speed: +$('speed').value };
}
function waitingAt() {
const w = [];
for (let f = 0; f < FLOORS; f++) w.push([0, 0]);
for (const p of S.all) if (p.t <= S.t && p.board < 0) w[p.from][p.dir > 0 ? 0 : 1]++;
return w;
}
function draw() {
const cv = $('cv'), g = cv.getContext('2d');
const W = cv.clientWidth || 340, H = 268;
cv.width = W; cv.height = H;
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const fg = css('--muted'), line = css('--line'), car = css('--car'), open = css('--open'), up = css('--up'), dn = css('--dn');
const RH = (H - 4) / FLOORS, x0 = 100, n = S.cars.length, sw = (W - 6 - x0) / n;
const yTop = f => 2 + (FLOORS - 1 - f) * RH;
g.font = '11px system-ui, sans-serif'; g.textBaseline = 'middle';
const w = waitingAt();
for (let f = 0; f < FLOORS; f++) {
const y = yTop(f);
g.strokeStyle = line; g.beginPath(); g.moveTo(0, y + RH); g.lineTo(W, y + RH); g.stroke();
g.fillStyle = fg; g.textAlign = 'right'; g.fillText(String(f), 20, y + RH / 2);
let cx = 28;
for (let k = 0; k < 2; k++) {
const c = w[f][k];
g.fillStyle = k === 0 ? up : dn;
for (let i = 0; i < Math.min(c, 4); i++) { g.beginPath(); g.arc(cx + 4, y + RH / 2, 3.2, 0, 6.3); g.fill(); cx += 8; }
if (c > 4) { g.textAlign = 'left'; g.fillText('+' + (c - 4), cx, y + RH / 2); cx += 18; }
cx += 4;
}
}
for (let i = 0; i < n; i++) {
const c = S.cars[i], sx = x0 + i * sw;
g.fillStyle = css('--bar'); g.globalAlpha = 0.35; g.fillRect(sx + 2, 2, sw - 4, H - 4); g.globalAlpha = 1;
const pos = c.floor + (c.mode === 1 ? c.dir * c.prog / T_FLOOR : 0);
const cw = Math.min(sw - 8, 46), ch = RH - 3;
const cx = sx + (sw - cw) / 2, cy = yTop(pos) + 1.5;
g.fillStyle = c.mode === 2 ? open : car;
g.fillRect(cx, cy, cw, ch);
g.fillStyle = '#fff'; g.textAlign = 'center'; g.font = 'bold 11px system-ui, sans-serif';
g.fillText((c.dir > 0 ? '▲' : c.dir < 0 ? '▼' : '·') + c.onboard.length, cx + cw / 2, cy + ch / 2);
g.font = '11px system-ui, sans-serif';
}
}
function fmt1(v) { return v.toFixed(1); }
function updStatus() {
let waitN = 0;
for (const p of S.all) if (p.t <= S.t && p.board < 0) waitN++;
let txt = fmt("t = %t% s - waiting: %w% - delivered: %d% of %n%", { t: S.t, w: waitN, d: S.done, n: S.all.length });
if (finished(S)) {
const s = stats(S);
txt += ' ' + fmt("Finished: average wait %w% s, average journey %j% s, longest wait %m% s.", { w: fmt1(s.wait), j: fmt1(s.journey), m: s.max });
}
$('status').textContent = txt;
}
function stopTimer() { playing = false; if (timer) clearInterval(timer); timer = null; $('play').textContent = "Play"; }
function reset() {
stopTimer();
const q = params();
S = makeSim(makeTraffic(q.preset, q.seed), q.policy, q.n);
$('out').innerHTML = '';
$('phint').textContent = HINTS[q.policy];
draw(); updStatus();
}
function play() {
if (playing) { stopTimer(); return; }
if (finished(S) || S.t >= T_MAX) reset();
playing = true; $('play').textContent = "Pause";
timer = setInterval(() => {
acc += params().speed * 0.05;
while (acc >= 1) { acc -= 1; if (!finished(S) && S.t < T_MAX) tick(S); }
draw(); updStatus();
if (finished(S) || S.t >= T_MAX) stopTimer();
}, 50);
}
// Run every policy on the same traffic and tabulate the three metrics.
function compare() {
stopTimer();
const q = params(), tr = makeTraffic(q.preset, q.seed);
const rows = POLICIES.map(p => ({ p: p, s: stats(runAll(tr, p, q.n)) }));
const mx = k => Math.max.apply(null, rows.map(r => r.s[k]));
const mn = k => Math.min.apply(null, rows.map(r => r.s[k]));
const cell = (r, k) => {
const v = r.s[k], pct = mx(k) > 0 ? Math.round(100 * v / mx(k)) : 0;
return '<td class="m' + (v === mn(k) ? ' best' : '') + '"><div class="bar" style="width:' + pct + '%"></div><span>' + fmt1(v) + ' s</span></td>';
};
let h = '<table><caption>' + fmt("Same traffic (%n% passengers, seed %seed%) and %c% car(s) under every policy. Bold green = best in column.", { n: tr.length, c: q.n, seed: q.seed }) + '</caption>';
h += '<tr><th>Policy</th><th>Avg wait</th><th>Avg journey</th><th>Max wait</th></tr>';
for (const r of rows) h += '<tr><td>' + NAMES[r.p] + '</td>' + cell(r, 'wait') + cell(r, 'journey') + cell(r, 'max') + '</tr>';
$('out').innerHTML = h + '</table>';
}
['preset', 'policy', 'cars', 'seed'].forEach(id => $(id).addEventListener('change', reset));
$('newseed').addEventListener('click', () => { $('seed').value = 1 + Math.floor(Math.random() * 9999); reset(); });
$('play').addEventListener('click', play);
$('reset').addEventListener('click', reset);
$('cmp').addEventListener('click', compare);
window.addEventListener('resize', () => { if (S) draw(); });
reset();
Comments
Loading comments...