Fix graph view freeze on large files; add fold toggle and root rule

Root cause of the tab freeze on ~1700-node files was G6's default entrance animation: measured 1700 nodes at >2 min animated vs 1.5 s with animation: false. Secondary cost was antv-dagre (~7 s at that size); since IsChildOf is a tree, an O(n) tidy layout (depth = rank, post-order leaf slots, parents centered) computed client-side replaces it and renders the same file in ~1.4 s. A guard skips auto-render above 4000 nodes with an explicit Render-anyway button, so opening the console with a huge session loaded stays responsive.

Folding is now switchable ('fold containers' checkbox, persisted in localStorage) and generalized: any node with children folds except the IsChildOf root of the loaded graph, so Documents (and later Projects/Teams) fold automatically once they gain a parent node.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-07-15 23:18:30 +02:00
parent 186dfb72b0
commit 3c8e8da495
No known key found for this signature in database

View File

@ -151,18 +151,24 @@ Graph Console
<fieldset id="graph-view-panel"> <fieldset id="graph-view-panel">
<legend>Graph view <legend>Graph view
<button type="button" id="graph-maximize" style="margin-left: 1em;">Expand</button> <button type="button" id="graph-maximize" style="margin-left: 1em;">Expand</button>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;">
<input type="checkbox" id="graph-fold-toggle" /> fold containers
</label>
</legend> </legend>
<desc> <desc>
Renders the in-memory Ladybug graph with AntV G6 (CDN). Node color Renders the in-memory Ladybug graph with AntV G6 (CDN). Node color
and glyph encode the node table; edges are <code>IsChildOf</code> and glyph encode the node table; edges are <code>IsChildOf</code>
(arrow points to parent). Containers render as foldable boxes: (arrow points to parent). With "fold containers" on, anything with
double-click a box to collapse or expand it. Redraws automatically children (except the root) renders as a foldable box: double-click
after live changes (fold state survives redraws). to collapse or expand. Redraws automatically after live changes
(fold state survives redraws). Very large graphs need an explicit
"Render anyway".
</desc> </desc>
<div id="graph-legend" style="font-size: 12px; margin: 4px 0;"></div> <div id="graph-legend" style="font-size: 12px; margin: 4px 0;"></div>
<div id="graph-canvas" <div id="graph-canvas"
style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff;"></div> style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff;"></div>
<div id="graph-view-status" style="color: #666; font-size: 12px;"></div> <div id="graph-view-status" style="color: #666; font-size: 12px;"></div>
<button type="button" id="graph-render-anyway" style="display: none;">Render anyway</button>
</fieldset> </fieldset>
</div> </div>
{% endif %} {% endif %}
@ -294,16 +300,17 @@ Graph Console
"Image": { color: "#eda100", glyph: "star", size: 14 } "Image": { color: "#eda100", glyph: "star", size: 14 }
}; };
const FALLBACK_STYLE = { color: "#767470", glyph: "circle", size: 14 }; const FALLBACK_STYLE = { color: "#767470", glyph: "circle", size: 14 };
// Containers become foldable G6 combos (Document excluded: folding the // Above this many nodes the view is not rendered automatically; the
// whole graph is useless). A combo holds the container node itself plus // "Render anyway" button forces it (~1.5 s per 2k nodes, measured).
// its direct children; nesting mirrors the IsChildOf tree. const RENDER_GUARD_NODES = 4000;
const CONTAINER_TABLES = { const LAYOUT_XS = 40; // horizontal leaf slot spacing
"Page": true, "Frame": true, "Group": true, "Boolean": true, "SVGRaw": true const LAYOUT_YS = 70; // vertical rank (depth) spacing
};
const graphViewStatus = document.getElementById("graph-view-status"); const graphViewStatus = document.getElementById("graph-view-status");
let g6graph = null; let g6graph = null;
let refetchTimer = null; let refetchTimer = null;
let lastGraphData = null;
let renderForced = false;
function nodeStyle(table) { function nodeStyle(table) {
return NODE_STYLES[table] || FALLBACK_STYLE; return NODE_STYLES[table] || FALLBACK_STYLE;
@ -313,22 +320,57 @@ Graph Console
return "combo:" + nodeId; return "combo:" + nodeId;
} }
function toG6Data(data, collapsedIds) { function foldEnabled() {
return localStorage.getItem("graph-fold-containers") !== "0";
}
// IsChildOf is a tree, so an O(n) tidy layout replaces a generic DAG
// layout: depth = rank (y), post-order leaf slots = x, parents centered
// over their children. antv-dagre needed ~7 s at 1700 nodes; this is free.
function treePositions(nodes, parentOf, childrenOf) {
const pos = {};
let slot = 0;
function place(id, depth) {
const kids = childrenOf[id] || [];
if (!kids.length) {
pos[id] = { x: slot * LAYOUT_XS, y: depth * LAYOUT_YS };
slot += 1;
return;
}
kids.forEach(function (k) { place(k, depth + 1); });
const xs = kids.map(function (k) { return pos[k].x; });
pos[id] = { x: (Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2,
y: depth * LAYOUT_YS };
slot += 1; // breathing room between adjacent subtrees
}
nodes.forEach(function (n) {
if (!parentOf[n.id]) place(n.id, 0);
});
return pos;
}
function toG6Data(data, collapsedIds, withCombos) {
const nodes = data.nodes || []; const nodes = data.nodes || [];
const edges = (data.edges || []) const edges = (data.edges || [])
.slice() .slice()
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); }); .sort(function (a, b) { return (a.position || 0) - (b.position || 0); });
const parentOf = {}; const parentOf = {};
const childCount = {}; const childrenOf = {};
edges.forEach(function (e) { edges.forEach(function (e) {
parentOf[e.source] = e.target; parentOf[e.source] = e.target;
childCount[e.target] = (childCount[e.target] || 0) + 1; (childrenOf[e.target] = childrenOf[e.target] || []).push(e.source);
}); });
// Only containers with children get a combo: empty boxes are clutter. const pos = treePositions(nodes, parentOf, childrenOf);
// Foldable = has children and has a parent: the IsChildOf root of the
// loaded graph (today a Document, later maybe a Project or Team) is
// never a combo — folding the whole graph is useless. Empty containers
// stay plain nodes.
const hasCombo = {}; const hasCombo = {};
nodes.forEach(function (n) { if (withCombos) {
if (CONTAINER_TABLES[n.table] && childCount[n.id]) hasCombo[n.id] = true; nodes.forEach(function (n) {
}); if ((childrenOf[n.id] || []).length && parentOf[n.id]) hasCombo[n.id] = true;
});
}
const combos = []; const combos = [];
nodes.forEach(function (n) { nodes.forEach(function (n) {
if (!hasCombo[n.id]) return; if (!hasCombo[n.id]) return;
@ -339,7 +381,10 @@ Graph Console
combos.push(combo); combos.push(combo);
}); });
const g6nodes = nodes.map(function (n) { const g6nodes = nodes.map(function (n) {
const out = { id: n.id, data: { label: n.label, table: n.table } }; const p = pos[n.id] || { x: 0, y: 0 };
const out = { id: n.id,
data: { label: n.label, table: n.table },
style: { x: p.x, y: p.y } };
if (hasCombo[n.id]) { if (hasCombo[n.id]) {
out.combo = comboIdFor(n.id); out.combo = comboIdFor(n.id);
} else if (parentOf[n.id] && hasCombo[parentOf[n.id]]) { } else if (parentOf[n.id] && hasCombo[parentOf[n.id]]) {
@ -374,8 +419,10 @@ Graph Console
data: g6data, data: g6data,
autoFit: "view", autoFit: "view",
autoResize: true, autoResize: true,
// Entrance/update animation is the performance killer: 1700 nodes
// took >2 min animated vs ~1.5 s without (measured, headless).
animation: false,
padding: 20, padding: 20,
layout: { type: "antv-dagre", rankdir: "BT", nodesep: 10, ranksep: 40, sortByCombo: true },
node: { node: {
type: function (d) { return nodeStyle(d.data.table).glyph; }, type: function (d) { return nodeStyle(d.data.table).glyph; },
style: { style: {
@ -422,6 +469,27 @@ Graph Console
}).join(""); }).join("");
} }
function graphStatusText(data) {
return data.nodes.length + " nodes, " + data.edges.length + " edges"
+ " (graph revn " + data.revn + ")"
+ (data.truncated ? " — WARNING: truncated at row cap, view is partial" : "");
}
function renderCurrent() {
if (!lastGraphData) return;
const data = lastGraphData;
const anywayBtn = document.getElementById("graph-render-anyway");
if (data.nodes.length > RENDER_GUARD_NODES && !renderForced) {
graphViewStatus.textContent =
graphStatusText(data) + " — too large to render automatically";
if (anywayBtn) anywayBtn.style.display = "inline";
return;
}
if (anywayBtn) anywayBtn.style.display = "none";
graphViewStatus.textContent = graphStatusText(data);
renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled()));
}
function refetchGraph() { function refetchGraph() {
if (typeof G6 === "undefined") { if (typeof G6 === "undefined") {
graphViewStatus.textContent = "G6 failed to load (CDN unreachable)"; graphViewStatus.textContent = "G6 failed to load (CDN unreachable)";
@ -433,11 +501,8 @@ Graph Console
return resp.json(); return resp.json();
}) })
.then(function (data) { .then(function (data) {
graphViewStatus.textContent = lastGraphData = data;
data.nodes.length + " nodes, " + data.edges.length + " edges" renderCurrent();
+ " (graph revn " + data.revn + ")"
+ (data.truncated ? " — WARNING: truncated at row cap, view is partial" : "");
return renderGraph(toG6Data(data, collapsedComboIds()));
}) })
.catch(function (err) { .catch(function (err) {
graphViewStatus.textContent = "graph view error: " + err; graphViewStatus.textContent = "graph view error: " + err;
@ -710,6 +775,23 @@ Graph Console
}); });
} }
const foldToggle = document.getElementById("graph-fold-toggle");
if (foldToggle) {
foldToggle.checked = foldEnabled();
foldToggle.addEventListener("change", function () {
localStorage.setItem("graph-fold-containers", foldToggle.checked ? "1" : "0");
renderCurrent();
});
}
const renderAnywayBtn = document.getElementById("graph-render-anyway");
if (renderAnywayBtn) {
renderAnywayBtn.addEventListener("click", function () {
renderForced = true;
renderCurrent();
});
}
connect(); connect();
refreshSyncStatus(); refreshSyncStatus();
renderGraphLegend(); renderGraphLegend();