mirror of
https://github.com/penpot/penpot.git
synced 2026-08-10 14:59:08 +00:00
✨ 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:
parent
cc18dd37db
commit
10035135eb
@ -29,16 +29,15 @@ Graph Console
|
||||
placeholder="file-id"
|
||||
value="{% if session %}{{session.file-id}}{% endif %}" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row" style="display: flex; gap: 8px;">
|
||||
<input type="submit" value="Load" />
|
||||
{% if session %}
|
||||
<input type="submit" value="Unload" form="graph-unload-form" />
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% if session %}
|
||||
<form method="post" action="/dbg/actions/graph-unload">
|
||||
<div class="row">
|
||||
<input type="submit" value="Unload" />
|
||||
</div>
|
||||
</form>
|
||||
<form id="graph-unload-form" method="post" action="/dbg/actions/graph-unload"></form>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
|
||||
@ -53,7 +52,8 @@ Graph Console
|
||||
<legend>Loaded session</legend>
|
||||
<desc>
|
||||
<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 />
|
||||
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 />
|
||||
@ -101,7 +101,8 @@ Graph Console
|
||||
<legend>Cypher query</legend>
|
||||
<form id="graph-query-form" method="post" action="/dbg/actions/graph-query">
|
||||
<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 class="row">
|
||||
<input type="submit" value="Run query" />
|
||||
@ -154,6 +155,9 @@ Graph Console
|
||||
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;">
|
||||
<input type="checkbox" id="graph-fold-toggle" /> fold containers
|
||||
</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;">
|
||||
layout: <select id="graph-layout-select"></select>
|
||||
</label>
|
||||
@ -209,8 +213,28 @@ Graph Console
|
||||
const tree = document.getElementById("graph-files-tree");
|
||||
const loadForm = document.getElementById("graph-load-form");
|
||||
const loadInput = loadForm ? loadForm.querySelector("input[name=file-id]") : null;
|
||||
const penpotLink = document.getElementById("graph-penpot-link");
|
||||
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) {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
@ -236,6 +260,7 @@ Graph Console
|
||||
.then(function (data) {
|
||||
tree.textContent = "";
|
||||
const teams = data.teams || [];
|
||||
linkLoadedFile(teams);
|
||||
if (!teams.length) {
|
||||
tree.textContent = "No files found.";
|
||||
return;
|
||||
@ -304,6 +329,9 @@ Graph Console
|
||||
"Image": { color: "#eda100", glyph: "star", 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
|
||||
// "Render anyway" button forces it (~1.5 s per 2k nodes, measured).
|
||||
const RENDER_GUARD_NODES = 4000;
|
||||
@ -346,6 +374,7 @@ Graph Console
|
||||
let g6graphAnimated = null;
|
||||
let refetchTimer = null;
|
||||
let lastGraphData = null;
|
||||
let lastGraphSig = null;
|
||||
let renderForced = false;
|
||||
let graphFilterIds = null;
|
||||
|
||||
@ -366,6 +395,12 @@ Graph Console
|
||||
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
|
||||
// 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.
|
||||
@ -391,7 +426,7 @@ Graph Console
|
||||
return pos;
|
||||
}
|
||||
|
||||
function toG6Data(data, collapsedIds, withCombos) {
|
||||
function toG6Data(data, collapsedIds, withCombos, presetPositions) {
|
||||
const nodes = data.nodes || [];
|
||||
const edges = (data.edges || [])
|
||||
.slice()
|
||||
@ -402,7 +437,9 @@ Graph Console
|
||||
parentOf[e.source] = e.target;
|
||||
(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
|
||||
// loaded graph (today a Document, later maybe a Project or Team) is
|
||||
// never a combo — folding the whole graph is useless. Empty containers
|
||||
@ -423,10 +460,12 @@ Graph Console
|
||||
combos.push(combo);
|
||||
});
|
||||
const g6nodes = nodes.map(function (n) {
|
||||
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 } };
|
||||
data: { label: n.label, table: n.table } };
|
||||
if (pos) {
|
||||
const p = pos[n.id] || { x: 0, y: 0 };
|
||||
out.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]]) {
|
||||
@ -453,7 +492,7 @@ Graph Console
|
||||
|
||||
function renderGraph(g6data) {
|
||||
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)) {
|
||||
// Layout or animation-mode switch: recreate the graph (cheap);
|
||||
// neither is swappable on a live instance.
|
||||
@ -505,7 +544,28 @@ Graph Console
|
||||
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;
|
||||
g6graph = new G6.Graph(opts);
|
||||
@ -518,11 +578,10 @@ Graph Console
|
||||
const el = document.getElementById("graph-legend");
|
||||
el.innerHTML = Object.keys(NODE_STYLES).map(function (table) {
|
||||
const s = NODE_STYLES[table];
|
||||
return '<span style="margin-right: 1em;">'
|
||||
+ '<i style="display: inline-block; width: 10px; height: 10px;'
|
||||
+ ' margin-right: 3px; border: 1px solid #52514e;'
|
||||
+ ' background: ' + s.color + ';"></i>'
|
||||
+ escapeHtml(table) + " (" + s.glyph + ")</span>";
|
||||
return '<span style="margin-right: 1em; white-space: nowrap;">'
|
||||
+ '<span style="color: ' + s.color + '; font-size: 14px;">'
|
||||
+ (GLYPH_CHARS[s.glyph] || "●") + '</span> '
|
||||
+ escapeHtml(table) + "</span>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
@ -579,7 +638,8 @@ Graph Console
|
||||
}
|
||||
if (anywayBtn) anywayBtn.style.display = "none";
|
||||
graphViewStatus.textContent = graphStatusText(data) + filterNote;
|
||||
renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled()));
|
||||
renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled(),
|
||||
LAYOUTS[currentLayoutName()] == null));
|
||||
}
|
||||
|
||||
function refetchGraph() {
|
||||
@ -596,7 +656,13 @@ Graph Console
|
||||
return resp.json();
|
||||
})
|
||||
.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;
|
||||
if (sig === lastGraphSig) return;
|
||||
lastGraphSig = sig;
|
||||
renderCurrent();
|
||||
})
|
||||
.catch(function (err) {
|
||||
@ -813,15 +879,22 @@ Graph Console
|
||||
+ " style=\"border-collapse: collapse; width: 100%;\">"
|
||||
+ "<thead><tr>";
|
||||
|
||||
(result.columns || []).forEach(function (column) {
|
||||
html += "<th>" + escapeHtml(column) + "</th>";
|
||||
// filter_* columns feed node ids to the graph-view filter below but
|
||||
// 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>";
|
||||
|
||||
(result.rows || []).forEach(function (row) {
|
||||
html += "<tr>";
|
||||
row.forEach(function (cell) {
|
||||
html += "<td><code>" + escapeHtml(cell) + "</code></td>";
|
||||
visibleIdx.forEach(function (i) {
|
||||
html += "<td><code>" + escapeHtml(row[i]) + "</code></td>";
|
||||
});
|
||||
html += "</tr>";
|
||||
});
|
||||
@ -850,6 +923,19 @@ Graph Console
|
||||
|
||||
const queryForm = document.getElementById("graph-query-form");
|
||||
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) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(queryForm);
|
||||
@ -891,6 +977,25 @@ Graph Console
|
||||
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) {
|
||||
maximizeBtn.addEventListener("click", function () {
|
||||
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");
|
||||
if (layoutSelect) {
|
||||
Object.keys(LAYOUTS).forEach(function (name) {
|
||||
|
||||
@ -24,7 +24,12 @@
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(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
|
||||
(atom {}))
|
||||
@ -236,12 +241,26 @@
|
||||
:nodes nodes
|
||||
: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
|
||||
"Build template data for the graph debug console page."
|
||||
[profile-id & {:keys [query query-result error message]}]
|
||||
{:session (session-info profile-id)
|
||||
:query (or query default-query)
|
||||
:query-result query-result
|
||||
:query-result (some-> query-result hide-filter-columns)
|
||||
:error error
|
||||
:message message
|
||||
:default-query default-query})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user