User report: the graph "disappeared". Reproduced, and the cause was not the
graph at all — the panel's main thread was blocked hard enough that the browser
stopped responding to clicks.
Root cause, found by measuring rather than guessing:
rAF 2 FPS, setTimeout(0) lag 1353ms, with the graph rendering correctly the
whole time. The scene was repainting continuously at 60fps even though it is
static between the 10s data refreshes. Each repaint cost ~305ms on this
host's software GL, so the main thread never got a free slot.
Fixes:
- Render on demand. The ticker is registered but not started; it runs only while
an attack pulse is in flight or the user is dragging, and stops once the scene
is quiet. applyView() (the single funnel for pan/zoom/drag/refit) repaints
synchronously. Result: 2 FPS -> 73 FPS, 1353ms -> 2ms lag. That is faster than
a blank page in the same harness (29 FPS), which confirms the ticker was the
cost, not the scene complexity.
- Stop rebuilding Pixi objects every refresh. _redrawEdges destroyed and
recreated ~70 Graphics + ~36 Text each cycle; every new Text allocates a
canvas, rasterises glyphs and uploads a texture. Edges are now kept per
from->to key and only their geometry is redrawn; a label's text is re-set only
when the string actually changes. Same for node titles/subtitles.
- Bind tooltip handlers once, at node creation. Re-binding inside the refresh
loop added a pointerover listener every 10s, so a single hover after an hour
fired thousands of handlers.
Two correctness bugs fixed while in there:
- init() race. The Topology tab button calls showView('topo') AND loadTopo(), and
showView() itself calls loadTopo(), so two ran concurrently. The re-entry guard
tested `topoGraph && topoGraph.app`, but topoGraph was assigned BEFORE the
awaited init(), so app was still null and a second renderer was built. Both
cleared host.innerHTML and appended their own canvas, so the last init to
finish won the DOM while the bridge still pointed at the other — a live canvas
that was no longer on the page. Now guarded by a single-flight promise, and
the instance is published only after init() resolves.
- Dropped the dead topoLoaded flag left over from the SVG renderer.
Hierarchical drag: dragging a team now carries its 16 challenge nodes with it.
The parent->children index is built from the edge list, the same source the
lines are drawn from, so the drag hierarchy cannot disagree with the picture;
challenges missing from the edge list still attach by id prefix. Children
translate rigidly (verified: 0.000px deviation across all 16).
Verified from a cold panel restart: all 5 topology tests pass, 73 FPS, 2ms
lag, 37 nodes drawn, graph framed at 4 viewport widths, no page errors, and
platform SLA unregressed at 32/32.
691 lines
28 KiB
JavaScript
691 lines
28 KiB
JavaScript
// ---------------------------------------------------------------------------
|
|
// 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) + '%';
|
|
}
|
|
}
|
|
|