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:
1 parent
30f8052e79
commit
cf47770798
6 files changed
+573
-59
No files matched your search
+43
-17
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user