Fix the topology freezing and make team drags carry their challenges

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.
This commit is contained in:
root
2026-09-27 01:17:56 +08:00
parent 30f8052e79
commit cf47770798
6 changed files with 573 additions and 59 deletions
+43 -17
View File
@@ -549,8 +549,6 @@ async function viewCred(ch) {
} catch (e) { toast(e.message, true); }
}
// ---------- Topology ----------
let topoLoaded = false;
// ---------- Topology (PixiJS v8) ----------
// The renderer lives in topo_pixi.js as an ES module so the ~810 KB Pixi bundle is
// only parsed when this tab is actually opened. These functions are the thin
@@ -558,6 +556,17 @@ let topoLoaded = false;
let topoAttacks = [];
let topoGraph = null;
let topoModule = null;
// Single-flight guard. The Topology tab button calls `showView('topo'); loadTopo()`
// and showView() also calls loadTopo() for this view, so two loadTopo() run
// concurrently on every click. Previously the re-entry check was
// `if (topoGraph && topoGraph.app)`, but `topoGraph` was assigned BEFORE the
// awaited init(), so `app` was still null and the second call built a SECOND
// renderer. Both ran `host.innerHTML = ''` and appended their own canvas, so
// whichever init() finished last won the DOM while `topoGraph` still referenced
// the other — a live canvas that was no longer on the page. Track the in-flight
// promise so every caller awaits the same init.
let topoInitPromise = null;
let topoLoadPromise = null;
async function topoEngine() {
if (!topoModule) topoModule = await import('/static/topo_pixi.js');
@@ -568,24 +577,41 @@ async function ensureTopoGraph() {
const host = document.getElementById('topoHost');
if (!host) return null;
if (topoGraph && topoGraph.app) return topoGraph;
const mod = await topoEngine();
topoGraph = new mod.TopoGraph(host);
await topoGraph.init();
const lbl = document.getElementById('topoEngine');
if (lbl) lbl.textContent = '· rendered with PixiJS v8';
return topoGraph;
if (topoInitPromise) return topoInitPromise; // an init is already running
topoInitPromise = (async () => {
try {
const mod = await topoEngine();
const g = new mod.TopoGraph(host);
await g.init();
topoGraph = g; // publish only when ready
const lbl = document.getElementById('topoEngine');
if (lbl) lbl.textContent = '· rendered with PixiJS v8';
return g;
} finally {
topoInitPromise = null;
}
})();
return topoInitPromise;
}
async function loadTopo() {
try {
const g = await ensureTopoGraph();
if (!g) return;
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
topoAttacks = a.events || [];
g.setData(d.nodes, d.edges, topoAttacks);
} catch (e) {
toast('Topologi gagal: ' + e.message, true);
}
// The 10s timer plus the tab click both land here; serialise so two refreshes
// never interleave a scene rebuild.
if (topoLoadPromise) return topoLoadPromise;
topoLoadPromise = (async () => {
try {
const g = await ensureTopoGraph();
if (!g) return;
const [d, a] = await Promise.all([api('/api/topology'), api('/api/attacks').catch(() => ({events: []}))]);
topoAttacks = a.events || [];
g.setData(d.nodes, d.edges, topoAttacks);
} catch (e) {
toast('Topologi gagal: ' + e.message, true);
} finally {
topoLoadPromise = null;
}
})();
return topoLoadPromise;
}
function topoZoom(factor) {
+205 -42
View File
@@ -57,6 +57,8 @@ export class TopoGraph {
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;
@@ -110,18 +112,25 @@ export class TopoGraph {
this.world.addChild(this.edgeLayer, this.attackLayer, this.nodeLayer);
this._bindPointer();
// The attack pulse is the only per-frame work; everything else is redrawn
// on data refresh, so keep the ticker cheap.
// 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();
}
@@ -199,6 +208,36 @@ export class TopoGraph {
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);
@@ -207,6 +246,9 @@ export class TopoGraph {
// 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) {
@@ -245,29 +287,52 @@ export class TopoGraph {
const theme = this._themeFor(n);
const r = theme.r;
const fillAlpha = theme.alpha ?? 1;
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 });
view.title.style.fill = theme.color;
view.title.style.fontSize = n.type === 'team' ? 12 : 10;
view.title.text = n.label;
const sub = n.type === 'team' ? `:${n.receiver_port}` : (n.port ? `:${n.port}` : '');
view.sub.text = sub || '';
view.sub.visible = !!sub;
view.title.y = sub ? 0 : -4;
view.sub.y = 12;
// 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.c.position.set(p.x, p.y);
view.dot.clear();
if (n.status === 'running') {
view.dot.circle(r - 8, -r + 8, 5).fill({ color: THEME.running });
if (!view.dragging && (view.posKey !== `${p.x},${p.y}`)) {
view.posKey = `${p.x},${p.y}`;
view.c.position.set(p.x, p.y);
}
if (n.tooltip) {
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));
}
@@ -342,20 +407,64 @@ export class TopoGraph {
}
// ---- 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) {
// Throttle to ~30fps: the ticker runs even when the tab is hidden and this
// host only has 2 cores. 33ms budget between alpha updates.
this.ticker += deltaMS;
if (this.ticker < 33) return;
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 30x a second for nothing.
// so we are not re-tessellating every frame for nothing.
const wantDash = hot ? '6 3' : '4 4';
if (view.dash !== wantDash) {
view.dash = wantDash;
@@ -368,6 +477,8 @@ export class TopoGraph {
}
}
}
// Nothing left to animate: stop paying for frames.
if (!stillAnimating) this._stopTicking();
}
// ---- pan / zoom / drag -------------------------------------------------
@@ -409,10 +520,31 @@ export class TopoGraph {
const world = this.toWorld(ev);
view.dragging = true;
container.cursor = 'grabbing';
this.drag = { view, dx: container.x - world.x, dy: container.y - world.y };
// 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();
@@ -420,6 +552,12 @@ export class TopoGraph {
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);
@@ -445,25 +583,47 @@ export class TopoGraph {
_redrawEdges(pos) {
const PIXI = this.PIXI;
this.edgeLayer.removeChildren().forEach(c => c.destroy({ children: true }));
// 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 g = new PIXI.Graphics();
g.moveTo(a.x, a.y).lineTo(b.x, b.y)
.stroke({ width: 1.5, color: THEME.edge, alpha: 1 });
g.eventMode = 'none';
this.edgeLayer.addChild(g);
if (e.label) {
const t = new PIXI.Text({
text: e.label,
style: { fill: THEME.edgeLabel, fontSize: 9, fontFamily: 'ui-monospace, monospace' },
});
t.anchor.set(0.5);
t.position.set((a.x + b.x) / 2, (a.y + b.y) / 2 - 6);
t.eventMode = 'none';
this.edgeLayer.addChild(t);
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);
}
}
@@ -517,6 +677,9 @@ export class TopoGraph {
}
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);