/* Venue digital-twin renderer.
*
* Canvas 2D, layered back to front:
* circuit geometry -> corridors (coloured by density) -> predicted congestion
* -> flow direction -> reroute overlay -> agents -> nodes -> labels -> alert halo
*
* Everything drawn here comes from a simulation frame. Nothing is decorative
* state: if a corridor is orange, its measured density put it there.
*/
const LEVEL_COLOURS = {
clear: '#2f9e6a',
busy: '#d7c33a',
warning: '#ff9310',
critical: '#ff3222',
};
const NODE_STYLE = {
grandstand: { r: 13, shape: 'stand', fill: '#1a2334', stroke: '#2f3d55' },
general_admission: { r: 13, shape: 'stand', fill: '#1a2334', stroke: '#2f3d55' },
concourse: { r: 7, shape: 'circle', fill: '#141c2b', stroke: '#2a3750' },
junction: { r: 5, shape: 'circle', fill: '#141c2b', stroke: '#2a3750' },
concession: { r: 6, shape: 'diamond',fill: '#1d2233', stroke: '#3a4462' },
gate: { r: 8, shape: 'gate', fill: '#182130', stroke: '#3d4d6a' },
exit: { r: 9, shape: 'gate', fill: '#221a1c', stroke: '#5e3438' },
transport: { r: 11, shape: 'hub', fill: '#0f2430', stroke: '#2b5f74' },
parking: { r: 10, shape: 'hub', fill: '#141f2c', stroke: '#33506b' },
restricted: { r: 8, shape: 'circle', fill: '#2a1418', stroke: '#6b2b32' },
};
export const LAYERS = [
{ id: 'agents', label: 'Agents', sw: '#7fd7ff', on: true },
{ id: 'density', label: 'Density', sw: '#ff9310', on: true },
{ id: 'prediction', label: 'Predicted', sw: '#a78bfa', on: true },
{ id: 'flow', label: 'Flow', sw: '#35c8f5', on: true },
{ id: 'reroute', label: 'Reroute', sw: '#12d38a', on: true },
{ id: 'labels', label: 'Labels', sw: '#9aa6bd', on: true },
];
export class VenueMap {
constructor(canvas, tooltipEl) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.tooltip = tooltipEl;
this.venue = null;
this.frame = null;
this.layers = Object.fromEntries(LAYERS.map(l => [l.id, l.on]));
this.hover = null;
this.pointer = null;
this.focusEdge = null; // base id of the primary bottleneck
this.dash = 0;
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this._edgeIndex = new Map();
this._nodeIndex = new Map();
this._raf = null;
this._lastTs = 0;
this._onResize = () => this.resize();
window.addEventListener('resize', this._onResize);
canvas.addEventListener('mousemove', e => this._onMove(e));
canvas.addEventListener('mouseleave', () => { this.pointer = null; this.hover = null; this._hideTip(); });
this.resize();
this._loop = this._loop.bind(this);
this._raf = requestAnimationFrame(this._loop);
}
destroy() {
cancelAnimationFrame(this._raf);
window.removeEventListener('resize', this._onResize);
}
setVenue(venue) {
this.venue = venue;
this.frame = null;
this._buildGeometry();
this.resize();
}
setFrame(frame) {
this.frame = frame;
if (!this.venue) return;
this._edgeIndex.clear();
for (const e of frame.edges || []) this._edgeIndex.set(e.id, e);
this._nodeIndex.clear();
for (const n of frame.nodes || []) this._nodeIndex.set(n.id, n);
this.focusEdge = frame.primary_bottleneck ? frame.primary_bottleneck.base_id : null;
this._predicted = new Set();
const critical = this.venue.critical_density;
for (const p of (frame.prediction && frame.prediction.top) || []) {
const peak = p.peak_projected ?? 0;
if (peak >= critical * 0.82 && peak > (p.current ?? 0) + 0.04) this._predicted.add(p.base_id);
}
this._rerouteEdges = new Set();
for (const path of frame.reroute_paths || []) {
for (const id of path.base_edges || []) this._rerouteEdges.add(id);
}
}
setLayer(id, on) { this.layers[id] = on; }
// ── geometry ────────────────────────────────────────────────────────
_buildGeometry() {
const v = this.venue;
if (!v) return;
this.nodeById = new Map(v.nodes.map(n => [n.id, n]));
this.edgePaths = v.edges.map(e => {
const a = this.nodeById.get(e.source), b = this.nodeById.get(e.target);
const pts = [[a.x, a.y], ...(e.via || []), [b.x, b.y]];
return { edge: e, pts };
});
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const consider = (x, y) => {
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
};
v.nodes.forEach(n => consider(n.x, n.y));
(v.landmarks || []).forEach(l => (l.points || []).forEach(p => consider(p[0], p[1])));
this.edgePaths.forEach(ep => ep.pts.forEach(p => consider(p[0], p[1])));
this.bounds = { minX, minY, maxX, maxY };
}
resize() {
const rect = this.canvas.getBoundingClientRect();
if (!rect.width || !rect.height) return;
this.canvas.width = Math.round(rect.width * this.dpr);
this.canvas.height = Math.round(rect.height * this.dpr);
this.w = rect.width;
this.h = rect.height;
this._computeTransform();
}
_computeTransform() {
if (!this.bounds || !this.w) return;
const pad = 46;
const bw = Math.max(this.bounds.maxX - this.bounds.minX, 1);
const bh = Math.max(this.bounds.maxY - this.bounds.minY, 1);
const s = Math.min((this.w - pad * 2) / bw, (this.h - pad * 2) / bh);
this.scale = s;
this.offX = (this.w - bw * s) / 2 - this.bounds.minX * s;
this.offY = (this.h - bh * s) / 2 - this.bounds.minY * s;
}
X(x) { return x * this.scale + this.offX; }
Y(y) { return y * this.scale + this.offY; }
scaleBarPx() { return this.scale ? 100 * this.scale : 0; }
// ── interaction ─────────────────────────────────────────────────────
_onMove(ev) {
const rect = this.canvas.getBoundingClientRect();
this.pointer = { x: ev.clientX - rect.left, y: ev.clientY - rect.top,
cx: ev.clientX, cy: ev.clientY };
this.hover = this._pick(this.pointer.x, this.pointer.y);
if (this.hover) this._showTip(); else this._hideTip();
}
_pick(px, py) {
if (!this.venue) return null;
for (const n of this.venue.nodes) {
const st = NODE_STYLE[n.type] || NODE_STYLE.junction;
const d = Math.hypot(this.X(n.x) - px, this.Y(n.y) - py);
if (d <= st.r + 5) return { kind: 'node', node: n };
}
let best = null, bestD = 11;
for (const ep of this.edgePaths) {
for (let i = 0; i < ep.pts.length - 1; i++) {
const d = distToSeg(px, py,
this.X(ep.pts[i][0]), this.Y(ep.pts[i][1]),
this.X(ep.pts[i + 1][0]), this.Y(ep.pts[i + 1][1]));
if (d < bestD) { bestD = d; best = { kind: 'edge', edge: ep.edge }; }
}
}
return best;
}
_showTip() {
const t = this.tooltip;
const h = this.hover;
let html = '';
if (h.kind === 'edge') {
const s = this._edgeIndex.get(h.edge.id) || {};
html = `
${h.edge.id.replace(/_/g, ' ')}
${row('Mean density', fmt(s.d, 2) + ' p/m²')}
${row('Peak local', fmt(s.dl, 2) + ' p/m²')}
${row('Walking speed', fmt(s.v, 2) + ' m/s')}
${row('Inflow', fmt(s.in, 0) + '/min')}
${row('Outflow', fmt(s.out, 0) + '/min')}
${row('Queueing', fmt(s.q, 0))}
${row('Capacity use', pct(s.u))}
${row('Width', h.edge.width_m + ' m')}
${row('Length', Math.round(h.edge.length_m) + ' m')}
${row('Capacity', Math.round(h.edge.capacity_ppm) + '/min')}
${row('Risk', fmt(s.r, 2))}`;
} else {
const s = this._nodeIndex.get(h.node.id) || {};
html = `${h.node.name}
${row('Type', h.node.type.replace(/_/g, ' '))}
${h.node.area_m2 ? row('Occupancy', fmt(s.occ, 0)) : ''}
${h.node.area_m2 ? row('Density', fmt(s.d, 2) + ' p/m²') : ''}
${row('Queueing', fmt(s.q, 0))}
${s.cap != null ? row('Throughput', fmt(s.thr, 0) + ' / ' + fmt(s.cap, 0) + ' per min') : ''}
${s.cap != null && s.cap_pct !== 100 ? row('Capacity', s.cap_pct + '% of nominal') : ''}
${h.node.note ? `${h.node.note}
` : ''}`;
}
t.innerHTML = html;
t.hidden = false;
const bw = t.offsetWidth, bh = t.offsetHeight;
let x = this.pointer.x + 16, y = this.pointer.y + 16;
if (x + bw > this.w - 8) x = this.pointer.x - bw - 16;
if (y + bh > this.h - 8) y = this.pointer.y - bh - 16;
t.style.left = Math.max(8, x) + 'px';
t.style.top = Math.max(8, y) + 'px';
}
_hideTip() { this.tooltip.hidden = true; }
// ── render loop ─────────────────────────────────────────────────────
_loop(ts) {
const dt = this._lastTs ? Math.min(ts - this._lastTs, 64) : 16;
this._lastTs = ts;
this.dash = (this.dash + dt * 0.028) % 1000;
this.draw();
this._raf = requestAnimationFrame(this._loop);
}
draw() {
const ctx = this.ctx;
if (!ctx || !this.w) return;
ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
ctx.clearRect(0, 0, this.w, this.h);
if (!this.venue) return;
this._drawLandmarks(ctx);
this._drawEdges(ctx);
if (this.layers.prediction) this._drawPredicted(ctx);
if (this.layers.reroute) this._drawReroute(ctx);
if (this.layers.flow) this._drawFlow(ctx);
if (this.layers.agents) this._drawAgents(ctx);
this._drawNodes(ctx);
if (this.layers.labels) this._drawLabels(ctx);
this._drawFocus(ctx);
}
_drawLandmarks(ctx) {
for (const lm of this.venue.landmarks || []) {
const pts = lm.points || [];
if (pts.length < 2) continue;
ctx.beginPath();
pts.forEach((p, i) => i ? ctx.lineTo(this.X(p[0]), this.Y(p[1]))
: ctx.moveTo(this.X(p[0]), this.Y(p[1])));
if (lm.closed) ctx.closePath();
if (lm.kind === 'track') {
ctx.strokeStyle = 'rgba(120,138,170,.20)';
ctx.lineWidth = Math.max(9, 16 * this.scale);
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.stroke();
ctx.strokeStyle = 'rgba(160,180,215,.30)';
ctx.lineWidth = 1.1;
ctx.setLineDash([7, 9]);
ctx.stroke();
ctx.setLineDash([]);
} else if (lm.kind === 'infield') {
ctx.fillStyle = 'rgba(14,20,32,.62)';
ctx.fill();
} else if (lm.kind === 'building') {
ctx.fillStyle = 'rgba(38,48,68,.5)';
ctx.fill();
ctx.strokeStyle = 'rgba(90,108,142,.35)';
ctx.lineWidth = 1; ctx.stroke();
} else if (lm.kind === 'label') {
ctx.strokeStyle = 'rgba(225,6,0,.75)';
ctx.lineWidth = 3; ctx.stroke();
if (lm.label) {
ctx.fillStyle = 'rgba(225,80,70,.85)';
ctx.font = '600 9px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(lm.label, this.X(pts[0][0]), this.Y(pts[0][1]) - 6);
}
}
}
}
_edgeWidthPx(e) {
return Math.max(2.2, Math.min(e.width_m * this.scale * 0.72, 15));
}
_drawEdges(ctx) {
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (const ep of this.edgePaths) {
const st = this._edgeIndex.get(ep.edge.id);
const w = this._edgeWidthPx(ep.edge);
ctx.beginPath();
ep.pts.forEach((p, i) => i ? ctx.lineTo(this.X(p[0]), this.Y(p[1]))
: ctx.moveTo(this.X(p[0]), this.Y(p[1])));
ctx.strokeStyle = 'rgba(24,32,48,.95)';
ctx.lineWidth = w + 3.5;
ctx.stroke();
let colour = '#243044';
if (st && this.layers.density) {
const level = st.lvl || 'clear';
if (level === 'clear') {
// Fade an idle corridor in from the base grey so that "lightly used"
// is visually distinct from "empty".
const t = (st.d || 0) / Math.max(this.venue.warning_density * 0.55, 0.1);
colour = mix('#2a3750', LEVEL_COLOURS.clear, Math.min(t, 1));
} else {
colour = LEVEL_COLOURS[level];
}
}
ctx.strokeStyle = colour;
ctx.lineWidth = w;
ctx.globalAlpha = st && this.layers.density ? 0.95 : 0.55;
ctx.stroke();
ctx.globalAlpha = 1;
// A saturated corridor gets a soft glow so it reads at a glance.
if (st && this.layers.density && (st.lvl === 'critical' || st.lvl === 'warning')) {
ctx.save();
ctx.shadowColor = LEVEL_COLOURS[st.lvl];
ctx.shadowBlur = st.lvl === 'critical' ? 16 : 9;
ctx.strokeStyle = LEVEL_COLOURS[st.lvl];
ctx.lineWidth = w * 0.55;
ctx.globalAlpha = st.lvl === 'critical' ? 0.85 : 0.55;
ctx.stroke();
ctx.restore();
}
}
}
_drawPredicted(ctx) {
if (!this._predicted || !this._predicted.size) return;
ctx.save();
ctx.setLineDash([6, 6]);
ctx.lineDashOffset = -this.dash * 0.7;
for (const ep of this.edgePaths) {
if (!this._predicted.has(ep.edge.id)) continue;
ctx.beginPath();
ep.pts.forEach((p, i) => i ? ctx.lineTo(this.X(p[0]), this.Y(p[1]))
: ctx.moveTo(this.X(p[0]), this.Y(p[1])));
ctx.strokeStyle = 'rgba(167,139,250,.95)';
ctx.lineWidth = this._edgeWidthPx(ep.edge) + 5.5;
ctx.stroke();
}
ctx.restore();
}
_drawReroute(ctx) {
if (!this._rerouteEdges || !this._rerouteEdges.size) return;
ctx.save();
ctx.setLineDash([12, 10]);
ctx.lineDashOffset = -this.dash * 1.6;
ctx.lineCap = 'round';
for (const ep of this.edgePaths) {
if (!this._rerouteEdges.has(ep.edge.id)) continue;
ctx.beginPath();
ep.pts.forEach((p, i) => i ? ctx.lineTo(this.X(p[0]), this.Y(p[1]))
: ctx.moveTo(this.X(p[0]), this.Y(p[1])));
ctx.strokeStyle = 'rgba(18,211,138,.9)';
ctx.lineWidth = this._edgeWidthPx(ep.edge) * 0.5 + 1.5;
ctx.shadowColor = 'rgba(18,211,138,.6)';
ctx.shadowBlur = 8;
ctx.stroke();
}
ctx.restore();
}
_drawFlow(ctx) {
ctx.save();
for (const ep of this.edgePaths) {
const st = this._edgeIndex.get(ep.edge.id);
if (!st || (st.in || 0) < 25) continue;
const speedRatio = Math.min((st.v || 0) / 1.34, 1);
const reversed = !!st.reversed;
const pts = reversed ? [...ep.pts].reverse() : ep.pts;
const spacing = 26;
const phase = (this.dash * (0.35 + speedRatio * 1.5)) % spacing;
const total = polyLength(pts, this);
ctx.fillStyle = `rgba(150,220,255,${0.16 + 0.42 * speedRatio})`;
for (let d = phase; d < total; d += spacing) {
const pt = pointAt(pts, d, this);
if (!pt) continue;
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.rotate(pt.a);
ctx.beginPath();
ctx.moveTo(3.6, 0); ctx.lineTo(-2.6, 2.3); ctx.lineTo(-2.6, -2.3);
ctx.closePath(); ctx.fill();
ctx.restore();
}
}
ctx.restore();
}
_drawAgents(ctx) {
const a = this.frame && this.frame.agents;
if (!a || !a.x || !a.x.length) return;
const r = Math.max(1.1, Math.min(this.scale * 1.5, 2.4));
for (let i = 0; i < a.x.length; i++) {
const v = a.v[i];
ctx.fillStyle = v > 0.62 ? 'rgba(150,214,255,.82)'
: v > 0.32 ? 'rgba(255,205,110,.86)'
: 'rgba(255,110,88,.92)';
ctx.beginPath();
ctx.arc(this.X(a.x[i]), this.Y(a.y[i]), r, 0, 6.2832);
ctx.fill();
}
}
_drawNodes(ctx) {
for (const n of this.venue.nodes) {
const style = NODE_STYLE[n.type] || NODE_STYLE.junction;
const s = this._nodeIndex.get(n.id) || {};
const x = this.X(n.x), y = this.Y(n.y);
const r = style.r;
// Queue ring: how full the gate's waiting area is.
if ((s.q || 0) > 60) {
const q = Math.min(s.q / 4200, 1);
ctx.beginPath();
ctx.arc(x, y, r + 5, -Math.PI / 2, -Math.PI / 2 + q * 6.2832);
ctx.strokeStyle = q > 0.6 ? 'rgba(255,50,34,.9)' : q > 0.3 ? 'rgba(255,147,16,.9)' : 'rgba(215,195,58,.8)';
ctx.lineWidth = 2.6;
ctx.lineCap = 'round';
ctx.stroke();
}
ctx.beginPath();
drawShape(ctx, style.shape, x, y, r);
const lvl = s.lvl && s.lvl !== 'clear' ? LEVEL_COLOURS[s.lvl] : null;
ctx.fillStyle = lvl ? mix(style.fill, lvl, 0.5) : style.fill;
ctx.fill();
ctx.strokeStyle = lvl || style.stroke;
ctx.lineWidth = lvl ? 1.9 : 1.2;
ctx.stroke();
// Degraded capacity marker.
if (s.cap_pct != null && s.cap_pct < 100) {
ctx.beginPath();
ctx.arc(x + r * 0.82, y - r * 0.82, 3.6, 0, 6.2832);
ctx.fillStyle = '#ff3222';
ctx.fill();
ctx.strokeStyle = '#0b0f18'; ctx.lineWidth = 1.2; ctx.stroke();
}
}
}
_drawLabels(ctx) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const n of this.venue.nodes) {
const style = NODE_STYLE[n.type] || NODE_STYLE.junction;
if (style.r < 7) continue;
const label = n.short_label || n.name;
const x = this.X(n.x), y = this.Y(n.y) + style.r + 10;
ctx.font = '500 8.5px ui-monospace, SFMono-Regular, Menlo, monospace';
const w = ctx.measureText(label).width;
ctx.beginPath();
roundRect(ctx, x - w / 2 - 4, y - 6.5, w + 8, 13, 3);
ctx.fillStyle = 'rgba(7,10,17,.78)';
ctx.fill();
ctx.fillStyle = '#8f9db6';
ctx.fillText(label, x, y);
}
}
_drawFocus(ctx) {
if (!this.focusEdge) return;
const ep = this.edgePaths.find(p => p.edge.id === this.focusEdge);
if (!ep) return;
const mid = ep.pts[Math.floor(ep.pts.length / 2)];
const x = this.X(mid[0]), y = this.Y(mid[1]);
const t = (Date.now() % 1800) / 1800;
const r = 16 + t * 22;
ctx.beginPath();
ctx.arc(x, y, r, 0, 6.2832);
ctx.strokeStyle = `rgba(255,50,34,${0.55 * (1 - t)})`;
ctx.lineWidth = 2;
ctx.stroke();
}
}
/* ── helpers ─────────────────────────────────────────────────────── */
function drawShape(ctx, shape, x, y, r) {
if (shape === 'circle') { ctx.arc(x, y, r, 0, 6.2832); return; }
if (shape === 'diamond') {
ctx.moveTo(x, y - r); ctx.lineTo(x + r, y); ctx.lineTo(x, y + r); ctx.lineTo(x - r, y); ctx.closePath(); return;
}
if (shape === 'hub') {
ctx.arc(x, y, r, 0, 6.2832);
return;
}
const w = shape === 'stand' ? r * 1.75 : r * 1.5;
const h = shape === 'stand' ? r * 1.05 : r * 1.35;
roundRect(ctx, x - w / 2, y - h / 2, w, h, shape === 'stand' ? 3 : 2.5);
}
function roundRect(ctx, x, y, w, h, r) {
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function polyLength(pts, m) {
let t = 0;
for (let i = 0; i < pts.length - 1; i++) {
t += Math.hypot(m.X(pts[i + 1][0]) - m.X(pts[i][0]), m.Y(pts[i + 1][1]) - m.Y(pts[i][1]));
}
return t;
}
function pointAt(pts, dist, m) {
let acc = 0;
for (let i = 0; i < pts.length - 1; i++) {
const x0 = m.X(pts[i][0]), y0 = m.Y(pts[i][1]);
const x1 = m.X(pts[i + 1][0]), y1 = m.Y(pts[i + 1][1]);
const seg = Math.hypot(x1 - x0, y1 - y0);
if (acc + seg >= dist) {
const t = seg ? (dist - acc) / seg : 0;
return { x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t, a: Math.atan2(y1 - y0, x1 - x0) };
}
acc += seg;
}
return null;
}
function distToSeg(px, py, x1, y1, x2, y2) {
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy;
const t = len2 ? Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2)) : 0;
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
function hex2rgb(h) {
const n = parseInt(h.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mix(a, b, t) {
const A = hex2rgb(a), B = hex2rgb(b);
const k = Math.max(0, Math.min(1, t));
return `rgb(${Math.round(A[0] + (B[0] - A[0]) * k)},${Math.round(A[1] + (B[1] - A[1]) * k)},${Math.round(A[2] + (B[2] - A[2]) * k)})`;
}
const fmt = (v, d) => (v == null || Number.isNaN(v)) ? '—' : Number(v).toFixed(d);
const pct = v => v == null ? '—' : Math.round(v * 100) + '%';
const row = (k, v) => `${k}${v}
`;
export { LEVEL_COLOURS };