penpot/backend/resources/app/templates/graph-console.tmpl
Álvaro Tejero Cantero e011dedccf
💄 Console UI polish round
Merge the load form and files tree into one "Load graph from Penpot" box (tree first, uuid + Load/Unload in a row); Loaded session carries HH:MM in its legend; the Live changes box stays hidden until the first change arrives; query fieldset reads "Query graph (LadybugDB Cypher)" with the link covering both terms. Drop the hover tooltips (distracting, useless zoomed out) and the resident-size estimate (per-table counts stay on hover); every toggle gets a "When set/checked ..." title. Depth fold: 0 now expands every container (no more hunting for max depth). Node inspector: two-column flow, structured or long values folded behind the file-tree disclosure triangle.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-08-05 19:05:58 +02:00

1641 lines
62 KiB
Cheetah
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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">
<!-- flex: 1 1 0 + min-width: 0: size this .dashboard flex item from the
viewport, never from content — content-driven growth (e.g. the G6
canvas) would otherwise feed back into column width. -->
<section class="widget" style="max-width: none; flex: 1 1 0; min-width: 0;">
<p><a href="/dbg">&larr; 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 from Penpot</legend>
<desc>
Click file or paste UUID to load Penpot file into an in-memory
Ladybug database. Loading a new file replaces the previous one.
</desc>
<div id="graph-files-tree" style="font-size: 13px; margin-bottom: 6px;">Loading&hellip;</div>
<form id="graph-load-form" method="post" action="/dbg/actions/graph-load">
<div class="row" style="display: flex; gap: 8px;">
<input type="text" style="width:320px" name="file-id"
placeholder="file-id"
value="{% if session %}{{session.file-id}}{% endif %}" />
<input type="submit" value="Load" />
{% if session %}
<input type="submit" value="Unload" form="graph-unload-form"
title="Drop the in-memory session and free its memory" />
{% endif %}
</div>
</form>
{% if session %}
<form id="graph-unload-form" method="post" action="/dbg/actions/graph-unload"></form>
{% endif %}
</fieldset>
{% if session %}
<fieldset>
<legend>Loaded session (<span id="graph-loaded-at">{{session.loaded-at}}</span>)</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 />
Graph size: <b id="graph-size">…</b><br />
Schema: <b>{{session.schema-version}}</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>
</desc>
</fieldset>
<fieldset id="graph-changelog-box" style="display: none;">
<legend>Live changes</legend>
<desc>Latest changes applied to the backend graph.</desc>
<table id="graph-changelog" border="1" cellpadding="4" cellspacing="0"
style="border-collapse: collapse; width: 100%;">
<thead>
<tr>
<th>revn</th>
<th>changes</th>
</tr>
</thead>
<tbody id="graph-changelog-body"></tbody>
</table>
</fieldset>
<fieldset>
<legend>Query graph (<a href="https://docs.ladybugdb.com/cypher/"
target="_blank">LadybugDB Cypher</a>)</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>
<div style="max-height: 45vh; overflow: auto;">
<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>
</div>
</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
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="When checked, containers render as foldable boxes (combos); double-click folds/unfolds. When unchecked, plain nodes only — the fold controls to the right go dormant.">
<input type="checkbox" id="graph-fold-toggle" /> fold containers
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="When checked, graphs up to 100 nodes render with entrance animation.">
<input type="checkbox" id="graph-animate-toggle" /> animate
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="When set, added/removed marks fade out over this many display steps (0 disables diff marks).">
fade: <input type="number" id="graph-diff-steps" min="0" max="20"
style="width: 3.5em;" /> steps
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="When checked, every container without changed elements collapses, so changes stand out (overrides manual folds).">
<input type="checkbox" id="graph-diff-fold" /> fold unchanged
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;"
title="When set, overview mode: 0 expands every container, n ≥ 1 collapses every container at depth ≥ n from the root (overrides manual folds; empty = manual folding).">
fold ≥ depth: <input type="number" id="graph-depth-fold" min="0" max="99"
style="width: 3em;" />
</label>
<label style="margin-left: 1em; font-size: 12px; font-weight: normal;">
layout: <select id="graph-layout-select"></select>
</label>
</legend>
<desc>
Live view of the in-memory Ladybug graph (AntV G6). Double-click
folds containers when folding is on.
</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; overflow: hidden;"></div>
<div id="graph-view-status" style="color: #666; font-size: 12px;"></div>
<div id="graph-node-inspector"
style="display: none; font-size: 12px; margin-top: 4px; border: 1px solid #ccc;
padding: 6px; max-height: 40vh; overflow: auto;"></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;
}
/* Fieldsets default to min-inline-size: min-content, so the panel could
never shrink below its content and grew with the G6 canvas instead
(content -> fieldset -> column -> canvas feedback). Let it shrink;
the legend wraps and the canvas clips. */
#graph-view-panel {
min-inline-size: 0;
}
#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;
}
/* Eye-guiding pulse on just-changed elements: DOM overlay rings over the
canvas, independent of the G6 animation gate (big graphs render with
animation off). Two quick beats, then gone. */
#graph-canvas { position: relative; }
.graph-pulse {
position: absolute;
border: 3px solid;
border-radius: 50%;
pointer-events: none;
animation: graph-pulse 0.6s ease-out 2;
opacity: 0;
}
@keyframes graph-pulse {
0% { transform: translate(-50%, -50%) scale(0.5); opacity: 0.9; }
100% { transform: translate(-50%, -50%) scale(2.1); opacity: 0; }
}
#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 changelogBox = document.getElementById("graph-changelog-box");
const changelogBody = document.getElementById("graph-changelog-body");
let ws = null;
// --- G6 graph view -------------------------------------------------
// Monochrome entity scheme: chroma = change. All entities share one
// slate hue; *lightness* separates within-glyph siblings (validated:
// worst within-glyph pair ΔE 17.5; the chroma floor is deliberately
// violated — saturated color is reserved for diff marks and would
// otherwise compete with them). Glyph class carries type identity,
// direct labels relieve the light-step contrast; SVGRaw is the hollow
// hexagon instead of a fourth lightness step.
const NODE_STYLES = {
"Document": { color: "#14202e", glyph: "diamond", size: 28 },
"Page": { color: "#2e415a", glyph: "rect", size: 22 },
"Frame": { color: "#22344a", glyph: "hexagon", size: 18 },
"Group": { color: "#5b7089", glyph: "hexagon", size: 18 },
"Boolean": { color: "#93a4b8", glyph: "hexagon", size: 18 },
"SVGRaw": { color: "#8b98a9", glyph: "hexagon", size: 18, hollow: true },
"Rectangle": { color: "#93a4b8", glyph: "rect", size: 14 },
"Circle": { color: "#3b5069", glyph: "circle", size: 14 },
"Path": { color: "#5b7089", glyph: "triangle", size: 14 },
"Text": { color: "#8b9cb1", glyph: "circle", size: 14 },
"Image": { color: "#5b7089", glyph: "star", size: 14 },
"Component": { color: "#8195ab", glyph: "diamond", size: 20 }
};
const FALLBACK_STYLE = { color: "#5b7089", glyph: "circle", size: 14 };
// Edge style keyed on rel (wire field since slice-3); future edge
// attributes can feed styling the same way. All rels stay grey (chroma
// belongs to diff marks); identity comes from `sym`, a compact unicode
// rel label rendered mid-edge (abacus viewer EDGE_SYM convention) —
// dash variants alone cannot carry the growing rel roster. IsChildOf
// is the unlabeled default (the background tree structure).
// `sym` may be a full rel name: no compact glyph reads as "derived from
// a template" (∈ wrongly connotes membership), so IsInstanceOf spells
// itself out; the legend falls back to an arrow for long syms. No
// dashed edges — the label alone carries rel identity.
const EDGE_STYLES = {
"IsChildOf": { stroke: "#b3b0a8" },
"IsInstanceOf": { stroke: "#8b98a9", sym: "IsInstanceOf" }
};
const FALLBACK_EDGE_STYLE = { stroke: "#b3b0a8" };
// --- graph diff: add/remove marks with step fade --------------------
// A "step" is a display-changing refetch (no-op skips age nothing).
// Added elements get a green halo; removed ones stay in the display as
// ghosts with a dashed crimson halo and fading opacity (dash + fade
// carry the added/removed distinction for red-green CVD; the pair
// validates at deutan ΔE 17.4). Marks fade linearly with age and drop
// after N steps; N comes from the "fade" box (0 disables the feature).
// The diff is vs the previous display step, not between arbitrary
// revisions — true version-to-version diffs await server deltas / the
// graph-based-VCS work.
const DIFF_ADDED_COLOR = "#40c057";
const DIFF_REMOVED_COLOR = "#c2255c";
let diffMarks = { nodes: {}, edges: {} };
function diffSteps() {
const v = parseInt(localStorage.getItem("graph-diff-steps"), 10);
return Number.isFinite(v) && v >= 0 && v <= 20 ? v : 3;
}
function diffEdgeKey(source, rel, target) {
return source + "|" + (rel || "IsChildOf") + "|" + target;
}
function liveMark(marks, key) {
const m = marks[key];
return m && m.age < diffSteps() ? m : null;
}
// 1.0 at age 0 down to 1/N at age N-1; the mark drops at age N.
function diffFade(mark) {
return 1 - mark.age / diffSteps();
}
function markDiff(prev, next) {
if (!diffSteps() || !prev) {
diffMarks = { nodes: {}, edges: {} };
return;
}
[diffMarks.nodes, diffMarks.edges].forEach(function (marks) {
Object.keys(marks).forEach(function (k) {
marks[k].age += 1;
if (marks[k].age >= diffSteps()) delete marks[k];
});
});
const prevNodes = {};
prev.nodes.forEach(function (n) { prevNodes[n.id] = n; });
const nextNodes = {};
next.nodes.forEach(function (n) { nextNodes[n.id] = n; });
next.nodes.forEach(function (n) {
if (!prevNodes[n.id]) diffMarks.nodes[n.id] = { kind: "added", age: 0 };
});
prev.nodes.forEach(function (n) {
if (!nextNodes[n.id]) diffMarks.nodes[n.id] = { kind: "removed", age: 0, node: n };
});
const prevEdges = {};
prev.edges.forEach(function (e) { prevEdges[diffEdgeKey(e.source, e.rel, e.target)] = e; });
const nextEdges = {};
next.edges.forEach(function (e) { nextEdges[diffEdgeKey(e.source, e.rel, e.target)] = e; });
Object.keys(nextEdges).forEach(function (k) {
if (!prevEdges[k]) diffMarks.edges[k] = { kind: "added", age: 0 };
});
Object.keys(prevEdges).forEach(function (k) {
if (!nextEdges[k]) diffMarks.edges[k] = { kind: "removed", age: 0, edge: prevEdges[k] };
});
}
// Removed elements stay displayed as fading ghosts until their mark
// expires. Ghosts respect the query filter and re-enter layout and
// combo derivation through their ghost IsChildOf edges, so they keep
// their old place in the tree while fading.
function withGhosts(data) {
const nodes = data.nodes.slice();
const present = {};
nodes.forEach(function (n) { present[n.id] = true; });
Object.keys(diffMarks.nodes).forEach(function (id) {
const m = liveMark(diffMarks.nodes, id);
if (m && m.kind === "removed" && m.node && !present[id]
&& (!graphFilterIds || graphFilterIds.has(id))) {
nodes.push(m.node);
present[id] = true;
}
});
const edges = data.edges.slice();
const have = {};
edges.forEach(function (e) { have[diffEdgeKey(e.source, e.rel, e.target)] = true; });
Object.keys(diffMarks.edges).forEach(function (k) {
const m = liveMark(diffMarks.edges, k);
if (m && m.kind === "removed" && m.edge && !have[k]
&& present[m.edge.source] && present[m.edge.target]) {
edges.push(m.edge);
}
});
return { nodes: nodes, edges: edges, revn: data.revn, truncated: data.truncated };
}
function nodeMark(id) {
return liveMark(diffMarks.nodes, id);
}
function edgeMark(d) {
return liveMark(diffMarks.edges, diffEdgeKey(d.source, d.data.rel, d.target));
}
function diffColor(m) {
return m.kind === "added" ? DIFF_ADDED_COLOR : DIFF_REMOVED_COLOR;
}
function anyLiveMarks() {
return Object.keys(diffMarks.nodes).some(function (k) { return liveMark(diffMarks.nodes, k); })
|| Object.keys(diffMarks.edges).some(function (k) { return liveMark(diffMarks.edges, k); });
}
// Node ids touched by live marks: marked nodes plus the endpoints of
// marked edges (edge keys are "src|rel|tgt").
function liveMarkedNodeIds() {
const ids = new Set();
Object.keys(diffMarks.nodes).forEach(function (id) {
if (liveMark(diffMarks.nodes, id)) ids.add(id);
});
Object.keys(diffMarks.edges).forEach(function (k) {
if (liveMark(diffMarks.edges, k)) {
const parts = k.split("|");
ids.add(parts[0]);
ids.add(parts[2]);
}
});
return ids;
}
function diffFoldEnabled() {
return localStorage.getItem("graph-diff-fold") === "1";
}
// Overview mode: null = off, else collapse containers at depth ≥ value
// (root = depth 0, so e.g. 2 folds the containers hanging from a Page).
function depthFoldValue() {
const raw = localStorage.getItem("graph-depth-fold");
if (raw == null || raw === "") return null;
const v = parseInt(raw, 10);
return Number.isFinite(v) && v >= 0 ? v : null;
}
// One-shot pulse rings on age-0 marks, placed ~post-render (positions
// are sampled once; pulses don't track pan/zoom during their ~1.2 s).
function pulseAt(canvasPoint, color, sizePx) {
const host = document.getElementById("graph-canvas");
if (!host || !g6graph) return;
const vp = g6graph.getViewportByCanvas(canvasPoint);
const el = document.createElement("div");
el.className = "graph-pulse";
el.style.borderColor = color;
el.style.left = vp[0] + "px";
el.style.top = vp[1] + "px";
el.style.width = sizePx + "px";
el.style.height = sizePx + "px";
host.appendChild(el);
setTimeout(function () { el.remove(); }, 1400);
}
function schedulePulses() {
if (!diffSteps()) return;
setTimeout(function () {
if (!g6graph) return;
let zoom = 1;
try { zoom = g6graph.getZoom() || 1; } catch (_err) {}
Object.keys(diffMarks.nodes).forEach(function (id) {
const m = diffMarks.nodes[id];
if (!m || m.age !== 0) return;
try {
const p = g6graph.getElementPosition(id);
pulseAt([p[0], p[1]], diffColor(m),
Math.max(18, Math.min(64, 26 * zoom)));
} catch (_err) { /* hidden in a collapsed combo or gone */ }
});
Object.keys(diffMarks.edges).forEach(function (k) {
const m = diffMarks.edges[k];
if (!m || m.age !== 0) return;
const parts = k.split("|");
try {
const a = g6graph.getElementPosition(parts[0]);
const b = g6graph.getElementPosition(parts[2]);
pulseAt([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], diffColor(m),
Math.max(14, Math.min(48, 18 * zoom)));
} catch (_err) { /* endpoint hidden or gone */ }
});
}, 300);
}
// --- end graph diff --------------------------------------------------
// 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. Pruned 2026-07-16:
// grid/random/force/fruchterman/force-atlas2 added nothing over this
// set. combo-combined is the only combo-aware layout: it lays out each
// combo's members internally, then treats every combo as one super-node
// in an outer force pass. Non-hierarchical layouts carry overlap
// parameters (preventOverlap ignores label extents — see
// DENSE_LABEL_LAYOUTS).
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", preventOverlap: true, nodeSize: 32, nodeSpacing: 12 },
"radial": { type: "radial", preventOverlap: true, nodeSize: 32, unitRadius: 90 },
"d3-force": { type: "d3-force", collide: { radius: 26 } },
"combo-combined": { type: "combo-combined" }
};
const DEFAULT_LAYOUT = "tree";
// Always-on labels are the residual overlap driver on ring/stress
// layouts (their collision handling ignores label extents), so these
// get smaller labels; the node inspector carries full identity.
const DENSE_LABEL_LAYOUTS = {
"concentric": true, "radial": true, "d3-force": true
};
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 edgeStyle(rel) {
return EDGE_STYLES[rel] || FALLBACK_EDGE_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, diffFold) {
const nodes = data.nodes || [];
const edges = (data.edges || [])
.slice()
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); });
// Hierarchy (tree ranking, combo derivation, fold-ability) comes from
// IsChildOf only — still a tree by construction. Other rels
// (IsInstanceOf, …) are overlay edges drawn between positioned nodes.
const parentOf = {};
const childrenOf = {};
edges.forEach(function (e) {
if ((e.rel || "IsChildOf") !== "IsChildOf") return;
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;
});
}
// Derived fold state (overrides manual double-click folds while on):
// overview mode collapses every combo at depth ≥ the limit — except
// 0, which expands everything (saves hunting for the max depth) —
// and all of them when only diff-fold is on; diff-fold then re-opens
// the ancestor paths of changed elements — combined, that reads as
// "overview, with changes drilled open".
const depthLimit = depthFoldValue();
if (withCombos && (diffFold || depthLimit != null)) {
const depthOf = {};
const depth = function (id) {
if (depthOf[id] != null) return depthOf[id];
const p = parentOf[id];
depthOf[id] = p ? depth(p) + 1 : 0;
return depthOf[id];
};
collapsedIds = new Set();
Object.keys(hasCombo).forEach(function (id) {
if (depthLimit === 0) return;
if (depthLimit == null || depth(id) >= depthLimit) {
collapsedIds.add(comboIdFor(id));
}
});
if (diffFold) {
liveMarkedNodeIds().forEach(function (id) {
if (hasCombo[id]) collapsedIds.delete(comboIdFor(id));
let p = parentOf[id];
while (p) {
if (hasCombo[p]) collapsedIds.delete(comboIdFor(p));
p = parentOf[p];
}
});
}
}
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);
// Explicit boolean both ways: setData merges datum props by id on a
// live instance, so omitting `collapsed` would retain a previous
// `true` — a change inside a folded combo could then never expand it
// (the fold-unchanged bug).
combo.style = { collapsed: !!(collapsedIds && collapsedIds.has(combo.id)) };
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, rel: e.rel || "IsChildOf" } };
});
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",
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) {
const s = nodeStyle(d.data.table);
return s.hollow ? "#ffffff" : s.color;
},
// Unmarked nodes carry no stroke (hollow ones keep a thin one as
// their identity); the stroke channel belongs to diff marks:
// thick colored ring, dashed for removals (the CVD-safe cue).
stroke: function (d) {
const m = nodeMark(d.id);
return m ? diffColor(m) : nodeStyle(d.data.table).color;
},
lineWidth: function (d) {
if (nodeMark(d.id)) return 2.5;
return nodeStyle(d.data.table).hollow ? 1.5 : 0;
},
lineDash: function (d) {
const m = nodeMark(d.id);
return m && m.kind === "removed" ? [3, 3] : 0;
},
labelText: function (d) { return d.data.label; },
labelFontSize: function () {
return DENSE_LABEL_LAYOUTS[currentLayoutName()] ? 7 : 9;
},
labelFill: "#0b0b0b",
labelPlacement: "bottom",
halo: function (d) { return !!nodeMark(d.id); },
haloStroke: function (d) {
const m = nodeMark(d.id);
return m ? diffColor(m) : "#ffffff";
},
haloLineWidth: 12,
haloStrokeOpacity: function (d) {
const m = nodeMark(d.id);
return m ? 0.35 * diffFade(m) : 0;
},
opacity: function (d) {
const m = nodeMark(d.id);
return m && m.kind === "removed" ? diffFade(m) : 1;
},
labelOpacity: function (d) {
const m = nodeMark(d.id);
return m && m.kind === "removed" ? diffFade(m) : 1;
}
},
state: {
selected: { stroke: "#0b0b0b", lineWidth: 2 },
active: {},
inactive: { opacity: 0.2, labelOpacity: 0.2 }
}
},
edge: {
style: {
stroke: function (d) {
const m = edgeMark(d);
return m ? diffColor(m) : edgeStyle(d.data.rel).stroke;
},
lineDash: function (d) { return edgeStyle(d.data.rel).lineDash; },
lineWidth: function (d) {
const m = edgeMark(d);
return m ? (m.kind === "removed" ? 2.5 : 2) : 1;
},
strokeOpacity: function (d) {
const m = edgeMark(d);
return m ? Math.max(diffFade(m), 0.15) : 1;
},
labelText: function (d) { return edgeStyle(d.data.rel).sym || ""; },
labelFontSize: 7,
labelFill: "#52514e",
labelBackground: true,
labelBackgroundFill: "#ffffff",
labelBackgroundOpacity: 0.75,
endArrow: true,
endArrowSize: 6
},
state: {
selected: { lineWidth: 2 },
active: {},
inactive: { strokeOpacity: 0.1, labelOpacity: 0.1 }
}
},
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",
// Click highlights the 1-degree neighborhood (selected node black
// ring, neighbors keep full strength, the rest dims); clicking
// empty canvas clears. Runs alongside the node:click inspector.
{ type: "click-select", degree: 1,
state: "selected", neighborState: "active", unselectedState: "inactive" }],
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" },
{ id: "export", value: "export-png" }
];
},
onClick: function (value) {
if (value === "auto-fit") {
if (g6graph) g6graph.fitView();
} else if (value === "expand") {
setExpanded(true);
} else if (value === "restore") {
setExpanded(false);
} else if (value === "export-png") {
exportGraphPng();
}
}
}]
};
if (layoutCfg) opts.layout = layoutCfg;
g6graph = new G6.Graph(opts);
// Re-attached on every instance creation (layout/animation switches
// destroy and recreate the graph).
g6graph.on("node:click", function (e) {
const id = e.target && e.target.id;
if (id) showNodeInspector(String(id));
});
return g6graph.render().catch(function (_err) {
/* see above */
});
}
// Hand-rolled on purpose: the G6 legend plugin substitutes its own
// marker set (hexagon→circle, star→cross), breaking glyph identity.
// Entries reflect the *displayed* data only: node tables in roster
// order (unknown tables appended with the fallback style), then rels.
// Clean graph-only capture (no page chrome): the whole laid-out graph
// regardless of viewport, downloaded as PNG. Also the fast path for
// agents debugging the console — no full-page screenshot needed.
function exportGraphPng() {
if (!g6graph) return;
g6graph.toDataURL({ mode: "overall" })
.then(function (dataUrl) {
const a = document.createElement("a");
a.href = dataUrl;
a.download = "graph-" + (lastGraphData ? lastGraphData.revn : "view") + ".png";
a.click();
})
.catch(function (err) {
graphViewStatus.textContent = "export error: " + err;
});
}
function renderGraphLegend(data) {
const el = document.getElementById("graph-legend");
if (!el) return;
const tables = {};
(data.nodes || []).forEach(function (n) { tables[n.table] = true; });
const rels = {};
(data.edges || []).forEach(function (e) { rels[e.rel || "IsChildOf"] = true; });
function nodeItem(table, s) {
return '<span style="margin-right: 1em; white-space: nowrap;">'
+ '<span style="color: ' + s.color + '; font-size: 14px;">'
+ (s.hollow ? "⬡" : (GLYPH_CHARS[s.glyph] || "●")) + '</span> '
+ escapeHtml(table) + "</span>";
}
const items = Object.keys(NODE_STYLES)
.filter(function (t) { return tables[t]; })
.map(function (t) { return nodeItem(t, NODE_STYLES[t]); });
Object.keys(tables).sort().forEach(function (t) {
if (!NODE_STYLES[t]) items.push(nodeItem(t, FALLBACK_STYLE));
});
Object.keys(rels).sort().forEach(function (rel) {
const s = edgeStyle(rel);
const glyph = (s.sym && s.sym.length <= 2) ? s.sym : (s.lineDash ? "⇢" : "→");
items.push('<span style="margin-right: 1em; white-space: nowrap;">'
+ '<span style="color: ' + s.stroke + '; font-size: 14px;">'
+ escapeHtml(glyph) + '</span> '
+ escapeHtml(rel) + "</span>");
});
if (anyLiveMarks()) {
items.push('<span style="margin-right: 1em; white-space: nowrap;">'
+ '<span style="color: ' + DIFF_ADDED_COLOR + '; font-size: 14px;">+</span>'
+ ' added</span>');
items.push('<span style="white-space: nowrap;">'
+ '<span style="color: ' + DIFF_REMOVED_COLOR + '; font-size: 14px;"></span>'
+ ' removed</span>');
}
// join with a space: the spans are nowrap, so the separator is the
// only soft-wrap opportunity in the legend row
el.innerHTML = items.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 shown = withGhosts(data);
renderGraphLegend(shown);
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 (shown.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;
// "fold containers" is the master gate for combo rendering; the
// derived fold rules (fold-unchanged, depth) are dormant without it.
renderGraph(toG6Data(shown, collapsedComboIds(), foldEnabled(),
LAYOUTS[currentLayoutName()] == null, diffFoldEnabled()));
}
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) {
// Session-fieldset size line stays live even through skipped
// repaints (unlike the status line under the canvas); hover
// shows per-table counts.
const sizeEl = document.getElementById("graph-size");
if (sizeEl) {
sizeEl.textContent = data.nodes.length + " nodes, "
+ data.edges.length + " edges";
const counts = {};
data.nodes.forEach(function (n) {
counts[n.table] = (counts[n.table] || 0) + 1;
});
sizeEl.title = Object.keys(counts).sort().map(function (t) {
return t + "=" + counts[t];
}).join(", ");
}
// 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]);
if (sig === lastGraphSig) {
lastGraphData = data;
return;
}
markDiff(lastGraphData, data);
lastGraphData = data;
lastGraphSig = sig;
renderCurrent();
schedulePulses();
})
.catch(function (err) {
graphViewStatus.textContent = "graph view error: " + err;
});
}
// --- node inspector --------------------------------------------------
// Click a node → its full attribute row from the graph DB, rendered in
// a panel under the canvas. Panel over tooltip: the projected tables
// carry ~80 columns — too much for a tooltip, and the panel persists
// for side-by-side reading without obstructing the graph.
function runConsoleQuery(query) {
const formData = new FormData();
formData.append("query", query);
return fetch("/dbg/actions/graph-query", {
method: "POST",
headers: { "Accept": "application/json" },
body: formData
})
.then(function (resp) { return resp.text(); })
.then(function (text) { return parseTransitMap(JSON.parse(text)); });
}
function showNodeInspector(id) {
const panel = document.getElementById("graph-node-inspector");
if (!panel || !g6graph) return;
let datum = null;
try { datum = g6graph.getNodeData(id); } catch (_err) { return; }
const table = datum && datum.data && datum.data.table;
const label = (datum && datum.data && datum.data.label) || "";
// Both values are interpolated into Cypher — accept only what our own
// export produces (bare table names, UUID ids).
if (!table || !/^[A-Za-z]+$/.test(table)) return;
if (!/^[0-9a-f-]{36}$/i.test(id)) return;
panel.style.display = "block";
panel.textContent = "loading " + id + "…";
runConsoleQuery("MATCH (n:`" + table + "` {id: uuid('" + id + "')}) RETURN n.*;")
.then(function (data) {
if (data.error) {
panel.textContent = "inspector error: " + data.error;
return;
}
const result = data["query-result"];
const rows = (result && result.rows) || [];
let html = '<div style="display: flex; justify-content: space-between;">'
+ "<b>" + escapeHtml(table) + " " + escapeHtml(label) + "</b>"
+ '<button type="button" id="graph-inspector-close">close</button></div>'
+ '<div style="color: #666;">' + escapeHtml(id) + "</div>";
if (!rows.length) {
html += "<div>not in the graph DB (removed?)</div>";
} else {
const columns = result.columns || [];
let hidden = 0;
// Two-column flow; JSON-like values fold behind the same
// disclosure triangle the file tree uses.
html += '<div style="column-count: 2; column-gap: 16px; margin-top: 4px;">';
columns.forEach(function (col, i) {
const val = rows[0][i];
// Ladybug's value->clj fallback renders SQL nulls as "NULL".
if (val === null || val === undefined || val === "" || val === "NULL") {
hidden += 1;
return;
}
const key = escapeHtml(col.replace(/^n\./, ""));
const sval = String(val);
// Fold structured values ({…}) and walls of text (long arrays
// like migrations) behind a disclosure triangle.
if (sval.indexOf("{") !== -1 || sval.length > 120) {
html += '<details style="break-inside: avoid;">'
+ '<summary style="cursor: pointer;"><code>' + key + '</code></summary>'
+ '<pre style="white-space: pre-wrap; word-break: break-all; margin: 2px 0 4px 1.2em;">'
+ escapeHtml(sval) + "</pre></details>";
} else {
html += '<div style="break-inside: avoid;"><code>' + key
+ '</code>: <code>' + escapeHtml(sval) + "</code></div>";
}
});
html += "</div>";
if (hidden) {
html += '<div style="color: #666;">' + hidden + " empty attrs hidden</div>";
}
}
panel.innerHTML = html;
document.getElementById("graph-inspector-close")
.addEventListener("click", function () { panel.style.display = "none"; });
})
.catch(function (err) {
panel.textContent = "inspector error: " + err;
});
}
// --- end node inspector ----------------------------------------------
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) {
// The whole box stays hidden until the first change arrives; feed
// state lives in the session fieldset.
changelogBox.style.display = "block";
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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" : "";
// Actions bar sits above the table so a long result cannot push the
// "Show result in graph view" button out of sight; the table itself
// scrolls inside a capped container.
let html =
"<fieldset><legend>Results ("
+ escapeHtml(String(result["row-count"]))
+ " rows" + truncated + ")</legend>"
+ "<div id=\"graph-result-actions\" style=\"margin-bottom: 4px;\"></div>"
+ "<div style=\"max-height: 45vh; overflow: auto;\">"
+ "<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></div></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); });
document.getElementById("graph-result-actions").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. Entered
// via the toolbar's expand icon; exited via its exit icon or Esc.
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);
resizeGraphSoon();
}
// Follow container size (G6's autoResize is inert on this build).
// Safe only because the container's width is viewport-driven and can
// never follow the canvas: the fieldset's min-content floor is removed
// (#graph-view-panel min-inline-size: 0) and #graph-canvas clips
// (overflow: hidden). Without both, observer -> setSize -> wider canvas
// -> wider column -> observer is a runaway growth loop that also wipes
// the painted canvas on every step.
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) return;
try {
const cur = g6graph.getSize();
const w = canvasEl.clientWidth;
const h = canvasEl.clientHeight;
if (!cur || cur[0] !== w || cur[1] !== h) g6graph.setSize(w, h);
} catch (_err) { /* instance mid-recreate */ }
});
}).observe(canvasEl);
}
if (graphColumn) {
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 diffStepsInput = document.getElementById("graph-diff-steps");
if (diffStepsInput) {
diffStepsInput.value = String(diffSteps());
diffStepsInput.addEventListener("change", function () {
localStorage.setItem("graph-diff-steps", diffStepsInput.value);
renderCurrent();
});
}
const diffFoldToggle = document.getElementById("graph-diff-fold");
if (diffFoldToggle) {
diffFoldToggle.checked = diffFoldEnabled();
diffFoldToggle.addEventListener("change", function () {
localStorage.setItem("graph-diff-fold", diffFoldToggle.checked ? "1" : "0");
renderCurrent();
});
}
const depthFoldInput = document.getElementById("graph-depth-fold");
if (depthFoldInput) {
const v = depthFoldValue();
depthFoldInput.value = v == null ? "" : String(v);
depthFoldInput.addEventListener("change", function () {
localStorage.setItem("graph-depth-fold", depthFoldInput.value);
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();
});
}
// Compact the loaded-at timestamp to local HH:MM; full instant on hover.
const loadedAtEl = document.getElementById("graph-loaded-at");
if (loadedAtEl) {
const d = new Date(loadedAtEl.textContent.trim());
if (!isNaN(d.getTime())) {
loadedAtEl.title = loadedAtEl.textContent.trim();
loadedAtEl.textContent = String(d.getHours()).padStart(2, "0")
+ ":" + String(d.getMinutes()).padStart(2, "0");
}
}
connect();
refreshSyncStatus();
refetchGraph();
window.addEventListener("beforeunload", function () {
wsClosing = true;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(encodeUnsubscribe(fileId));
ws.close();
}
});
})();
</script>
{% endif %}
{% endblock %}