/* 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 };