mirror of
https://github.com/penpot/penpot.git
synced 2026-08-14 00:38:42 +00:00
setData merges datum props by id on a live G6 instance, so omitting style.collapsed retained a previous true: with "fold unchanged" on, a change inside a folded combo pulsed but never expanded it. Write the boolean explicitly both ways. Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
1435 lines
52 KiB
Cheetah
1435 lines
52 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 440px; min-width: 360px;">
|
||
|
||
<fieldset>
|
||
<legend>Load graph in memory</legend>
|
||
<desc>
|
||
Projects the Penpot file into an in-memory Ladybug database for this
|
||
admin session. Loading a new file replaces the previous one.
|
||
</desc>
|
||
<form id="graph-load-form" method="post" action="/dbg/actions/graph-load">
|
||
<div class="row">
|
||
<input type="text" style="width:420px" name="file-id"
|
||
placeholder="file-id"
|
||
value="{% if session %}{{session.file-id}}{% endif %}" />
|
||
</div>
|
||
<div class="row" style="display: flex; gap: 8px;">
|
||
<input type="submit" value="Load" />
|
||
{% if session %}
|
||
<input type="submit" value="Unload" form="graph-unload-form" />
|
||
{% endif %}
|
||
</div>
|
||
</form>
|
||
{% if session %}
|
||
<form id="graph-unload-form" method="post" action="/dbg/actions/graph-unload"></form>
|
||
{% endif %}
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>Files</legend>
|
||
<desc>Teams → projects → files. Click a file to load it.</desc>
|
||
<div id="graph-files-tree" style="font-size: 13px;">Loading…</div>
|
||
</fieldset>
|
||
|
||
{% if session %}
|
||
<fieldset>
|
||
<legend>Loaded session</legend>
|
||
<desc>
|
||
<p>
|
||
File: <b><a id="graph-penpot-link" data-file-id="{{session.file-id}}"
|
||
target="_blank">{{session.name}}</a></b> ({{session.file-id}})<br />
|
||
Loaded at revision: <b>{{session.revn}}</b><br />
|
||
Graph revision: <b id="graph-sync-revn">{% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %}</b><br />
|
||
Schema: <b>{{session.schema-version}}</b><br />
|
||
Loaded at: <b>{{session.loaded-at}}</b>
|
||
</p>
|
||
<p id="graph-sync-status">
|
||
Feed: <b id="graph-ws-status">connecting…</b>
|
||
<span id="graph-sync-error" style="display:none; margin-left: 1em; color: #b91c1c;"></span>
|
||
</p>
|
||
<form id="graph-reload-form" method="post" action="/dbg/actions/graph-reload">
|
||
<input type="submit" value="Full reload (fallback)" />
|
||
</form>
|
||
{% if session.projection.stats %}
|
||
<p>
|
||
Projection:
|
||
documents={{session.projection.stats.documents}},
|
||
pages={{session.projection.stats.pages}},
|
||
shapes={{session.projection.stats.shapes}}
|
||
</p>
|
||
{% endif %}
|
||
</desc>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>File changes (live)</legend>
|
||
<desc>
|
||
Subscribes to the workspace WebSocket feed for visibility. The backend
|
||
applies supported changes incrementally to the in-memory Ladybug graph
|
||
via msgbus (<code>:file-change</code>).
|
||
</desc>
|
||
<div id="graph-changelog-empty" style="color: #666;">Waiting for changes…</div>
|
||
<table id="graph-changelog" border="1" cellpadding="4" cellspacing="0"
|
||
style="border-collapse: collapse; width: 100%; display: none;">
|
||
<thead>
|
||
<tr>
|
||
<th>revn</th>
|
||
<th>changes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="graph-changelog-body"></tbody>
|
||
</table>
|
||
</fieldset>
|
||
|
||
<fieldset>
|
||
<legend>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. Both rels stay grey —
|
||
// dash alone separates them, keeping all chroma for diff marks.
|
||
const EDGE_STYLES = {
|
||
"IsChildOf": { stroke: "#b3b0a8" },
|
||
"IsInstanceOf": { stroke: "#8b98a9", lineDash: [4, 3] }
|
||
};
|
||
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;
|
||
},
|
||
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;">'
|
||
+ (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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function renderQueryOutput(data) {
|
||
const output = document.getElementById("graph-query-output");
|
||
if (!output) return;
|
||
|
||
if (data.error) {
|
||
output.innerHTML =
|
||
"<fieldset><legend>Error</legend>"
|
||
+ "<pre>" + escapeHtml(data.error) + "</pre></fieldset>";
|
||
return;
|
||
}
|
||
|
||
const result = data["query-result"];
|
||
if (!result) {
|
||
output.innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
const truncated = result["truncated?"] ? ", truncated" : "";
|
||
let html =
|
||
"<fieldset><legend>Results ("
|
||
+ escapeHtml(String(result["row-count"]))
|
||
+ " rows" + truncated + ")</legend>"
|
||
+ "<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\""
|
||
+ " style=\"border-collapse: collapse; width: 100%;\">"
|
||
+ "<thead><tr>";
|
||
|
||
// filter_* columns feed node ids to the graph-view filter below but
|
||
// are hidden from the table (convention; see default query).
|
||
const columns = result.columns || [];
|
||
const visibleIdx = [];
|
||
columns.forEach(function (column, i) {
|
||
if (!/^filter_/.test(column)) visibleIdx.push(i);
|
||
});
|
||
visibleIdx.forEach(function (i) {
|
||
html += "<th>" + escapeHtml(columns[i]) + "</th>";
|
||
});
|
||
html += "</tr></thead><tbody>";
|
||
|
||
(result.rows || []).forEach(function (row) {
|
||
html += "<tr>";
|
||
visibleIdx.forEach(function (i) {
|
||
html += "<td><code>" + escapeHtml(row[i]) + "</code></td>";
|
||
});
|
||
html += "</tr>";
|
||
});
|
||
|
||
html += "</tbody></table></fieldset>";
|
||
output.innerHTML = html;
|
||
|
||
// Offer to view the result as an induced subgraph: any UUID appearing
|
||
// in any result cell selects that node in the loaded graph.
|
||
const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
|
||
const ids = new Set();
|
||
(result.rows || []).forEach(function (row) {
|
||
row.forEach(function (cell) {
|
||
const found = String(cell).match(uuidRe);
|
||
if (found) found.forEach(function (m) { ids.add(m.toLowerCase()); });
|
||
});
|
||
});
|
||
if (ids.size) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.textContent = "Show result in graph view (" + ids.size + " ids)";
|
||
btn.addEventListener("click", function () { applyQueryFilter(ids); });
|
||
output.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
const queryForm = document.getElementById("graph-query-form");
|
||
if (queryForm) {
|
||
// Keep the query text across page reloads (load/unload/full-reload all
|
||
// re-render the page with the default query). Only restore over the
|
||
// default, never over a server-rendered non-default query.
|
||
const queryInput = queryForm.querySelector("textarea[name=query]");
|
||
const defaultQuery = (queryInput.dataset.defaultQuery || "").trim();
|
||
const storedQuery = localStorage.getItem("graph-query");
|
||
if (storedQuery && storedQuery.trim() !== defaultQuery
|
||
&& queryInput.value.trim() === defaultQuery) {
|
||
queryInput.value = storedQuery;
|
||
}
|
||
queryInput.addEventListener("input", function () {
|
||
localStorage.setItem("graph-query", queryInput.value);
|
||
});
|
||
queryForm.addEventListener("submit", function (event) {
|
||
event.preventDefault();
|
||
const formData = new FormData(queryForm);
|
||
fetch("/dbg/actions/graph-query", {
|
||
method: "POST",
|
||
headers: { "Accept": "application/json" },
|
||
body: formData
|
||
})
|
||
.then(function (resp) { return resp.text(); })
|
||
.then(function (text) {
|
||
renderQueryOutput(parseTransitMap(JSON.parse(text)));
|
||
})
|
||
.catch(function (err) {
|
||
renderQueryOutput({ error: String(err) });
|
||
});
|
||
});
|
||
}
|
||
|
||
// In-page expand (no Fullscreen API): keeps browser chrome and window
|
||
// manager splits usable while the graph takes the whole page. 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 %}
|