Spaces:
Runtime error
Runtime error
| /* 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 = `<h5>${h.edge.id.replace(/_/g, ' ')}</h5> | |
| ${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')} | |
| <div class="tsep"></div> | |
| ${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))} | |
| <div class="tsep"></div> | |
| ${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 = `<h5>${h.node.name}</h5> | |
| ${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 ? `<div class="tsep"></div><div style="color:var(--text-faint);font-size:10.5px">${h.node.note}</div>` : ''}`; | |
| } | |
| 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) => `<div class="trow"><span>${k}</span><b>${v}</b></div>`; | |
| export { LEVEL_COLOURS }; | |