mirror of
https://github.com/penpot/penpot.git
synced 2026-08-10 23:08:41 +00:00
Bundled edges render unsmooth and ugly on this build; the gating constant goes with it. Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
1708 lines
66 KiB
Cheetah
1708 lines
66 KiB
Cheetah
{% extends "app/templates/base.tmpl" %}
|
||
|
||
{% block title %}
|
||
Graph Console
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<nav>
|
||
<div class="title">
|
||
<h1>GRAPH CONSOLE (VERSION: {{version}})</h1>
|
||
</div>
|
||
</nav>
|
||
<main class="dashboard">
|
||
<!-- 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">← Back to debug</a></p>
|
||
|
||
<div style="display: flex; gap: 16px; align-items: flex-start;">
|
||
<div style="flex: 0 0 350px; min-width: 300px;">
|
||
|
||
<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…</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="flex: 1; min-width: 0; font-size: 11px;" name="file-id"
|
||
placeholder="file-id"
|
||
value="{% if session %}{{session.file-id}}{% endif %}" />
|
||
{% if session %}
|
||
<input type="submit" value="Reload"
|
||
title="Re-ingests the file in the box from scratch — the recovery fallback when live sync drifted or skipped changes. Paste a different UUID to switch files." />
|
||
<input type="submit" value="Unload" form="graph-unload-form"
|
||
title="Drop the in-memory session and free its memory" />
|
||
{% else %}
|
||
<input type="submit" value="Load" />
|
||
{% 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 style="margin: 0 0 4px;">
|
||
File: <span id="graph-file-crumbs"></span><b><a id="graph-penpot-link"
|
||
data-file-id="{{session.file-id}}"
|
||
target="_blank">{{session.name}}</a></b>
|
||
<span id="graph-bm" style="color: #666;"
|
||
title="resident memory of the session's in-memory DB (buffer manager)"></span><br />
|
||
<span title="The file's revision when it was ingested vs the revision of the last live change applied to the graph. They start equal; a graph value behind the workspace means missed changes — use Reload.">
|
||
Revisions: ingested at <b>{{session.revn}}</b> · graph now
|
||
<b id="graph-sync-revn">{% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %}</b>
|
||
</span><br />
|
||
Graph size: <b id="graph-size">…</b><br />
|
||
Schema: <b>{{session.schema-version}}</b>
|
||
</p>
|
||
<p id="graph-sync-status" style="margin: 0;">
|
||
Feed: <b id="graph-ws-status">connecting…</b>
|
||
<span id="graph-sync-error" style="display:none; margin-left: 1em; color: #b91c1c;"></span>
|
||
</p>
|
||
</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;">
|
||
layout: <select id="graph-layout-select"></select>
|
||
</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: 1.8em;" /> steps
|
||
</label>
|
||
<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" /> foldable containers
|
||
</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: 2.2em;" />
|
||
</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";
|
||
const crumbs = document.getElementById("graph-file-crumbs");
|
||
if (crumbs) crumbs.textContent = team.name + " › " + project.name + " › ";
|
||
}
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
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]
|
||
&& !hiddenTables.has(m.node.table)
|
||
&& (!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/edges the view is not rendered automatically;
|
||
// the "Render anyway" button forces it (~1.5 s per 2k nodes, measured;
|
||
// edges gate the guard too since plugin/combo cost scales with them).
|
||
// Escape hatch: /dbg/graph?safe disables auto-render entirely, so a
|
||
// page that hung on render can always be re-entered.
|
||
const RENDER_GUARD_NODES = 4000;
|
||
const RENDER_GUARD_EDGES = 8000;
|
||
// 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" }
|
||
};
|
||
// Tried and rejected 2026-07-17: fishbone (renders nothing on graph
|
||
// data — it is a category layout) and compact-box (degenerates into an
|
||
// overlapped chain: G6 tree layouts traverse parent→child while our
|
||
// IsChildOf edges point child→parent; revisit with a reversed-edge feed
|
||
// if a compact tree view is wanted).
|
||
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;
|
||
// Node tables hidden via legend clicks (session-local, not persisted).
|
||
const hiddenTables = new Set();
|
||
|
||
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;
|
||
});
|
||
}
|
||
|
||
// Legend entries are clickable: toggling one hides/shows that table's
|
||
// nodes (struck-through while hidden; hidden tables stay listed so they
|
||
// can be re-enabled).
|
||
function renderGraphLegend(data) {
|
||
const el = document.getElementById("graph-legend");
|
||
if (!el) return;
|
||
const tables = {};
|
||
(data.nodes || []).forEach(function (n) { tables[n.table] = true; });
|
||
if (lastGraphData) {
|
||
lastGraphData.nodes.forEach(function (n) {
|
||
if (hiddenTables.has(n.table)) tables[n.table] = true;
|
||
});
|
||
}
|
||
const rels = {};
|
||
(data.edges || []).forEach(function (e) { rels[e.rel || "IsChildOf"] = true; });
|
||
function nodeItem(table, s) {
|
||
const off = hiddenTables.has(table);
|
||
return '<span data-table="' + escapeHtml(table) + '"'
|
||
+ ' title="click to ' + (off ? "show" : "hide") + " " + escapeHtml(table) + ' nodes"'
|
||
+ ' style="margin-right: 1em; white-space: nowrap; cursor: pointer;'
|
||
+ (off ? " opacity: 0.35; text-decoration: line-through;" : "") + '">'
|
||
+ '<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 && !hiddenTables.size) return lastGraphData;
|
||
const keep = {};
|
||
const nodes = lastGraphData.nodes.filter(function (n) {
|
||
if (hiddenTables.has(n.table)) return false;
|
||
if (graphFilterIds && !graphFilterIds.has(n.id)) return false;
|
||
keep[n.id] = true;
|
||
return true;
|
||
});
|
||
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"
|
||
: "";
|
||
const safeMode = new URLSearchParams(location.search).has("safe");
|
||
if ((safeMode
|
||
|| shown.nodes.length > RENDER_GUARD_NODES
|
||
|| shown.edges.length > RENDER_GUARD_EDGES) && !renderForced) {
|
||
graphViewStatus.textContent =
|
||
graphStatusText(data) + filterNote
|
||
+ (safeMode ? " — safe mode (?safe): auto-render off"
|
||
: " — 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.
|
||
// bm-bytes = the session DB's buffer-manager usage (CALL
|
||
// bm_info()), i.e. actual resident memory; shown next to the
|
||
// file name. Floor-dominated: the wide slice-3 schema costs
|
||
// ~115 MiB before any data.
|
||
const bmEl = document.getElementById("graph-bm");
|
||
if (bmEl && data["bm-bytes"]) {
|
||
bmEl.textContent = "(" + (data["bm-bytes"] / 1048576).toFixed(1) + " MiB)";
|
||
}
|
||
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);
|
||
// add-obj / del-obj wear the diff-mark colors of the canvas.
|
||
changesCell.innerHTML = escapeHtml(summary)
|
||
.replace(/\badd-obj\b/g,
|
||
'<span style="color: ' + DIFF_ADDED_COLOR + ';">add-obj</span>')
|
||
.replace(/\bdel-obj\b/g,
|
||
'<span style="color: ' + DIFF_REMOVED_COLOR + ';">del-obj</span>');
|
||
row.appendChild(revnCell);
|
||
row.appendChild(changesCell);
|
||
changelogBody.appendChild(row);
|
||
row.scrollIntoView({ block: "nearest" });
|
||
}
|
||
|
||
function handleMessage(raw) {
|
||
let msg;
|
||
try {
|
||
msg = parseTransitMap(JSON.parse(raw));
|
||
} catch (_err) {
|
||
return;
|
||
}
|
||
|
||
if (msg.type !== "file-change" || msg["file-id"] !== fileId) return;
|
||
|
||
appendChange(msg.revn, summarizeChanges(msg.changes));
|
||
setTimeout(refreshSyncStatus, 150);
|
||
scheduleGraphRefetch();
|
||
}
|
||
|
||
function subscribe() {
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(encodeSubscribe(fileId));
|
||
}
|
||
}
|
||
|
||
let wsClosing = false;
|
||
let wsReconnectTimer = null;
|
||
|
||
function scheduleReconnect() {
|
||
if (wsClosing || wsReconnectTimer) return;
|
||
wsReconnectTimer = setTimeout(function () {
|
||
wsReconnectTimer = null;
|
||
connect();
|
||
}, 3000);
|
||
}
|
||
|
||
function connect() {
|
||
ws = new WebSocket(wsUrl);
|
||
wsStatus.textContent = "connecting…";
|
||
|
||
ws.addEventListener("open", function () {
|
||
wsStatus.textContent = "subscribed";
|
||
subscribe();
|
||
// catch up on anything missed while disconnected
|
||
refreshSyncStatus();
|
||
scheduleGraphRefetch();
|
||
});
|
||
|
||
ws.addEventListener("message", function (event) {
|
||
handleMessage(event.data);
|
||
});
|
||
|
||
ws.addEventListener("close", function () {
|
||
wsStatus.textContent = wsClosing ? "disconnected" : "disconnected — retrying…";
|
||
scheduleReconnect();
|
||
});
|
||
|
||
ws.addEventListener("error", function () {
|
||
wsStatus.textContent = "error";
|
||
});
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
return String(text)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function renderQueryOutput(data) {
|
||
const output = document.getElementById("graph-query-output");
|
||
if (!output) return;
|
||
|
||
if (data.error) {
|
||
output.innerHTML =
|
||
"<fieldset><legend>Error</legend>"
|
||
+ "<pre>" + escapeHtml(data.error) + "</pre></fieldset>";
|
||
return;
|
||
}
|
||
|
||
const result = data["query-result"];
|
||
if (!result) {
|
||
output.innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
const truncated = result["truncated?"] ? ", truncated" : "";
|
||
// 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 legendEl = document.getElementById("graph-legend");
|
||
if (legendEl) {
|
||
legendEl.addEventListener("click", function (ev) {
|
||
const item = ev.target.closest("[data-table]");
|
||
if (!item) return;
|
||
const t = item.getAttribute("data-table");
|
||
if (hiddenTables.has(t)) hiddenTables.delete(t); else hiddenTables.add(t);
|
||
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);
|
||
// A positive depth is meaningless without combos — switch them on.
|
||
if (parseInt(depthFoldInput.value, 10) > 0 && foldToggle && !foldToggle.checked) {
|
||
foldToggle.checked = true;
|
||
localStorage.setItem("graph-fold-containers", "1");
|
||
}
|
||
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 %}
|