diff --git a/backend/resources/app/templates/graph-console.tmpl b/backend/resources/app/templates/graph-console.tmpl index dd0dfd3316..a65ff17cd0 100644 --- a/backend/resources/app/templates/graph-console.tmpl +++ b/backend/resources/app/templates/graph-console.tmpl @@ -29,16 +29,15 @@ Graph Console placeholder="file-id" value="{% if session %}{{session.file-id}}{% endif %}" /> -
+
+ {% if session %} + + {% endif %}
{% if session %} -
-
- -
-
+
{% endif %} @@ -53,7 +52,8 @@ Graph Console Loaded session

- File: {{session.name}} ({{session.file-id}})
+ File: {{session.name}} ({{session.file-id}})
Loaded at revision: {{session.revn}}
Graph revision: {% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %}
Schema: {{session.schema-version}}
@@ -101,7 +101,8 @@ Graph Console Cypher query

- +
@@ -154,6 +155,9 @@ Graph Console + @@ -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// 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 '' - + '' - + escapeHtml(table) + " (" + s.glyph + ")"; + return '' + + '' + + (GLYPH_CHARS[s.glyph] || "●") + ' ' + + escapeHtml(table) + ""; }).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%;\">" + ""; - (result.columns || []).forEach(function (column) { - html += "" + escapeHtml(column) + ""; + // 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 += "" + escapeHtml(columns[i]) + ""; }); html += ""; (result.rows || []).forEach(function (row) { html += ""; - row.forEach(function (cell) { - html += "" + escapeHtml(cell) + ""; + visibleIdx.forEach(function (i) { + html += "" + escapeHtml(row[i]) + ""; }); html += ""; }); @@ -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) { diff --git a/backend/src/app/graph/debug.clj b/backend/src/app/graph/debug.clj index 18236ac5cc..471695cd35 100644 --- a/backend/src/app/graph/debug.clj +++ b/backend/src/app/graph/debug.clj @@ -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})