Add graph toolbar, animate toggle, filter columns, repaint skip

Graph view gains an on-canvas G6 toolbar (auto-fit, expand, restore - the fullscreen icons drive the existing in-page expand), an 'animate' checkbox that disables animation unconditionally when off (persisted, adaptive <=100-node rule applies only when on), and a ResizeObserver on the canvas so the panel follows window/flex resizes without touching the user's viewport. Preset tree positions are now only injected for the built-in tree layout, removing the tree-then-layout flash on animated re-renders under G6 layouts. Refetches skip the repaint when the display projection (nodes, edges, truncated) is byte-identical, so attribute-only change bursts no longer repaint.

Console: default query returns s/t name+label over all edges plus filter_src_id/filter_tgt_id columns; filter_* columns are hidden from the results table (client and server render) but still feed the 'Show result in graph view' id harvest, keeping the table legible while the graph filter stays available. The query text persists in localStorage across page reloads (restored only over the default, never over a server-rendered query). Legend shows colored Unicode glyphs matching node shapes instead of squares with textual annotations. Load/Unload buttons share one row (HTML5 form attribute), and the loaded file name links to the Penpot workspace via the legacy /#/workspace/<project-id>/<file-id> route resolved client-side from the files-tree payload.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-07-16 09:18:06 +02:00 committed by Andrey Antukh
parent bb5be56b6c
commit eeb7e2481e
2 changed files with 160 additions and 27 deletions

View File

