mirror of
https://github.com/penpot/penpot.git
synced 2026-08-18 02:39:02 +00:00
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>
1076 lines
37 KiB
Cheetah
1076 lines
37 KiB
Cheetah
{% extends "app/templates/base.tmpl" %}
|
|
|
|
{% block title %}
|
|
Graph Console
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<nav>
|
|
<div class="title">
|
|
<h1>GRAPH CONSOLE (VERSION: {{version}})</h1>
|
|
</div>
|
|
</nav>
|
|
<main class="dashboard">
|
|
<section class="widget" style="max-width: none;">
|
|
<p><a href="/dbg">← Back to debug</a></p>
|
|
|
|
<div style="display: flex; gap: 16px; align-items: flex-start;">
|
|
<div style="flex: 0 0 440px; min-width: 360px;">
|
|
|
|
<fieldset>
|
|
<legend>Load graph in memory</legend>
|
|
<desc>
|
|
Projects the Penpot file into an in-memory Ladybug database for this
|
|
admin session. Loading a new file replaces the previous one.
|
|
</desc>
|
|
<form id="graph-load-form" method="post" action="/dbg/actions/graph-load">
|
|
<div class="row">
|
|
<input type="text" style="width:420px" name="file-id"
|
|
placeholder="file-id"
|
|
value="{% if session %}{{session.file-id}}{% endif %}" />
|
|
</div>
|
|
<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 id="graph-unload-form" method="post" action="/dbg/actions/graph-unload"></form>
|
|
{% endif %}
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>Files</legend>
|
|
<desc>Teams → projects → files. Click a file to load it.</desc>
|
|
<div id="graph-files-tree" style="font-size: 13px;">Loading…</div>
|
|
</fieldset>
|
|
|
|
{% if session %}
|
|
<fieldset>
|
|
<legend>Loaded session</legend>
|
|
<desc>
|
|
<p>
|
|
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 />
|
|
Loaded at: <b>{{session.loaded-at}}</b>
|
|
</p>
|
|
<p id="graph-sync-status">
|
|
Feed: <b id="graph-ws-status">connecting…</b>
|
|
<span id="graph-sync-error" style="display:none; margin-left: 1em; color: #b91c1c;"></span>
|
|
</p>
|
|
<form id="graph-reload-form" method="post" action="/dbg/actions/graph-reload">
|
|
<input type="submit" value="Full reload (fallback)" />
|
|
</form>
|
|
{% if session.projection.stats %}
|
|
<p>
|
|
Projection:
|
|
documents={{session.projection.stats.documents}},
|
|
pages={{session.projection.stats.pages}},
|
|
shapes={{session.projection.stats.shapes}}
|
|
</p>
|
|
{% endif %}
|
|
</desc>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>File changes (live)</legend>
|
|
<desc>
|
|
Subscribes to the workspace WebSocket feed for visibility. The backend
|
|
applies supported changes incrementally to the in-memory Ladybug graph
|
|
via msgbus (<code>:file-change</code>).
|
|
</desc>
|
|
<div id="graph-changelog-empty" style="color: #666;">Waiting for changes…</div>
|
|
<table id="graph-changelog" border="1" cellpadding="4" cellspacing="0"
|
|
style="border-collapse: collapse; width: 100%; display: none;">
|
|
<thead>
|
|
<tr>
|
|
<th>revn</th>
|
|
<th>changes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="graph-changelog-body"></tbody>
|
|
</table>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<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;"
|
|
data-default-query="{{default-query}}">{{query}}</textarea>
|
|
</div>
|
|
<div class="row">
|
|
<input type="submit" value="Run query" />
|
|
</div>
|
|
</form>
|
|
</fieldset>
|
|
|
|
<div id="graph-query-output">
|
|
{% if error %}
|
|
<fieldset>
|
|
<legend>Error</legend>
|
|
<pre>{{error}}</pre>
|
|
</fieldset>
|
|
{% endif %}
|
|
|
|
{% if query-result %}
|
|
<fieldset>
|
|
<legend>Results ({{query-result.row-count}} rows{% if query-result.truncated? %}, truncated{% endif %})</legend>
|
|
<table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%;">
|
|
<thead>
|
|
<tr>
|
|
{% for column in query-result.columns %}
|
|
<th>{{column}}</th>
|
|
{% endfor %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in query-result.rows %}
|
|
<tr>
|
|
{% for cell in row %}
|
|
<td><code>{{cell}}</code></td>
|
|
{% endfor %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</fieldset>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
</div><!-- left column -->
|
|
|
|
{% if session %}
|
|
<div id="graph-view-column"
|
|
style="flex: 1 1 auto; min-width: 0;">
|
|
<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>
|
|
<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>
|
|
</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). 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>
|
|
<button type="button" id="graph-filter-reset" style="display: none;">Show full graph</button>
|
|
</fieldset>
|
|
</div>
|
|
{% endif %}
|
|
|
|
</div><!-- flex row -->
|
|
</section>
|
|
</main>
|
|
|
|
<style>
|
|
#graph-view-column {
|
|
position: sticky;
|
|
top: 8px;
|
|
}
|
|
#graph-view-column.graph-view-expanded {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1000;
|
|
background: #fff;
|
|
overflow: auto;
|
|
padding: 12px;
|
|
margin: 0;
|
|
}
|
|
#graph-view-column.graph-view-expanded #graph-canvas {
|
|
height: calc(100vh - 160px) !important;
|
|
}
|
|
#graph-files-tree summary { cursor: pointer; }
|
|
#graph-files-tree ul { margin: 2px 0 4px 0; padding-left: 2em; }
|
|
#graph-files-tree a { text-decoration: none; }
|
|
#graph-files-tree a:hover { text-decoration: underline; }
|
|
</style>
|
|
|
|
<script>
|
|
(function () {
|
|
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");
|
|
a.href = "#";
|
|
a.textContent = file.name;
|
|
a.title = file.id;
|
|
a.addEventListener("click", function (ev) {
|
|
ev.preventDefault();
|
|
if (loadInput) {
|
|
loadInput.value = file.id;
|
|
loadForm.submit();
|
|
}
|
|
});
|
|
li.appendChild(a);
|
|
return li;
|
|
}
|
|
|
|
fetch("/dbg/actions/graph-files")
|
|
.then(function (resp) {
|
|
if (!resp.ok) throw new Error("graph-files HTTP " + resp.status);
|
|
return resp.json();
|
|
})
|
|
.then(function (data) {
|
|
tree.textContent = "";
|
|
const teams = data.teams || [];
|
|
linkLoadedFile(teams);
|
|
if (!teams.length) {
|
|
tree.textContent = "No files found.";
|
|
return;
|
|
}
|
|
teams.forEach(function (team) {
|
|
const teamEl = document.createElement("details");
|
|
const teamSummary = document.createElement("summary");
|
|
teamSummary.textContent = team.name;
|
|
teamEl.appendChild(teamSummary);
|
|
(team.projects || []).forEach(function (project) {
|
|
const projEl = document.createElement("details");
|
|
projEl.style.marginLeft = "1em";
|
|
const projSummary = document.createElement("summary");
|
|
projSummary.textContent = project.name;
|
|
projEl.appendChild(projSummary);
|
|
const list = document.createElement("ul");
|
|
(project.files || []).forEach(function (file) {
|
|
list.appendChild(fileLink(file));
|
|
});
|
|
projEl.appendChild(list);
|
|
teamEl.appendChild(projEl);
|
|
});
|
|
tree.appendChild(teamEl);
|
|
});
|
|
})
|
|
.catch(function (err) {
|
|
tree.textContent = "Failed to load file tree: " + err;
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{% if session %}
|
|
<script src="https://cdn.jsdelivr.net/npm/@antv/g6@5/dist/g6.min.js"></script>
|
|
<script>
|
|
(function () {
|
|
const fileId = "{{session.file-id}}";
|
|
const sessionId = crypto.randomUUID();
|
|
const wsScheme = location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsUrl = wsScheme + "//" + location.host
|
|
+ "/ws/notifications?session-id=" + sessionId;
|
|
|
|
const wsStatus = document.getElementById("graph-ws-status");
|
|
const syncRevnEl = document.getElementById("graph-sync-revn");
|
|
const syncErrorEl = document.getElementById("graph-sync-error");
|
|
const changelog = document.getElementById("graph-changelog");
|
|
const changelogBody = document.getElementById("graph-changelog-body");
|
|
const changelogEmpty = document.getElementById("graph-changelog-empty");
|
|
|
|
let ws = null;
|
|
|
|
// --- G6 graph view -------------------------------------------------
|
|
// Color+glyph per node table. Colors from the validated categorical
|
|
// palette; within-glyph pairs are the only ones color must separate
|
|
// alone (every node also carries a direct name label + legend).
|
|
const NODE_STYLES = {
|
|
"Document": { color: "#0b0b0b", glyph: "diamond", size: 28 },
|
|
"Page": { color: "#2a78d6", glyph: "rect", size: 22 },
|
|
"Frame": { color: "#008300", glyph: "hexagon", size: 18 },
|
|
"Group": { color: "#4a3aa7", glyph: "hexagon", size: 18 },
|
|
"Boolean": { color: "#eda100", glyph: "hexagon", size: 18 },
|
|
"SVGRaw": { color: "#767470", glyph: "hexagon", size: 18 },
|
|
"Rectangle": { color: "#eb6834", glyph: "rect", size: 14 },
|
|
"Circle": { color: "#1baf7a", glyph: "circle", size: 14 },
|
|
"Path": { color: "#e34948", glyph: "triangle", size: 14 },
|
|
"Text": { color: "#e87ba4", glyph: "circle", size: 14 },
|
|
"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;
|
|
// G6's entrance/update animation is nice didactics on small graphs but
|
|
// the performance killer at scale (>2 min at 1700 nodes vs ~1.5 s off);
|
|
// keep it only below this node count.
|
|
const ANIMATE_MAX_NODES = 100;
|
|
const LAYOUT_XS = 40; // horizontal leaf slot spacing
|
|
const LAYOUT_YS = 70; // vertical rank (depth) spacing
|
|
|
|
// Layout dropdown source of truth: name -> G6 layout config, or null for
|
|
// the built-in O(n) tree layout (preset positions from treePositions,
|
|
// fastest, exploits IsChildOf being a tree). The <select> is populated
|
|
// from these keys; add/remove/tune entries here. All 13 G6 configs below
|
|
// were smoke-tested against combo data on this UMD build (2026-07-15).
|
|
// Note: iterative layouts (force*, fruchterman) are slow on 1000+ nodes;
|
|
// combo-combined is the only combo-aware one; the rest just get bounding
|
|
// boxes drawn around wherever they put the member nodes.
|
|
const LAYOUTS = {
|
|
"tree": null,
|
|
"antv-dagre": { type: "antv-dagre", rankdir: "BT", nodesep: 10, ranksep: 40, sortByCombo: true },
|
|
"dagre": { type: "dagre", rankdir: "BT" },
|
|
"circular": { type: "circular" },
|
|
"concentric": { type: "concentric" },
|
|
"radial": { type: "radial" },
|
|
"grid": { type: "grid" },
|
|
"force": { type: "force" },
|
|
"d3-force": { type: "d3-force" },
|
|
"force-atlas2": { type: "force-atlas2" },
|
|
"fruchterman": { type: "fruchterman" },
|
|
"mds": { type: "mds" },
|
|
"combo-combined": { type: "combo-combined" },
|
|
"random": { type: "random" }
|
|
};
|
|
const DEFAULT_LAYOUT = "tree";
|
|
|
|
const graphViewStatus = document.getElementById("graph-view-status");
|
|
let g6graph = null;
|
|
let g6graphLayout = null;
|
|
let g6graphAnimated = null;
|
|
let refetchTimer = null;
|
|
let lastGraphData = null;
|
|
let lastGraphSig = null;
|
|
let renderForced = false;
|
|
let graphFilterIds = null;
|
|
|
|
function currentLayoutName() {
|
|
const stored = localStorage.getItem("graph-layout");
|
|
return Object.prototype.hasOwnProperty.call(LAYOUTS, stored) ? stored : DEFAULT_LAYOUT;
|
|
}
|
|
|
|
function nodeStyle(table) {
|
|
return NODE_STYLES[table] || FALLBACK_STYLE;
|
|
}
|
|
|
|
function comboIdFor(nodeId) {
|
|
return "combo:" + nodeId;
|
|
}
|
|
|
|
function foldEnabled() {
|
|
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.
|
|
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, presetPositions) {
|
|
const nodes = data.nodes || [];
|
|
const edges = (data.edges || [])
|
|
.slice()
|
|
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); });
|
|
const parentOf = {};
|
|
const childrenOf = {};
|
|
edges.forEach(function (e) {
|
|
parentOf[e.source] = e.target;
|
|
(childrenOf[e.target] = childrenOf[e.target] || []).push(e.source);
|
|
});
|
|
// 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
|
|
// stay plain nodes.
|
|
const hasCombo = {};
|
|
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;
|
|
const combo = { id: comboIdFor(n.id), data: { label: n.label, table: n.table } };
|
|
const p = parentOf[n.id];
|
|
if (p && hasCombo[p]) combo.combo = comboIdFor(p);
|
|
if (collapsedIds && collapsedIds.has(combo.id)) combo.style = { collapsed: true };
|
|
combos.push(combo);
|
|
});
|
|
const g6nodes = nodes.map(function (n) {
|
|
const out = { id: n.id,
|
|
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]]) {
|
|
out.combo = comboIdFor(parentOf[n.id]);
|
|
}
|
|
return out;
|
|
});
|
|
const g6edges = edges.map(function (e) {
|
|
return { source: e.source, target: e.target, data: { position: e.position } };
|
|
});
|
|
return { nodes: g6nodes, edges: g6edges, combos: combos };
|
|
}
|
|
|
|
function collapsedComboIds() {
|
|
if (!g6graph) return new Set();
|
|
try {
|
|
return new Set(g6graph.getComboData()
|
|
.filter(function (c) { return c.style && c.style.collapsed; })
|
|
.map(function (c) { return c.id; }));
|
|
} catch (_err) {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function renderGraph(g6data) {
|
|
const layoutName = currentLayoutName();
|
|
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.
|
|
try { g6graph.destroy(); } catch (_err) {}
|
|
g6graph = null;
|
|
}
|
|
if (g6graph) {
|
|
g6graph.setData(g6data);
|
|
return g6graph.render().catch(function (_err) {
|
|
/* instance may be destroyed mid-render on rapid toggle/layout switches */
|
|
});
|
|
}
|
|
g6graphLayout = layoutName;
|
|
g6graphAnimated = animate;
|
|
const layoutCfg = LAYOUTS[layoutName];
|
|
const opts = {
|
|
container: "graph-canvas",
|
|
data: g6data,
|
|
autoFit: "view",
|
|
autoResize: true,
|
|
animation: animate,
|
|
padding: 20,
|
|
node: {
|
|
type: function (d) { return nodeStyle(d.data.table).glyph; },
|
|
style: {
|
|
size: function (d) { return nodeStyle(d.data.table).size; },
|
|
fill: function (d) { return nodeStyle(d.data.table).color; },
|
|
stroke: "#52514e",
|
|
lineWidth: 0.5,
|
|
labelText: function (d) { return d.data.label; },
|
|
labelFontSize: 9,
|
|
labelFill: "#0b0b0b",
|
|
labelPlacement: "bottom"
|
|
}
|
|
},
|
|
edge: {
|
|
style: { stroke: "#b3b0a8", endArrow: true, endArrowSize: 6 }
|
|
},
|
|
combo: {
|
|
type: "rect",
|
|
style: {
|
|
labelText: function (d) { return d.data.label; },
|
|
labelFontSize: 9,
|
|
labelFill: "#52514e",
|
|
labelPlacement: "top",
|
|
stroke: function (d) { return nodeStyle(d.data.table).color; },
|
|
lineWidth: 1,
|
|
fillOpacity: 0.03,
|
|
radius: 4
|
|
}
|
|
},
|
|
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);
|
|
return g6graph.render().catch(function (_err) {
|
|
/* see above */
|
|
});
|
|
}
|
|
|
|
function renderGraphLegend() {
|
|
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; white-space: nowrap;">'
|
|
+ '<span style="color: ' + s.color + '; font-size: 14px;">'
|
|
+ (GLYPH_CHARS[s.glyph] || "●") + '</span> '
|
|
+ escapeHtml(table) + "</span>";
|
|
}).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" : "");
|
|
}
|
|
|
|
// Query filter: induced subgraph of the already-exported graph, selected
|
|
// by node ids found in the last Cypher result. No reconstruction from the
|
|
// query result is needed — ids are enough to slice the cached export.
|
|
function filteredGraphData() {
|
|
if (!graphFilterIds) return lastGraphData;
|
|
const keep = {};
|
|
const nodes = lastGraphData.nodes.filter(function (n) {
|
|
if (graphFilterIds.has(n.id)) { keep[n.id] = true; return true; }
|
|
return false;
|
|
});
|
|
const edges = lastGraphData.edges.filter(function (e) {
|
|
return keep[e.source] && keep[e.target];
|
|
});
|
|
return { nodes: nodes, edges: edges,
|
|
revn: lastGraphData.revn, truncated: lastGraphData.truncated };
|
|
}
|
|
|
|
function applyQueryFilter(ids) {
|
|
if (!lastGraphData) return;
|
|
const present = new Set();
|
|
lastGraphData.nodes.forEach(function (n) { if (ids.has(n.id)) present.add(n.id); });
|
|
if (!present.size) {
|
|
graphViewStatus.textContent = "query result matches no nodes in the loaded graph";
|
|
return;
|
|
}
|
|
graphFilterIds = present;
|
|
renderCurrent();
|
|
}
|
|
|
|
function renderCurrent() {
|
|
if (!lastGraphData) return;
|
|
const data = filteredGraphData();
|
|
const anywayBtn = document.getElementById("graph-render-anyway");
|
|
const filterBtn = document.getElementById("graph-filter-reset");
|
|
if (filterBtn) filterBtn.style.display = graphFilterIds ? "inline" : "none";
|
|
const filterNote = graphFilterIds
|
|
? " — query filter: " + data.nodes.length + " of "
|
|
+ lastGraphData.nodes.length + " nodes"
|
|
: "";
|
|
if (data.nodes.length > RENDER_GUARD_NODES && !renderForced) {
|
|
graphViewStatus.textContent =
|
|
graphStatusText(data) + filterNote + " — too large to render automatically";
|
|
if (anywayBtn) anywayBtn.style.display = "inline";
|
|
return;
|
|
}
|
|
if (anywayBtn) anywayBtn.style.display = "none";
|
|
graphViewStatus.textContent = graphStatusText(data) + filterNote;
|
|
renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled(),
|
|
LAYOUTS[currentLayoutName()] == null));
|
|
}
|
|
|
|
function refetchGraph() {
|
|
if (typeof G6 === "undefined") {
|
|
graphViewStatus.textContent = "G6 failed to load (CDN unreachable)";
|
|
return;
|
|
}
|
|
fetch("/dbg/actions/graph-data")
|
|
.then(function (resp) {
|
|
if (resp.status === 404) {
|
|
throw new Error("no graph session (backend restarted?) — reload a file");
|
|
}
|
|
if (!resp.ok) throw new Error("graph-data HTTP " + resp.status);
|
|
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) {
|
|
graphViewStatus.textContent = "graph view error: " + err;
|
|
});
|
|
}
|
|
|
|
function scheduleGraphRefetch() {
|
|
if (refetchTimer) clearTimeout(refetchTimer);
|
|
refetchTimer = setTimeout(function () {
|
|
refetchTimer = null;
|
|
refetchGraph();
|
|
}, 400);
|
|
}
|
|
// --- end G6 graph view ---------------------------------------------
|
|
|
|
function encodeTransitUuid(uuid) {
|
|
return "~u" + uuid;
|
|
}
|
|
|
|
function encodeSubscribe(fileId) {
|
|
return JSON.stringify({
|
|
"~:type": "~:subscribe-file",
|
|
"~:file-id": encodeTransitUuid(fileId)
|
|
});
|
|
}
|
|
|
|
function encodeUnsubscribe(fileId) {
|
|
return JSON.stringify({
|
|
"~:type": "~:unsubscribe-file",
|
|
"~:file-id": encodeTransitUuid(fileId)
|
|
});
|
|
}
|
|
|
|
function parseTransitValue(value) {
|
|
if (typeof value === "string") {
|
|
if (value.startsWith("~:")) return value.slice(2);
|
|
if (value.startsWith("~u")) return value.slice(2);
|
|
}
|
|
if (Array.isArray(value)) return value.map(parseTransitValue);
|
|
if (value && typeof value === "object") return parseTransitMap(value);
|
|
return value;
|
|
}
|
|
|
|
function parseTransitMap(obj) {
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const name = key.startsWith("~:") ? key.slice(2) : key;
|
|
out[name] = parseTransitValue(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function summarizeChange(change) {
|
|
const parts = [change.type];
|
|
if (change.id) parts.push("id=" + change.id);
|
|
if (change.obj && change.obj.type) parts.push("shape=" + change.obj.type);
|
|
if (change.operations && change.operations.length) {
|
|
const attrs = change.operations
|
|
.map(function (op) { return op.attr; })
|
|
.filter(Boolean);
|
|
if (attrs.length) parts.push("attrs=" + attrs.join(","));
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function summarizeChanges(changes) {
|
|
if (!changes || !changes.length) return "(empty)";
|
|
return changes.map(summarizeChange).join("; ");
|
|
}
|
|
|
|
function summarizeSkipped(skipped) {
|
|
if (!skipped) return "";
|
|
const items = Array.isArray(skipped) ? skipped : [skipped];
|
|
return items.map(function (item) {
|
|
if (!item || typeof item !== "object") return String(item);
|
|
const type = item.type || "unknown";
|
|
const reason = item.reason ? " (" + item.reason + ")" : "";
|
|
return String(type) + reason;
|
|
}).join("; ");
|
|
}
|
|
|
|
function refreshSyncStatus() {
|
|
fetch("/dbg/actions/graph-sync-status")
|
|
.then(function (resp) { return resp.text(); })
|
|
.then(function (text) {
|
|
const status = parseTransitMap(JSON.parse(text));
|
|
if (status["graph-revn"] !== undefined) {
|
|
syncRevnEl.textContent = String(status["graph-revn"]);
|
|
}
|
|
if (status.sync && status.sync.error) {
|
|
syncErrorEl.style.display = "inline";
|
|
syncErrorEl.textContent = "sync error: " + status.sync.error;
|
|
} else if (status.sync && status.sync["last-skipped"]
|
|
&& summarizeSkipped(status.sync["last-skipped"])) {
|
|
syncErrorEl.style.display = "inline";
|
|
syncErrorEl.textContent =
|
|
"some changes skipped: "
|
|
+ summarizeSkipped(status.sync["last-skipped"])
|
|
+ " (use full reload if needed)";
|
|
} else {
|
|
syncErrorEl.style.display = "none";
|
|
syncErrorEl.textContent = "";
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function appendChange(revn, summary) {
|
|
changelogEmpty.style.display = "none";
|
|
changelog.style.display = "table";
|
|
|
|
const row = document.createElement("tr");
|
|
const revnCell = document.createElement("td");
|
|
const changesCell = document.createElement("td");
|
|
|
|
revnCell.textContent = String(revn);
|
|
changesCell.textContent = summary;
|
|
row.appendChild(revnCell);
|
|
row.appendChild(changesCell);
|
|
changelogBody.appendChild(row);
|
|
row.scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
function handleMessage(raw) {
|
|
let msg;
|
|
try {
|
|
msg = parseTransitMap(JSON.parse(raw));
|
|
} catch (_err) {
|
|
return;
|
|
}
|
|
|
|
if (msg.type !== "file-change" || msg["file-id"] !== fileId) return;
|
|
|
|
appendChange(msg.revn, summarizeChanges(msg.changes));
|
|
setTimeout(refreshSyncStatus, 150);
|
|
scheduleGraphRefetch();
|
|
}
|
|
|
|
function subscribe() {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(encodeSubscribe(fileId));
|
|
}
|
|
}
|
|
|
|
let wsClosing = false;
|
|
let wsReconnectTimer = null;
|
|
|
|
function scheduleReconnect() {
|
|
if (wsClosing || wsReconnectTimer) return;
|
|
wsReconnectTimer = setTimeout(function () {
|
|
wsReconnectTimer = null;
|
|
connect();
|
|
}, 3000);
|
|
}
|
|
|
|
function connect() {
|
|
ws = new WebSocket(wsUrl);
|
|
wsStatus.textContent = "connecting…";
|
|
|
|
ws.addEventListener("open", function () {
|
|
wsStatus.textContent = "subscribed";
|
|
subscribe();
|
|
// catch up on anything missed while disconnected
|
|
refreshSyncStatus();
|
|
scheduleGraphRefetch();
|
|
});
|
|
|
|
ws.addEventListener("message", function (event) {
|
|
handleMessage(event.data);
|
|
});
|
|
|
|
ws.addEventListener("close", function () {
|
|
wsStatus.textContent = wsClosing ? "disconnected" : "disconnected — retrying…";
|
|
scheduleReconnect();
|
|
});
|
|
|
|
ws.addEventListener("error", function () {
|
|
wsStatus.textContent = "error";
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function renderQueryOutput(data) {
|
|
const output = document.getElementById("graph-query-output");
|
|
if (!output) return;
|
|
|
|
if (data.error) {
|
|
output.innerHTML =
|
|
"<fieldset><legend>Error</legend>"
|
|
+ "<pre>" + escapeHtml(data.error) + "</pre></fieldset>";
|
|
return;
|
|
}
|
|
|
|
const result = data["query-result"];
|
|
if (!result) {
|
|
output.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const truncated = result["truncated?"] ? ", truncated" : "";
|
|
let html =
|
|
"<fieldset><legend>Results ("
|
|
+ escapeHtml(String(result["row-count"]))
|
|
+ " rows" + truncated + ")</legend>"
|
|
+ "<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\""
|
|
+ " style=\"border-collapse: collapse; width: 100%;\">"
|
|
+ "<thead><tr>";
|
|
|
|
// 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>";
|
|
visibleIdx.forEach(function (i) {
|
|
html += "<td><code>" + escapeHtml(row[i]) + "</code></td>";
|
|
});
|
|
html += "</tr>";
|
|
});
|
|
|
|
html += "</tbody></table></fieldset>";
|
|
output.innerHTML = html;
|
|
|
|
// Offer to view the result as an induced subgraph: any UUID appearing
|
|
// in any result cell selects that node in the loaded graph.
|
|
const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
|
const ids = new Set();
|
|
(result.rows || []).forEach(function (row) {
|
|
row.forEach(function (cell) {
|
|
const found = String(cell).match(uuidRe);
|
|
if (found) found.forEach(function (m) { ids.add(m.toLowerCase()); });
|
|
});
|
|
});
|
|
if (ids.size) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.textContent = "Show result in graph view (" + ids.size + " ids)";
|
|
btn.addEventListener("click", function () { applyQueryFilter(ids); });
|
|
output.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
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);
|
|
fetch("/dbg/actions/graph-query", {
|
|
method: "POST",
|
|
headers: { "Accept": "application/json" },
|
|
body: formData
|
|
})
|
|
.then(function (resp) { return resp.text(); })
|
|
.then(function (text) {
|
|
renderQueryOutput(parseTransitMap(JSON.parse(text)));
|
|
})
|
|
.catch(function (err) {
|
|
renderQueryOutput({ error: String(err) });
|
|
});
|
|
});
|
|
}
|
|
|
|
// In-page expand (no Fullscreen API): keeps browser chrome and window
|
|
// manager splits usable while the graph takes the whole page.
|
|
const maximizeBtn = document.getElementById("graph-maximize");
|
|
const graphColumn = document.getElementById("graph-view-column");
|
|
|
|
function resizeGraphSoon() {
|
|
setTimeout(function () {
|
|
if (g6graph) {
|
|
const canvas = document.getElementById("graph-canvas");
|
|
try {
|
|
g6graph.setSize(canvas.clientWidth, canvas.clientHeight);
|
|
g6graph.fitView();
|
|
} catch (_err) { /* autoResize covers most cases */ }
|
|
}
|
|
}, 120);
|
|
}
|
|
|
|
function setExpanded(expanded) {
|
|
graphColumn.classList.toggle("graph-view-expanded", expanded);
|
|
maximizeBtn.textContent = expanded ? "Restore (Esc)" : "Expand";
|
|
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"));
|
|
});
|
|
document.addEventListener("keydown", function (ev) {
|
|
if (ev.key === "Escape"
|
|
&& graphColumn.classList.contains("graph-view-expanded")) {
|
|
setExpanded(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
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 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) {
|
|
const opt = document.createElement("option");
|
|
opt.value = name;
|
|
opt.textContent = name;
|
|
layoutSelect.appendChild(opt);
|
|
});
|
|
layoutSelect.value = currentLayoutName();
|
|
layoutSelect.addEventListener("change", function () {
|
|
localStorage.setItem("graph-layout", layoutSelect.value);
|
|
renderCurrent();
|
|
});
|
|
}
|
|
|
|
const renderAnywayBtn = document.getElementById("graph-render-anyway");
|
|
if (renderAnywayBtn) {
|
|
renderAnywayBtn.addEventListener("click", function () {
|
|
renderForced = true;
|
|
renderCurrent();
|
|
});
|
|
}
|
|
|
|
const filterResetBtn = document.getElementById("graph-filter-reset");
|
|
if (filterResetBtn) {
|
|
filterResetBtn.addEventListener("click", function () {
|
|
graphFilterIds = null;
|
|
renderCurrent();
|
|
});
|
|
}
|
|
|
|
connect();
|
|
refreshSyncStatus();
|
|
renderGraphLegend();
|
|
refetchGraph();
|
|
|
|
window.addEventListener("beforeunload", function () {
|
|
wsClosing = true;
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(encodeUnsubscribe(fileId));
|
|
ws.close();
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|