mirror of
https://github.com/penpot/penpot.git
synced 2026-08-19 03:08:40 +00:00
✨ Add query-result subgraph, ws auto-reconnect, adaptive animation
The Cypher result pane now offers 'Show result in graph view': any UUID found in any result cell selects the matching nodes in the cached export and the view renders the induced subgraph (edges kept when both endpoints match); 'Show full graph' resets. No graph reconstruction from the query result is needed. The notifications websocket reconnects automatically (3 s retry) and resubscribes + refetches on reopen, so backend restarts no longer permanently kill the live feed; a lost session now reports 'no graph session (backend restarted?) - reload a file' instead of a bare 404. Animation is size-adaptive: graphs (or filtered subgraphs) up to 100 nodes render animated for didactics, larger ones stay animation-free; crossing the threshold recreates the instance like a layout switch. Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
parent
2d2c0392d4
commit
cc18dd37db
@ -172,6 +172,7 @@ Graph Console
|
|||||||
style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff;"></div>
|
style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff;"></div>
|
||||||
<div id="graph-view-status" style="color: #666; font-size: 12px;"></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-render-anyway" style="display: none;">Render anyway</button>
|
||||||
|
<button type="button" id="graph-filter-reset" style="display: none;">Show full graph</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -306,6 +307,10 @@ Graph Console
|
|||||||
// Above this many nodes the view is not rendered automatically; the
|
// Above this many nodes the view is not rendered automatically; the
|
||||||
// "Render anyway" button forces it (~1.5 s per 2k nodes, measured).
|
// "Render anyway" button forces it (~1.5 s per 2k nodes, measured).
|
||||||
const RENDER_GUARD_NODES = 4000;
|
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_XS = 40; // horizontal leaf slot spacing
|
||||||
const LAYOUT_YS = 70; // vertical rank (depth) spacing
|
const LAYOUT_YS = 70; // vertical rank (depth) spacing
|
||||||
|
|
||||||
@ -338,9 +343,11 @@ Graph Console
|
|||||||
const graphViewStatus = document.getElementById("graph-view-status");
|
const graphViewStatus = document.getElementById("graph-view-status");
|
||||||
let g6graph = null;
|
let g6graph = null;
|
||||||
let g6graphLayout = null;
|
let g6graphLayout = null;
|
||||||
|
let g6graphAnimated = null;
|
||||||
let refetchTimer = null;
|
let refetchTimer = null;
|
||||||
let lastGraphData = null;
|
let lastGraphData = null;
|
||||||
let renderForced = false;
|
let renderForced = false;
|
||||||
|
let graphFilterIds = null;
|
||||||
|
|
||||||
function currentLayoutName() {
|
function currentLayoutName() {
|
||||||
const stored = localStorage.getItem("graph-layout");
|
const stored = localStorage.getItem("graph-layout");
|
||||||
@ -446,9 +453,10 @@ Graph Console
|
|||||||
|
|
||||||
function renderGraph(g6data) {
|
function renderGraph(g6data) {
|
||||||
const layoutName = currentLayoutName();
|
const layoutName = currentLayoutName();
|
||||||
if (g6graph && g6graphLayout !== layoutName) {
|
const animate = g6data.nodes.length <= ANIMATE_MAX_NODES;
|
||||||
// Layout switch: recreate the graph (cheap with animation off);
|
if (g6graph && (g6graphLayout !== layoutName || g6graphAnimated !== animate)) {
|
||||||
// preset-tree <-> real-layout cannot be swapped on a live instance.
|
// Layout or animation-mode switch: recreate the graph (cheap);
|
||||||
|
// neither is swappable on a live instance.
|
||||||
try { g6graph.destroy(); } catch (_err) {}
|
try { g6graph.destroy(); } catch (_err) {}
|
||||||
g6graph = null;
|
g6graph = null;
|
||||||
}
|
}
|
||||||
@ -459,15 +467,14 @@ Graph Console
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
g6graphLayout = layoutName;
|
g6graphLayout = layoutName;
|
||||||
|
g6graphAnimated = animate;
|
||||||
const layoutCfg = LAYOUTS[layoutName];
|
const layoutCfg = LAYOUTS[layoutName];
|
||||||
const opts = {
|
const opts = {
|
||||||
container: "graph-canvas",
|
container: "graph-canvas",
|
||||||
data: g6data,
|
data: g6data,
|
||||||
autoFit: "view",
|
autoFit: "view",
|
||||||
autoResize: true,
|
autoResize: true,
|
||||||
// Entrance/update animation is the performance killer: 1700 nodes
|
animation: animate,
|
||||||
// took >2 min animated vs ~1.5 s without (measured, headless).
|
|
||||||
animation: false,
|
|
||||||
padding: 20,
|
padding: 20,
|
||||||
node: {
|
node: {
|
||||||
type: function (d) { return nodeStyle(d.data.table).glyph; },
|
type: function (d) { return nodeStyle(d.data.table).glyph; },
|
||||||
@ -525,18 +532,53 @@ Graph Console
|
|||||||
+ (data.truncated ? " — WARNING: truncated at row cap, view is partial" : "");
|
+ (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() {
|
function renderCurrent() {
|
||||||
if (!lastGraphData) return;
|
if (!lastGraphData) return;
|
||||||
const data = lastGraphData;
|
const data = filteredGraphData();
|
||||||
const anywayBtn = document.getElementById("graph-render-anyway");
|
const anywayBtn = document.getElementById("graph-render-anyway");
|
||||||
|
const filterBtn = document.getElementById("graph-filter-reset");
|
||||||
|
if (filterBtn) filterBtn.style.display = graphFilterIds ? "inline" : "none";
|
||||||
|
const filterNote = graphFilterIds
|
||||||
|
? " — query filter: " + data.nodes.length + " of "
|
||||||
|
+ lastGraphData.nodes.length + " nodes"
|
||||||
|
: "";
|
||||||
if (data.nodes.length > RENDER_GUARD_NODES && !renderForced) {
|
if (data.nodes.length > RENDER_GUARD_NODES && !renderForced) {
|
||||||
graphViewStatus.textContent =
|
graphViewStatus.textContent =
|
||||||
graphStatusText(data) + " — too large to render automatically";
|
graphStatusText(data) + filterNote + " — too large to render automatically";
|
||||||
if (anywayBtn) anywayBtn.style.display = "inline";
|
if (anywayBtn) anywayBtn.style.display = "inline";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (anywayBtn) anywayBtn.style.display = "none";
|
if (anywayBtn) anywayBtn.style.display = "none";
|
||||||
graphViewStatus.textContent = graphStatusText(data);
|
graphViewStatus.textContent = graphStatusText(data) + filterNote;
|
||||||
renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled()));
|
renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -547,6 +589,9 @@ Graph Console
|
|||||||
}
|
}
|
||||||
fetch("/dbg/actions/graph-data")
|
fetch("/dbg/actions/graph-data")
|
||||||
.then(function (resp) {
|
.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);
|
if (!resp.ok) throw new Error("graph-data HTTP " + resp.status);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
})
|
})
|
||||||
@ -697,6 +742,17 @@ Graph Console
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wsClosing = false;
|
||||||
|
let wsReconnectTimer = null;
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (wsClosing || wsReconnectTimer) return;
|
||||||
|
wsReconnectTimer = setTimeout(function () {
|
||||||
|
wsReconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
ws = new WebSocket(wsUrl);
|
ws = new WebSocket(wsUrl);
|
||||||
wsStatus.textContent = "connecting…";
|
wsStatus.textContent = "connecting…";
|
||||||
@ -704,6 +760,9 @@ Graph Console
|
|||||||
ws.addEventListener("open", function () {
|
ws.addEventListener("open", function () {
|
||||||
wsStatus.textContent = "subscribed";
|
wsStatus.textContent = "subscribed";
|
||||||
subscribe();
|
subscribe();
|
||||||
|
// catch up on anything missed while disconnected
|
||||||
|
refreshSyncStatus();
|
||||||
|
scheduleGraphRefetch();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("message", function (event) {
|
ws.addEventListener("message", function (event) {
|
||||||
@ -711,7 +770,8 @@ Graph Console
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("close", function () {
|
ws.addEventListener("close", function () {
|
||||||
wsStatus.textContent = "disconnected";
|
wsStatus.textContent = wsClosing ? "disconnected" : "disconnected — retrying…";
|
||||||
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("error", function () {
|
ws.addEventListener("error", function () {
|
||||||
@ -768,6 +828,24 @@ Graph Console
|
|||||||
|
|
||||||
html += "</tbody></table></fieldset>";
|
html += "</tbody></table></fieldset>";
|
||||||
output.innerHTML = html;
|
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");
|
const queryForm = document.getElementById("graph-query-form");
|
||||||
@ -857,12 +935,21 @@ Graph Console
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filterResetBtn = document.getElementById("graph-filter-reset");
|
||||||
|
if (filterResetBtn) {
|
||||||
|
filterResetBtn.addEventListener("click", function () {
|
||||||
|
graphFilterIds = null;
|
||||||
|
renderCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
refreshSyncStatus();
|
refreshSyncStatus();
|
||||||
renderGraphLegend();
|
renderGraphLegend();
|
||||||
refetchGraph();
|
refetchGraph();
|
||||||
|
|
||||||
window.addEventListener("beforeunload", function () {
|
window.addEventListener("beforeunload", function () {
|
||||||
|
wsClosing = true;
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(encodeUnsubscribe(fileId));
|
ws.send(encodeUnsubscribe(fileId));
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user