mirror of
https://github.com/penpot/penpot.git
synced 2026-08-09 06:18:47 +00:00
⚡ 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:
parent
186dfb72b0
commit
3c8e8da495
@ -151,18 +151,24 @@ Graph Console
|
||||
<fieldset id="graph-view-panel">
|
||||
<legend>Graph view
|
||||
<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>
|
||||
<desc>
|
||||
Renders the in-memory Ladybug graph with AntV G6 (CDN). Node color
|
||||
and glyph encode the node table; edges are <code>IsChildOf</code>
|
||||
(arrow points to parent). Containers render as foldable boxes:
|
||||
double-click a box to collapse or expand it. Redraws automatically
|
||||
after live changes (fold state survives redraws).
|
||||
(arrow points to parent). With "fold containers" on, anything with
|
||||
children (except the root) renders as a foldable box: double-click
|
||||
to collapse or expand. Redraws automatically after live changes
|
||||
(fold state survives redraws). Very large graphs need an explicit
|
||||
"Render anyway".
|
||||
</desc>
|
||||
<div id="graph-legend" style="font-size: 12px; margin: 4px 0;"></div>
|
||||
<div id="graph-canvas"
|
||||
style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff;"></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>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -294,16 +300,17 @@ Graph Console
|
||||
"Image": { color: "#eda100", glyph: "star", size: 14 }
|
||||
};
|
||||
const FALLBACK_STYLE = { color: "#767470", glyph: "circle", size: 14 };
|
||||
// Containers become foldable G6 combos (Document excluded: folding the
|
||||
// whole graph is useless). A combo holds the container node itself plus
|
||||
// its direct children; nesting mirrors the IsChildOf tree.
|
||||
const CONTAINER_TABLES = {
|
||||
"Page": true, "Frame": true, "Group": true, "Boolean": true, "SVGRaw": true
|
||||
};
|
||||
// Above this many nodes the view is not rendered automatically; the
|
||||
// "Render anyway" button forces it (~1.5 s per 2k nodes, measured).
|
||||
const RENDER_GUARD_NODES = 4000;
|
||||
const LAYOUT_XS = 40; // horizontal leaf slot spacing
|
||||
const LAYOUT_YS = 70; // vertical rank (depth) spacing
|
||||
|
||||
const graphViewStatus = document.getElementById("graph-view-status");
|
||||
let g6graph = null;
|
||||
let refetchTimer = null;
|
||||
let lastGraphData = null;
|
||||
let renderForced = false;
|
||||
|
||||
function nodeStyle(table) {
|
||||
return NODE_STYLES[table] || FALLBACK_STYLE;
|
||||
@ -313,22 +320,57 @@ Graph Console
|
||||
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 edges = (data.edges || [])
|
||||
.slice()
|
||||
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); });
|
||||
const parentOf = {};
|
||||
const childCount = {};
|
||||
const childrenOf = {};
|
||||
edges.forEach(function (e) {
|
||||
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 = {};
|
||||
nodes.forEach(function (n) {
|
||||
if (CONTAINER_TABLES[n.table] && childCount[n.id]) hasCombo[n.id] = true;
|
||||
});
|
||||
if (withCombos) {
|
||||
nodes.forEach(function (n) {
|
||||
if ((childrenOf[n.id] || []).length && parentOf[n.id]) hasCombo[n.id] = true;
|
||||
});
|
||||
}
|
||||
const combos = [];
|
||||
nodes.forEach(function (n) {
|
||||
if (!hasCombo[n.id]) return;
|
||||
@ -339,7 +381,10 @@ Graph Console
|
||||
combos.push(combo);
|
||||
});
|
||||
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]) {
|
||||
out.combo = comboIdFor(n.id);
|
||||
} else if (parentOf[n.id] && hasCombo[parentOf[n.id]]) {
|
||||
@ -374,8 +419,10 @@ Graph Console
|
||||
data: g6data,
|
||||
autoFit: "view",
|
||||
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,
|
||||
layout: { type: "antv-dagre", rankdir: "BT", nodesep: 10, ranksep: 40, sortByCombo: true },
|
||||
node: {
|
||||
type: function (d) { return nodeStyle(d.data.table).glyph; },
|
||||
style: {
|
||||
@ -422,6 +469,27 @@ Graph Console
|
||||
}).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() {
|
||||
if (typeof G6 === "undefined") {
|
||||
graphViewStatus.textContent = "G6 failed to load (CDN unreachable)";
|
||||
@ -433,11 +501,8 @@ Graph Console
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
graphViewStatus.textContent =
|
||||
data.nodes.length + " nodes, " + data.edges.length + " edges"
|
||||
+ " (graph revn " + data.revn + ")"
|
||||
+ (data.truncated ? " — WARNING: truncated at row cap, view is partial" : "");
|
||||
return renderGraph(toG6Data(data, collapsedComboIds()));
|
||||
lastGraphData = data;
|
||||
renderCurrent();
|
||||
})
|
||||
.catch(function (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();
|
||||
refreshSyncStatus();
|
||||
renderGraphLegend();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user