// --------------------------------------------------------------------------- // Topology graph renderer — PixiJS v8 (WebGL/WebGPU). // // Replaces the hand-rolled inline-SVG topology. Why Pixi: // * 37 nodes / 36 edges redrawn every 10s as one innerHTML string blew away // and re-created every DOM node, so CSS animations (attack pulse) restarted // and dragging fought the browser's own hit-testing. // * The scene graph gives per-node transforms for free, so pan/zoom is a // single container transform instead of getScreenCTM() matrix math. // // v8 API notes (verified against pixijs.com/8.x docs, NOT v7 memory): // * `new Application()` then `await app.init({...})` — init is ASYNC. // * `app.canvas` is the HTMLCanvasElement (v7 was `app.view`). // * Graphics is a builder: `.circle(x,y,r).fill({color}).stroke({width,color})`. // The v7 `.beginFill().drawCircle().endFill()` chain is gone. // * `Text` uses `style: { fill, fontSize, fontFamily, align }`; v7 took flat // ctor args like `new Text(txt, style, canvas)`, which no longer applies. // // The module is self-contained: it owns its Application, and `destroy()` tears // it down so re-entering the tab cannot leak a second WebGL context. // --------------------------------------------------------------------------- let PIXI = null; export async function loadPixi() { if (PIXI) return PIXI; // Dynamic import: the topology tab may never be opened, and an 810 KB parse // on every panel load is pure waste. PIXI = await import('/static/vendor/pixi.mjs'); return PIXI; } const THEME = { bg: 0x0a0f1c, edge: 0x2a4a6f, edgeLabel: 0x5a7a9f, node: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0xa8c3e0, r: 34 }, panel: { fill: 0x0ea5e9, alpha: 0.20, stroke: 0x0ea5e9, color: 0x7dd3fc, r: 42 }, infra: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0x8aa0b8, r: 30 }, team: { fill: 0x2563eb, alpha: 0.13, stroke: 0x3b82f6, color: 0x93c5fd, r: 48 }, challenge: { fill: 0x0e1526, stroke: 0x2a4a6f, color: 0xc9d4e3, r: 26 }, running: 0x34d399, attackOk: 0xf87171, attackFail: 0xfb923c, }; // Challenge layout order — must match the old SVG code, which positioned the // 6 native GEMASTIK XVIII challenges left-to-right under their team. const NATIVE_ORDER = ['blogpost', 'carbeat', 'cdn', 'phew', 'sheesh', 'warmup']; export class TopoGraph { constructor(host) { this.host = host; this.app = null; this.world = null; // pan/zoom container this.edgeLayer = null; this.attackLayer = null; this.nodeLayer = null; this.nodeViews = new Map(); // id -> {container, data} this.edgeViews = new Map(); // 'from->to' -> {line, label} this.childrenOf = new Map(); // parent id -> [child ids], for hierarchical drag this.attackViews = new Map(); // key -> {gfx, label, latest, ok, fail} this.scale = 1; this.panX = 0; this.panY = 0; this.nodes = []; this.edges = []; this.attacks = []; this.drag = null; this.ticker = 0; } async init() { const PIXI = await loadPixi(); this.PIXI = PIXI; this.app = new PIXI.Application(); await this.app.init({ resizeTo: this.host, background: THEME.bg, antialias: true, autoDensity: true, resolution: Math.min(2, window.devicePixelRatio || 1), preference: 'webgl', // WebGL clears its back buffer after compositing unless this is set, so a // readback/screenshot of the canvas is a coin flip that depends on which // frame the snapshot lands on. That made the render look intermittently // blank. Cheap here (a 2D scene, no shaders) and it makes the view // capturable for tests, screenshots and "save image" affordances. preserveDrawingBuffer: true, }); this.app.canvas.style.display = 'block'; this.app.canvas.style.width = '100%'; this.app.canvas.style.height = '520px'; this.app.canvas.style.touchAction = 'none'; this.host.innerHTML = ''; this.host.appendChild(this.app.canvas); this.world = new PIXI.Container(); this.app.stage.addChild(this.world); // Render groups: pan/zoom touches ONE transform instead of every child. this.world.enableRenderGroup(); // First setData() must frame the graph instead of rendering at scale=1. this._needsFit = true; // `resizeTo: this.host` resizes the renderer but not our view transform, so // a narrower window would clip the graph. Re-frame instead. this._resizeHandler = () => this._onResize(); window.addEventListener('resize', this._resizeHandler); this.edgeLayer = new PIXI.Container(); this.attackLayer = new PIXI.Container(); this.nodeLayer = new PIXI.Container(); this.world.addChild(this.edgeLayer, this.attackLayer, this.nodeLayer); this._bindPointer(); // Render on demand: the ticker is registered but NOT started. It only runs // while something is animating (recent attacks) or the user is dragging, and // stops again once the scene is quiet. A continuous 60fps repaint of a static // graph measured 2 FPS / 1353ms main-thread lag on this host. this._ticking = false; this.app.ticker.add((ticker) => this._animate(ticker.deltaMS)); this.app.ticker.stop(); this.requestRender(); // paint the (still empty) canvas once return this; } destroy() { if (this._resizeHandler) window.removeEventListener('resize', this._resizeHandler); if (this.app) { try { this.app.destroy(true, { children: true }); } catch (e) { /* already gone */ } } this.app = null; this.nodeViews.clear(); this.edgeViews.clear(); this.attackViews.clear(); } // ---- layout ------------------------------------------------------------- layout(nodes) { // The world is wider than the canvas on purpose (the old SVG scrolled // horizontally), but the graph must still START inside the viewport: the // team ring was centred on W/2, so with 37 nodes W grew to ~4810 and every // team/challenge node landed far off the right edge of a ~1300px canvas. // Centre the layout on the VISIBLE width, then let it grow to the right for // overflow, and let reset()/applyView() fit it back on screen. const visW = Math.max(320, (this.app && this.app.renderer ? this.app.renderer.width / this.app.renderer.resolution : 900)); const W = Math.max(visW, 900, nodes.length * 60); const H = 500; // Centre the ring on the viewport, not on the scrollable width. const cx = visW / 2; const pos = {}; pos.dns = { x: cx, y: 30 }; pos.traefik = { x: cx, y: 100 }; pos.panel = { x: cx - 140, y: 100 }; const teams = nodes.filter(n => n.type === 'team'); const teamPos = {}; teams.forEach((t, i) => { const ang = (i / Math.max(1, teams.length)) * Math.PI * 2 - Math.PI / 2; // Radius must fit the viewport too, or the outermost team is off-screen. const rx = Math.min(visW * 0.36, 420); const ry = 100; const tx = cx + rx * Math.cos(ang); const ty = 250 + ry * Math.sin(ang) * 0.6; teamPos[t.index] = { x: tx, y: ty }; pos[t.id] = { x: tx, y: ty }; }); nodes.filter(n => n.type === 'challenge').forEach(n => { const ti = n.id.split('-')[0].replace('team', ''); const base = teamPos[ti] || { x: cx, y: 300 }; const label = n.label || ''; const tail = label.split('-').pop() || label; const chIdx = NATIVE_ORDER.indexOf(tail); // Imported XVI/XVII challenges aren't in NATIVE_ORDER: fan them out to // the right of the native six so they never stack on the same column. const slot = chIdx >= 0 ? chIdx - 2.5 : (n.nativeIndex ?? 0) + 3.5; pos[n.id] = { x: base.x + slot * 70, y: base.y + 130 }; }); return { pos, W, H }; } // Frame the whole graph inside the viewport (used by reset + first load). fit() { if (!this.app || !this.pos) return; const rw = this.app.renderer.width / this.app.renderer.resolution; const rh = this.app.renderer.height / this.app.renderer.resolution; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const [, v] of this.nodeViews) { minX = Math.min(minX, v.c.x - 60); maxX = Math.max(maxX, v.c.x + 60); minY = Math.min(minY, v.c.y - 60); maxY = Math.max(maxY, v.c.y + 60); } if (!isFinite(minX)) return; const gw = maxX - minX, gh = maxY - minY; const pad = 16; this.scale = Math.max(0.2, Math.min(1.6, Math.min((rw - pad * 2) / gw, (rh - pad * 2) / gh))); this.panX = (rw - gw * this.scale) / 2 - minX * this.scale; this.panY = (rh - gh * this.scale) / 2 - minY * this.scale; this.applyView(); } // ---- data -> scene ------------------------------------------------------ setData(nodes, edges, attacks) { if (!this.app) return; this.nodes = nodes || []; this.edges = edges || []; this.attacks = attacks || []; const PIXI = this.PIXI; const { pos, W, H } = this.layout(this.nodes); this.pos = pos; this.worldW = W; this.worldH = H; // Parent -> children index, for hierarchical dragging. Ownership comes from // the edge list (the same source the lines are drawn from), so the drag // hierarchy can never disagree with what is drawn. Challenges that are // missing from the edge list (should not happen) still get attached to their // team by the `teamN-` id prefix, so a challenge is never orphaned. const owned = new Map(); for (const e of this.edges) { const a = this.nodes.find(n => n.id === e.from); const b = this.nodes.find(n => n.id === e.to); if (!a || !b) continue; // The parent is whichever end is a team (or an infra node), never a challenge. let parent = null, child = null; if (a.type === 'challenge' && b.type === 'team') { parent = b; child = a; } else if (b.type === 'challenge' && a.type === 'team') { parent = a; child = b; } else if (a.type === 'challenge' && b.type !== 'challenge') { parent = b; child = a; } else if (b.type === 'challenge' && a.type !== 'challenge') { parent = a; child = b; } if (!parent || !child) continue; if (!owned.has(parent.id)) owned.set(parent.id, []); const list = owned.get(parent.id); if (!list.includes(child.id)) list.push(child.id); } for (const n of this.nodes) { if (n.type !== 'challenge') continue; const teamId = 'team' + n.id.split('-')[0].replace('team', ''); if (!this.nodes.some(x => x.id === teamId)) continue; if (!owned.has(teamId)) owned.set(teamId, []); if (!owned.get(teamId).includes(n.id)) owned.get(teamId).push(n.id); } this.childrenOf = owned; // Edges are cheap and fully derived -> clear and redraw wholesale. this._redrawEdges(pos); this._syncNodes(pos); this._syncAttacks(pos); // First paint (or a big data change) must land framed, not at scale=1 with // the graph hanging off the right edge. if (this._needsFit) { this._needsFit = false; this.fit(); } else this.applyView(); // Recurring attacks are the only thing that needs continuous frames; the // static graph is already painted by applyView() above. if (this._attacksAreAnimating()) this._startTicking(); } _syncNodes(pos) { const PIXI = this.PIXI; const seen = new Set(); for (const n of this.nodes) { const p = pos[n.id]; if (!p) continue; seen.add(n.id); let view = this.nodeViews.get(n.id); if (!view) { const c = new PIXI.Container(); const halo = new PIXI.Graphics(); const disc = new PIXI.Graphics(); const title = new PIXI.Text({ text: n.label, style: { fill: 0xffffff, fontSize: 10, fontFamily: 'ui-sans-serif, system-ui', fontWeight: 'bold', align: 'center' }, }); const sub = new PIXI.Text({ text: '', style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace', align: 'center' }, }); const dot = new PIXI.Graphics(); c.addChild(halo, disc, title, sub, dot); // Nodes must be draggable, so make the whole container interactive. c.eventMode = 'static'; c.cursor = 'grab'; c.on('pointerdown', (ev) => this._onNodeDown(ev, c)); title.anchor.set(0.5); sub.anchor.set(0.5); this.nodeLayer.addChild(c); view = { c, halo, disc, title, sub, dot, data: null }; this.nodeViews.set(n.id, view); } view.data = n; const theme = this._themeFor(n); const r = theme.r; const fillAlpha = theme.alpha ?? 1; // Assigning `.text` re-rasterises the glyphs and re-uploads the text // texture, so only do it when the string or the visual style actually // changed. Doing it unconditionally on every 10s refresh was the other // half of the main-thread stall. if (view.discKey !== `${theme.fill}|${theme.stroke}|${r}|${fillAlpha}`) { view.discKey = `${theme.fill}|${theme.stroke}|${r}|${fillAlpha}`; view.disc.clear() .circle(0, 0, r) .fill({ color: theme.fill, alpha: fillAlpha }) .stroke({ width: 1.5, color: theme.stroke, alpha: 1 }); view.halo.clear() .circle(0, 0, Math.max(1, r - 4)) .stroke({ width: 1, color: theme.stroke, alpha: 0.3 }); } const titleSize = n.type === 'team' ? 12 : 10; if (view.titleKey !== `${n.label}|${theme.color}|${titleSize}`) { view.titleKey = `${n.label}|${theme.color}|${titleSize}`; view.title.style.fill = theme.color; view.title.style.fontSize = titleSize; view.title.text = n.label; } const subText = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : ''); if (view.subText !== subText) { view.subText = subText; view.sub.text = subText || ''; view.sub.visible = !!subText; view.title.y = subText ? 0 : -4; view.sub.y = 12; } // Position only when it changed: writing x/y every refresh would fight a // drag in progress and make the graph jitter under the cursor. if (!view.dragging && (view.posKey !== `${p.x},${p.y}`)) { view.posKey = `${p.x},${p.y}`; view.c.position.set(p.x, p.y); } const running = n.status === 'running'; if (view.running !== running) { view.running = running; view.dot.clear(); if (running) view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running }); } // Bind the tooltip ONCE, at creation. Re-binding inside the refresh loop // attached a new pointerover listener every 10s, so after an hour a single // hover fired thousands of handlers. if (n.tooltip && !view.hasTooltip) { view.hasTooltip = true; view.c.on('pointerover', () => this._setTooltip(n)); view.c.on('pointerout', () => this._setTooltip(null)); } } // Drop views for nodes that no longer exist (a deleted team, a challenge // disabled in the challenge manager) so they don't linger as ghosts. for (const [id, view] of [...this.nodeViews]) { if (seen.has(id)) continue; view.c.destroy({ children: true }); this.nodeViews.delete(id); } } _themeFor(n) { if (n.type === 'panel') return THEME.panel; if (n.type === 'infra') return THEME.infra; if (n.type === 'team') return THEME.team; if (n.type === 'challenge') return THEME.challenge; return THEME.node; } _syncAttacks(pos) { const PIXI = this.PIXI; const now = Date.now() / 1000; const recent = this.attacks.filter(e => now - (e.ts || 0) < 600); const counts = {}; for (const e of recent) { const k = `${e.attacker}:${e.target}`; counts[k] = counts[k] || { ok: 0, fail: 0, latest: e.ts || 0 }; counts[k][e.success ? 'ok' : 'fail']++; counts[k].latest = Math.max(counts[k].latest, e.ts || 0); } this.attackCounts = counts; const seen = new Set(); for (const [k, c] of Object.entries(counts)) { const [atk, tgt] = k.split(':'); const a = pos['team' + atk], b = pos['team' + tgt]; if (!a || !b || atk === tgt) continue; seen.add(k); let view = this.attackViews.get(k); if (!view) { const g = new PIXI.Container(); const line = new PIXI.Graphics(); const label = new PIXI.Text({ text: '', style: { fill: 0xffffff, fontSize: 12, fontFamily: 'ui-sans-serif, system-ui', align: 'center' }, }); label.anchor.set(0.5); g.addChild(line, label); g.eventMode = 'none'; this.attackLayer.addChild(g); view = { g, line, label }; this.attackViews.set(k, view); } view.color = c.ok > 0 ? THEME.attackOk : THEME.attackFail; view.line.clear() .moveTo(a.x, a.y).lineTo(b.x, b.y) .stroke({ width: 2, color: view.color, alpha: 0.9 }); view.label.style.fill = view.color; view.label.text = `${c.ok}✓ ${c.fail}✗`; view.label.position.set((a.x + b.x) / 2 + 8, (a.y + b.y) / 2 - 10); view.latest = c.latest; view.ok = c.ok; view.fail = c.fail; } for (const [k, view] of [...this.attackViews]) { if (seen.has(k)) continue; view.g.destroy({ children: true }); this.attackViews.delete(k); } } // ---- animation --------------------------------------------------------- // Render on demand, NOT every frame. // // Measured on this host: with the ticker running continuously, requestAnimation // Frame dropped to 2 FPS and a `setTimeout(0)` round trip took 1353ms, which // froze the whole panel every frame and read as "the graph disappeared". // With the ticker stopped: 72 FPS and a 15ms lag. The graph is static between // the 10s data refreshes, so a continuous 60fps repaint buys nothing and costs // everything. // // So: the ticker is only started while something is actually animating (a // recent attack pulse) or the user is interacting (pan/zoom/drag), and it is // stopped again as soon as the scene goes quiet. Every mutation calls // requestRender() to guarantee at least one repaint. requestRender() { this._needsRender = true; this.app.render(); // paint once, synchronously } _startTicking() { if (this._ticking || !this.app) return; this._ticking = true; this.app.ticker.start(); } _stopTicking() { if (!this._ticking || !this.app) return; this._ticking = false; this.app.ticker.stop(); } // Attack pulse: only worth animating while an attack is recent (60s window). // Outside that window every line is static, so the ticker can be parked. _attacksAreAnimating() { if (!this.attackViews.size) return false; const now = Date.now() / 1000; for (const [, view] of this.attackViews) { if (now - (view.latest || 0) < 60) return true; } return false; } _animate(deltaMS) { this.ticker += deltaMS; if (this.ticker < 66) { // ~15fps is plenty for a 1.2s pulse if (this._attacksAreAnimating()) this.app.render(); return; } this.ticker = 0; const now = Date.now() / 1000; let stillAnimating = false; for (const [k, view] of this.attackViews) { const hot = now - (view.latest || 0) < 60; if (hot) stillAnimating = true; // 1.2s cycle, matching the old CSS keyframes (attackPulse 1.2s). const phase = (now % 1.2) / 1.2; view.line.alpha = hot ? 0.55 + 0.45 * Math.sin(phase * Math.PI) : 0.9; // Recent attacks read as a dashed "hot" line; redraw only on state change // so we are not re-tessellating every frame for nothing. const wantDash = hot ? '6 3' : '4 4'; if (view.dash !== wantDash) { view.dash = wantDash; const a = this.pos['team' + k.split(':')[0]]; const b = this.pos['team' + k.split(':')[1]]; if (a && b) { view.line.clear() .moveTo(a.x, a.y).lineTo(b.x, b.y) .stroke({ width: hot ? 3 : 2, color: view.color, alpha: 0.9 }); } } } // Nothing left to animate: stop paying for frames. if (!stillAnimating) this._stopTicking(); } // ---- pan / zoom / drag ------------------------------------------------- _bindPointer() { const cv = this.app.canvas; // v8 replaces the v7 `interactive = true` + `on('pointermove')` on the // stage with eventMode on the object plus a normal DOM listener for the // background pan (the stage itself is not interactive by default). cv.addEventListener('wheel', (ev) => { ev.preventDefault(); this.zoomAt(ev, ev.ctrlKey ? 0.01 : 0.0022); }, { passive: false }); let panning = null; cv.addEventListener('pointerdown', (ev) => { if (this.drag) return; // node drag wins panning = { x: ev.clientX, y: ev.clientY, px: this.panX, py: this.panY }; cv.setPointerCapture(ev.pointerId); }); cv.addEventListener('pointermove', (ev) => { if (!panning) return; this.panX = panning.px + (ev.clientX - panning.x); this.panY = panning.py + (ev.clientY - panning.y); this.applyView(); }); const endPan = (ev) => { if (!panning) return; panning = null; try { cv.releasePointerCapture(ev.pointerId); } catch (e) { /* not captured */ } }; cv.addEventListener('pointerup', endPan); cv.addEventListener('pointercancel', endPan); } _onNodeDown(ev, container) { ev.stopPropagation(); const view = [...this.nodeViews.values()].find(v => v.c === container); if (!view) return; const world = this.toWorld(ev); view.dragging = true; container.cursor = 'grabbing'; // Dragging a parent must carry its children: a team node owns its 16 // challenge nodes, and a challenge belongs to exactly one team. Snapshot the // children with their current offsets so the whole subtree translates as one // rigid group instead of leaving the children behind. const children = (this.childrenOf.get(view.data.id) || []) .map((cid) => this.nodeViews.get(cid)) .filter((cv) => cv && cv.c !== container); const kids = children.map((cv) => ({ view: cv, dx: cv.c.x - container.x, dy: cv.c.y - container.y, })); this.drag = { view, dx: container.x - world.x, dy: container.y - world.y, kids }; this._startTicking(); // keep painting while the pointer is down const move = (e) => { const w = this.toWorld(e); container.position.set(w.x + this.drag.dx, w.y + this.drag.dy); // Children ride along with the parent, keeping their original offsets. // Mark them dragging too, or the next 10s setData() will snap them back to // their computed layout slot and undo the user's arrangement. for (const k of this.drag.kids) { k.view.dragging = true; k.view.c.position.set(container.x + k.dx, container.y + k.dy); k.view.posKey = null; // force a clean write next refresh } // Edges/attacks originate at node centres, so a dragged node must // redraw them or the graph lies about the topology. this._redrawForDrag(); }; const up = () => { view.dragging = false; container.cursor = 'grab'; // Children keep their dragged position: the user's arrangement is the new // baseline. Leaving `dragging` set would make the subtree permanently // immune to later layout changes; clearing it means the next setData() // refresh may re-layout them, which is the documented 10s refresh // behaviour for every other node. for (const k of this.drag ? this.drag.kids : []) k.view.dragging = false; this.drag = null; window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); this._redrawForDrag(); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); } _redrawForDrag() { // A dragged node's position is user intent, not layout, so re-derive the // edge/attack endpoints from the live node transforms instead of calling // setData (which would snap the node back to its computed slot). const pos = {}; for (const [id, view] of this.nodeViews) pos[id] = { x: view.c.x, y: view.c.y }; for (const id of ['dns', 'traefik', 'panel']) { if (this.nodeViews.get(id)) pos[id] = { x: this.nodeViews.get(id).c.x, y: this.nodeViews.get(id).c.y }; } this.pos = pos; this._redrawEdges(pos); this._redrawAttacks(pos); } _redrawEdges(pos) { const PIXI = this.PIXI; // Edges are fully derived from node positions, so they are rebuilt when a // node moves. But the objects are NOT thrown away each time: every new // PIXI.Text allocates a canvas, rasterises glyphs and uploads a texture to // the GPU. Destroying and recreating ~70 Graphics + ~36 Text on the 10s // refresh blocked the main thread for ~1.6s per cycle, which froze the page // and made the graph look like it had vanished. Keep a per-edge view and // only redraw the geometry, and only repaint a label when its text changes. const seen = new Set(); for (const e of this.edges) { const a = pos[e.from], b = pos[e.to]; if (!a || !b) continue; const key = `${e.from}->${e.to}`; seen.add(key); let view = this.edgeViews.get(key); if (!view) { const line = new PIXI.Graphics(); line.eventMode = 'none'; const label = e.label ? new PIXI.Text({ text: e.label, style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace' }, }) : null; if (label) { label.anchor.set(0.5); label.eventMode = 'none'; } this.edgeLayer.addChild(line); if (label) this.edgeLayer.addChild(label); view = { line, label, labelText: label ? e.label : null }; this.edgeViews.set(key, view); } view.line.clear().moveTo(a.x, a.y).lineTo(b.x, b.y) .stroke({ width: 1.5, color: THEME.edge, alpha: 1 }); if (view.label) { if (view.labelText !== e.label) { view.label.text = e.label; view.labelText = e.label; } view.label.position.set((a.x + b.x) / 2, (a.y + b.y) / 2 - 6); } } for (const [key, view] of [...this.edgeViews]) { if (seen.has(key)) continue; view.line.destroy(); if (view.label) view.label.destroy(); this.edgeViews.delete(key); } } _redrawAttacks(pos) { for (const [k, view] of this.attackViews) { const a = pos['team' + k.split(':')[0]], b = pos['team' + k.split(':')[1]]; if (!a || !b) continue; view.color = view.ok > 0 ? THEME.attackOk : THEME.attackFail; view.line.clear() .moveTo(a.x, a.y).lineTo(b.x, b.y) .stroke({ width: 2, color: view.color, alpha: 0.9 }); view.label.position.set((a.x + b.x) / 2 + 8, (a.y + b.y) / 2 - 10); } } toWorld(ev) { const rect = this.app.canvas.getBoundingClientRect(); return { x: (ev.clientX - rect.left - this.panX) / this.scale, y: (ev.clientY - rect.top - this.panY) / this.scale, }; } zoomAt(ev, factor) { const rect = this.app.canvas.getBoundingClientRect(); const mx = ev.clientX - rect.left, my = ev.clientY - rect.top; const before = { x: (mx - this.panX) / this.scale, y: (my - this.panY) / this.scale }; const next = Math.max(0.2, Math.min(4, this.scale * (1 - factor * 12))); this.scale = next; // Keep the point under the cursor fixed while scaling. this.panX = mx - before.x * this.scale; this.panY = my - before.y * this.scale; this.applyView(); } zoomBy(factor) { this.scale = Math.max(0.2, Math.min(4, this.scale * factor)); this.applyView(); } reset() { // "Reset" means "show me the whole graph", not "scale=1, pan=0" — the latter // is only correct while the layout fits the viewport, and with many teams it // does not. Frame-to-fit is what the button actually promises. if (this.nodeViews.size) this.fit(); else { this.scale = 1; this.panX = 0; this.panY = 0; this.applyView(); } } _onResize() { if (this.nodeViews.size) this.fit(); } applyView() { // Called on every pan, zoom, drag and refit: repaint synchronously so the // view tracks the pointer even with the ticker parked. this.requestRender(); if (!this.app || !this.world) return; this.world.position.set(this.panX || 0, this.panY || 0); this.world.scale.set(this.scale); const lbl = document.getElementById('topoZoomLabel'); if (lbl) lbl.textContent = Math.round(this.scale * 100) + '%'; } }