@ -29,16 +29,15 @@ Graph Console
placeholder="file-id" placeholder="file-id"
value="{% if session %}{{session.file-id}}{% endif %}" /> value="{% if session %}{{session.file-id}}{% endif %}" />
</div> </div>
<div class="row"> <div class="row" style="display: flex; gap: 8px;">
<input type="submit" value="Load" /> <input type="submit" value="Load" />
{% if session %}
<input type="submit" value="Unload" form="graph-unload-form" />
{% endif %}
</div> </div>
</form> </form>
{% if session %} {% if session %}
<form method="post" action="/dbg/actions/graph-unload"> <form id="graph-unload-form" method="post" action="/dbg/actions/graph-unload"></form>
<div class="row">
<input type="submit" value="Unload" />
</div>
</form>
{% endif %} {% endif %}
</fieldset> </fieldset>
@ -53,7 +52,8 @@ Graph Console
<legend>Loaded session</legend> <legend>Loaded session</legend>
<desc> <desc>
<p> <p>
File: <b>{{session.name}}</b> ({{session.file-id}})<br /> File: <b><a id="graph-penpot-link" data-file-id="{{session.file-id}}"
target="_blank">{{session.name}}</a></b> ({{session.file-id}})<br />
Loaded at revision: <b>{{session.revn}}</b><br /> Loaded at revision: <b>{{session.revn}}</b><br />
Graph revision: <b id="graph-sync-revn">{% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %}</b><br /> Graph revision: <b id="graph-sync-revn">{% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %}</b><br />
Schema: <b>{{session.schema-version}}</b><br /> Schema: <b>{{session.schema-version}}</b><br />
@ -101,7 +101,8 @@ Graph Console
<legend>Cypher query</legend> <legend>Cypher query</legend>
<form id="graph-query-form" method="post" action="/dbg/actions/graph-query"> <form id="graph-query-form" method="post" action="/dbg/actions/graph-query">
<div class="row"> <div class="row">
<textarea name="query" rows="8" style="width:100%; font-family: monospace;">{{query}}</textarea> <textarea name="query" rows="8" style="width:100%; font-family: monospace;"
data-default-query="{{default-query}}">{{query}}</textarea>
</div> </div>
<div class="row"> <div class="row">
<input type="submit" value="Run query" /> <input type="submit" value="Run query" />
@ -154,6 +155,9 @@ Graph Console
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"> <label style="margin-left: 1em; font-size: 12px; font-weight: normal;">
<input type="checkbox" id="graph-fold-toggle" /> fold containers <input type="checkbox" id="graph-fold-toggle" /> fold containers
</label> </label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;">
<input type="checkbox" id="graph-animate-toggle" /> animate
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"> <label style="margin-left: 1em; font-size: 12px; font-weight: normal;">
layout: <select id="graph-layout-select"></select> layout: <select id="graph-layout-select"></select>
</label> </label>
@ -209,8 +213,28 @@ Graph Console
const tree = document.getElementById("graph-files-tree"); const tree = document.getElementById("graph-files-tree");
const loadForm = document.getElementById("graph-load-form"); const loadForm = document.getElementById("graph-load-form");
const loadInput = loadForm ? loadForm.querySelector("input[name=file-id]") : null; const loadInput = loadForm ? loadForm.querySelector("input[name=file-id]") : null;
const penpotLink = document.getElementById("graph-penpot-link");
if (!tree) return; if (!tree) return;
// Make the loaded-session file name a link into the Penpot workspace.
// The legacy /#/workspace/<project-id>/<file-id> route resolves the team
// itself, and project-id is already in the files-tree payload; same
// origin as this page, so no base URL to configure.
function linkLoadedFile(teams) {
if (!penpotLink || !penpotLink.dataset.fileId) return;
const fileId = penpotLink.dataset.fileId;
teams.forEach(function (team) {
(team.projects || []).forEach(function (project) {
(project.files || []).forEach(function (file) {
if (file.id === fileId) {
penpotLink.href = "/#/workspace/" + project.id + "/" + file.id;
penpotLink.title = "Open in Penpot";
}
});
});
});
}
function fileLink(file) { function fileLink(file) {
const li = document.createElement("li"); const li = document.createElement("li");
const a = document.createElement("a"); const a = document.createElement("a");
@ -236,6 +260,7 @@ Graph Console
.then(function (data) { .then(function (data) {
tree.textContent = ""; tree.textContent = "";
const teams = data.teams || []; const teams = data.teams || [];
linkLoadedFile(teams);
if (!teams.length) { if (!teams.length) {
tree.textContent = "No files found."; tree.textContent = "No files found.";
return; return;
@ -304,6 +329,9 @@ 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 };
// Legend glyph characters mirroring the G6 node types above.
const GLYPH_CHARS = { diamond: "◆", rect: "■", hexagon: "⬢",
circle: "●", triangle: "▲", star: "★" };
// Above this many nodes the view is not rendered automatically; the // Above this many nodes the view is not rendered automatically; the
// "Render anyway" button forces it (~1.5 s per 2k nodes, measured). // "Render anyway" button forces it (~1.5 s per 2k nodes, measured).
const RENDER_GUARD_NODES = 4000; const RENDER_GUARD_NODES = 4000;
@ -346,6 +374,7 @@ Graph Console
let g6graphAnimated = null; let g6graphAnimated = null;
let refetchTimer = null; let refetchTimer = null;
let lastGraphData = null; let lastGraphData = null;
let lastGraphSig = null;
let renderForced = false; let renderForced = false;
let graphFilterIds = null; let graphFilterIds = null;
@ -366,6 +395,12 @@ Graph Console
return localStorage.getItem("graph-fold-containers") !== "0"; return localStorage.getItem("graph-fold-containers") !== "0";
} }
// Off ⇒ animation disabled unconditionally, incl. small graphs (the
// adaptive ≤ ANIMATE_MAX_NODES rule only applies when this is on).
function animateEnabled() {
return localStorage.getItem("graph-animate") !== "0";
}
// IsChildOf is a tree, so an O(n) tidy layout replaces a generic DAG // 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 // 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. // over their children. antv-dagre needed ~7 s at 1700 nodes; this is free.
@ -391,7 +426,7 @@ Graph Console
return pos; return pos;
} }
function toG6Data(data, collapsedIds, withCombos) { function toG6Data(data, collapsedIds, withCombos, presetPositions) {
const nodes = data.nodes || []; const nodes = data.nodes || [];
const edges = (data.edges || []) const edges = (data.edges || [])
.slice() .slice()
@ -402,7 +437,9 @@ Graph Console
parentOf[e.source] = e.target; parentOf[e.source] = e.target;
(childrenOf[e.target] = childrenOf[e.target] || []).push(e.source); (childrenOf[e.target] = childrenOf[e.target] || []).push(e.source);
}); });
const pos = treePositions(nodes, parentOf, childrenOf); // Preset positions only for the built-in tree layout; under a G6
// layout they would make animated renders flash tree-then-layout.
const pos = presetPositions ? treePositions(nodes, parentOf, childrenOf) : null;
// Foldable = has children and has a parent: the IsChildOf root of the // Foldable = has children and has a parent: the IsChildOf root of the
// loaded graph (today a Document, later maybe a Project or Team) is // loaded graph (today a Document, later maybe a Project or Team) is
// never a combo — folding the whole graph is useless. Empty containers // never a combo — folding the whole graph is useless. Empty containers
@ -423,10 +460,12 @@ Graph Console
combos.push(combo); combos.push(combo);
}); });
const g6nodes = nodes.map(function (n) { const g6nodes = nodes.map(function (n) {
const p = pos[n.id] || { x: 0, y: 0 };
const out = { id: n.id, const out = { id: n.id,
data: { label: n.label, table: n.table }, data: { label: n.label, table: n.table } };
style: { x: p.x, y: p.y } }; if (pos) {
const p = pos[n.id] || { x: 0, y: 0 };
out.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]]) {
@ -453,7 +492,7 @@ Graph Console
function renderGraph(g6data) { function renderGraph(g6data) {
const layoutName = currentLayoutName(); const layoutName = currentLayoutName();
const animate = g6data.nodes.length <= ANIMATE_MAX_NODES; const animate = animateEnabled() && g6data.nodes.length <= ANIMATE_MAX_NODES;
if (g6graph && (g6graphLayout !== layoutName || g6graphAnimated !== animate)) { if (g6graph && (g6graphLayout !== layoutName || g6graphAnimated !== animate)) {
// Layout or animation-mode switch: recreate the graph (cheap); // Layout or animation-mode switch: recreate the graph (cheap);
// neither is swappable on a live instance. // neither is swappable on a live instance.
@ -505,7 +544,28 @@ Graph Console
radius: 4 radius: 4
} }
}, },
behaviors: ["zoom-canvas", "drag-canvas", "drag-element", "collapse-expand"] behaviors: ["zoom-canvas", "drag-canvas", "drag-element", "collapse-expand"],
plugins: [{
key: "toolbar",
type: "toolbar",
position: "top-left",
getItems: function () {
return [
{ id: "auto-fit", value: "auto-fit" },
{ id: "request-fullscreen", value: "expand" },
{ id: "exit-fullscreen", value: "restore" }
];
},
onClick: function (value) {
if (value === "auto-fit") {
if (g6graph) g6graph.fitView();
} else if (value === "expand") {
setExpanded(true);
} else if (value === "restore") {
setExpanded(false);
}
}
}]
}; };
if (layoutCfg) opts.layout = layoutCfg; if (layoutCfg) opts.layout = layoutCfg;
g6graph = new G6.Graph(opts); g6graph = new G6.Graph(opts);
@ -518,11 +578,10 @@ Graph Console
const el = document.getElementById("graph-legend"); const el = document.getElementById("graph-legend");
el.innerHTML = Object.keys(NODE_STYLES).map(function (table) { el.innerHTML = Object.keys(NODE_STYLES).map(function (table) {
const s = NODE_STYLES[table]; const s = NODE_STYLES[table];
return '<span style="margin-right: 1em;">' return '<span style="margin-right: 1em; white-space: nowrap;">'
+ '<i style="display: inline-block; width: 10px; height: 10px;' + '<span style="color: ' + s.color + '; font-size: 14px;">'
+ ' margin-right: 3px; border: 1px solid #52514e;' + (GLYPH_CHARS[s.glyph] || "●") + '</span> '
+ ' background: ' + s.color + ';"></i>' + escapeHtml(table) + "</span>";
+ escapeHtml(table) + " (" + s.glyph + ")</span>";
}).join(""); }).join("");
} }
@ -579,7 +638,8 @@ Graph Console
} }
if (anywayBtn) anywayBtn.style.display = "none"; if (anywayBtn) anywayBtn.style.display = "none";
graphViewStatus.textContent = graphStatusText(data) + filterNote; graphViewStatus.textContent = graphStatusText(data) + filterNote;
renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled())); renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled(),
LAYOUTS[currentLayoutName()] == null));
} }
function refetchGraph() { function refetchGraph() {
@ -596,7 +656,13 @@ Graph Console
return resp.json(); return resp.json();
}) })
.then(function (data) { .then(function (data) {
// Skip the repaint when the display projection is unchanged: a
// change burst that only touches non-projected attrs (e.g. moving
// shapes around) bumps revn but not the picture.
const sig = JSON.stringify([data.nodes, data.edges, data.truncated]);
lastGraphData = data; lastGraphData = data;
if (sig === lastGraphSig) return;
lastGraphSig = sig;
renderCurrent(); renderCurrent();
}) })
.catch(function (err) { .catch(function (err) {
@ -813,15 +879,22 @@ Graph Console
+ " style=\"border-collapse: collapse; width: 100%;\">" + " style=\"border-collapse: collapse; width: 100%;\">"
+ "<thead><tr>"; + "<thead><tr>";
(result.columns || []).forEach(function (column) { // filter_* columns feed node ids to the graph-view filter below but
html += "<th>" + escapeHtml(column) + "</th>"; // are hidden from the table (convention; see default query).
const columns = result.columns || [];
const visibleIdx = [];
columns.forEach(function (column, i) {
if (!/^filter_/.test(column)) visibleIdx.push(i);
});
visibleIdx.forEach(function (i) {
html += "<th>" + escapeHtml(columns[i]) + "</th>";
}); });
html += "</tr></thead><tbody>"; html += "</tr></thead><tbody>";
(result.rows || []).forEach(function (row) { (result.rows || []).forEach(function (row) {
html += "<tr>"; html += "<tr>";
row.forEach(function (cell) { visibleIdx.forEach(function (i) {
html += "<td><code>" + escapeHtml(cell) + "</code></td>"; html += "<td><code>" + escapeHtml(row[i]) + "</code></td>";
}); });
html += "</tr>"; html += "</tr>";
}); });
@ -850,6 +923,19 @@ Graph Console
const queryForm = document.getElementById("graph-query-form"); const queryForm = document.getElementById("graph-query-form");
if (queryForm) { if (queryForm) {
// Keep the query text across page reloads (load/unload/full-reload all
// re-render the page with the default query). Only restore over the
// default, never over a server-rendered non-default query.
const queryInput = queryForm.querySelector("textarea[name=query]");
const defaultQuery = (queryInput.dataset.defaultQuery || "").trim();
const storedQuery = localStorage.getItem("graph-query");
if (storedQuery && storedQuery.trim() !== defaultQuery
&& queryInput.value.trim() === defaultQuery) {
queryInput.value = storedQuery;
}
queryInput.addEventListener("input", function () {
localStorage.setItem("graph-query", queryInput.value);
});
queryForm.addEventListener("submit", function (event) { queryForm.addEventListener("submit", function (event) {
event.preventDefault(); event.preventDefault();
const formData = new FormData(queryForm); const formData = new FormData(queryForm);
@ -891,6 +977,25 @@ Graph Console
resizeGraphSoon(); resizeGraphSoon();
} }
// Follow container size (window resizes, flex reflow). Only setSize —
// the user's viewport must persist; explicit re-fit is the toolbar's
// auto-fit (or expand/restore, which do fitView via resizeGraphSoon).
const canvasEl = document.getElementById("graph-canvas");
if (typeof ResizeObserver !== "undefined" && canvasEl) {
let resizeRaf = null;
new ResizeObserver(function () {
if (resizeRaf) return;
resizeRaf = requestAnimationFrame(function () {
resizeRaf = null;
if (g6graph) {
try {
g6graph.setSize(canvasEl.clientWidth, canvasEl.clientHeight);
} catch (_err) { /* instance mid-recreate */ }
}
});
}).observe(canvasEl);
}
if (maximizeBtn && graphColumn) { if (maximizeBtn && graphColumn) {
maximizeBtn.addEventListener("click", function () { maximizeBtn.addEventListener("click", function () {
setExpanded(!graphColumn.classList.contains("graph-view-expanded")); setExpanded(!graphColumn.classList.contains("graph-view-expanded"));
@ -912,6 +1017,15 @@ Graph Console
}); });
} }
const animateToggle = document.getElementById("graph-animate-toggle");
if (animateToggle) {
animateToggle.checked = animateEnabled();
animateToggle.addEventListener("change", function () {
localStorage.setItem("graph-animate", animateToggle.checked ? "1" : "0");
renderCurrent();
});
}
const layoutSelect = document.getElementById("graph-layout-select"); const layoutSelect = document.getElementById("graph-layout-select");
if (layoutSelect) { if (layoutSelect) {
Object.keys(LAYOUTS).forEach(function (name) { Object.keys(LAYOUTS).forEach(function (name) {

View File

@ -24,7 +24,12 @@
(set! *warn-on-reflection* true) (set! *warn-on-reflection* true)
(def default-query (def default-query
"MATCH (n:Document) RETURN n.id AS id, n.name AS name;") "Default console query. The `filter_*` columns carry node ids for the
graph-view result filter; the results table hides them (see
`hide-filter-columns` and the template's `renderQueryOutput`)."
(str "MATCH (s)-[r]->(t) "
"RETURN s.name, label(s) AS src, label(r) AS rel, t.name, label(t) AS tgt, "
"s.id AS filter_src_id, t.id AS filter_tgt_id;"))
(defonce ^:private sessions (defonce ^:private sessions
(atom {})) (atom {}))
@ -236,12 +241,26 @@
:nodes nodes :nodes nodes
:edges edges})))) :edges edges}))))
(defn- hide-filter-columns
"Drop `filter_*` columns from a query result before HTML table render;
they exist to feed node ids to the graph-view filter, not for reading.
The JSON response path keeps the full result."
[{:keys [columns rows] :as result}]
(let [idxs (vec (keep-indexed
(fn [i c] (when-not (str/starts-with? (str c) "filter_") i))
columns))]
(if (or (empty? idxs) (= (count idxs) (count columns)))
result
(assoc result
:columns (mapv (vec columns) idxs)
:rows (mapv (fn [row] (mapv (vec row) idxs)) rows)))))
(defn console-context (defn console-context
"Build template data for the graph debug console page." "Build template data for the graph debug console page."
[profile-id & {:keys [query query-result error message]}] [profile-id & {:keys [query query-result error message]}]
{:session (session-info profile-id) {:session (session-info profile-id)
:query (or query default-query) :query (or query default-query)
:query-result query-result :query-result (some-> query-result hide-filter-columns)
:error error :error error
:message message :message message
:default-query default-query}) :default-query default-query})