penpot/backend/resources/app/templates/graph-console.tmpl
Álvaro Tejero Cantero dc6c97e446
Label edge rels with compact unicode symbols
Dash variants alone cannot carry the growing rel roster: EDGE_STYLES entries gain a sym rendered as a small mid-edge label with a white backing (IsInstanceOf = "∈"; IsChildOf stays unlabeled as the background structure), and the legend shows the symbol. Convention from the abacus viewer EDGE_SYM dict.

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

1444 lines
52 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 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 &rarr; projects &rarr; files. Click a file to load it.</desc>
<div id="graph-files-tree" style="font-size: 13px;">Loading&hellip;</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>LadybugDB <a href="https://docs.ladybugdb.com/cypher/"
target="_blank">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>
<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
<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;"
title="added/removed marks fade out over this many display steps (0 = off)">
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="collapse every container that holds no changed elements">
<input type="checkbox" id="graph-diff-fold" /> fold unchanged
</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); the legend
shows what is displayed. 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>
<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 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 -------------------------------------------------
// 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).
const EDGE_STYLES = {
"IsChildOf": { stroke: "#b3b0a8" },
"IsInstanceOf": { stroke: "#8b98a9", lineDash: [4, 3], sym: "∈" }
};
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";
}
// 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. 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 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;
});
}
// Diff-fold: derive fold state from the live marks instead of the
// instance — every combo collapses except those on an ancestor path
// of (or being) a changed element, so changes stand out of the
// unchanged mass. Manual double-click fold state is ignored while on.
if (diffFold && withCombos) {
collapsedIds = new Set();
Object.keys(hasCombo).forEach(function (id) { collapsedIds.add(comboIdFor(id)); });
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: 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;
}
}
},
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: 9,
labelFill: "#52514e",
labelBackground: true,
labelBackgroundFill: "#ffffff",
labelBackgroundOpacity: 0.75,
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 */
});
}
// 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.
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);
items.push('<span style="margin-right: 1em; white-space: nowrap;">'
+ '<span style="color: ' + s.stroke + '; font-size: 14px;">'
+ escapeHtml(s.sym || (s.lineDash ? "⇢" : "→")) + '</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;
renderGraph(toG6Data(shown, collapsedComboIds(), foldEnabled() || diffFoldEnabled(),
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) {
// 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;
});
}
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, "&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" : "";
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. 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 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();
refetchGraph();
window.addEventListener("beforeunload", function () {
wsClosing = true;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(encodeUnsubscribe(fileId));
ws.close();
}
});
})();
</script>
{% endif %}
{% endblock %}