mirror of
https://github.com/penpot/penpot.git
synced 2026-08-09 06:18:47 +00:00
✨ Add G6 graph view to debug graph console
POC per work/g6/plan.md. New /dbg/actions/graph-data exports the in-memory Ladybug session as plain JSON (per-table node queries + multi-table IsChildOf match, row cap 100k with truncation flag). Console page renders it with AntV G6 v5 (jsDelivr CDN, antv-dagre BT layout, color+glyph per node table, validated palette) and refetches debounced on live :file-change messages. Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
parent
2ab1d07366
commit
7bb027e172
@ -88,6 +88,19 @@ Graph Console
|
||||
</table>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Graph view</legend>
|
||||
<desc>
|
||||
Renders the in-memory Ladybug graph with AntV G6 (CDN). Node color
|
||||
and glyph encode the node table; edges are <code>IsChildOf</code>
|
||||
(arrow points to parent). Redraws automatically after live changes.
|
||||
</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;"></div>
|
||||
<div id="graph-view-status" style="color: #666; font-size: 12px;"></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cypher query</legend>
|
||||
<form id="graph-query-form" method="post" action="/dbg/actions/graph-query">
|
||||
@ -137,6 +150,7 @@ Graph Console
|
||||
</main>
|
||||
|
||||
{% if session %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@antv/g6@5/dist/g6.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const fileId = "{{session.file-id}}";
|
||||
@ -154,6 +168,122 @@ Graph Console
|
||||
|
||||
let ws = null;
|
||||
|
||||
// --- G6 graph view -------------------------------------------------
|
||||
// Color+glyph per node table. Colors from the validated categorical
|
||||
// palette; within-glyph pairs are the only ones color must separate
|
||||
// alone (every node also carries a direct name label + legend).
|
||||
const NODE_STYLES = {
|
||||
"Document": { color: "#0b0b0b", glyph: "diamond", size: 28 },
|
||||
"Page": { color: "#2a78d6", glyph: "rect", size: 22 },
|
||||
"Frame": { color: "#008300", glyph: "hexagon", size: 18 },
|
||||
"Group": { color: "#4a3aa7", glyph: "hexagon", size: 18 },
|
||||
"Boolean": { color: "#eda100", glyph: "hexagon", size: 18 },
|
||||
"SVGRaw": { color: "#767470", glyph: "hexagon", size: 18 },
|
||||
"Rectangle": { color: "#eb6834", glyph: "rect", size: 14 },
|
||||
"Circle": { color: "#1baf7a", glyph: "circle", size: 14 },
|
||||
"Path": { color: "#e34948", glyph: "triangle", size: 14 },
|
||||
"Text": { color: "#e87ba4", glyph: "circle", size: 14 },
|
||||
"Image": { color: "#eda100", glyph: "star", size: 14 }
|
||||
};
|
||||
const FALLBACK_STYLE = { color: "#767470", glyph: "circle", size: 14 };
|
||||
|
||||
const graphViewStatus = document.getElementById("graph-view-status");
|
||||
let g6graph = null;
|
||||
let refetchTimer = null;
|
||||
|
||||
function nodeStyle(table) {
|
||||
return NODE_STYLES[table] || FALLBACK_STYLE;
|
||||
}
|
||||
|
||||
function toG6Data(data) {
|
||||
return {
|
||||
nodes: (data.nodes || []).map(function (n) {
|
||||
return { id: n.id, data: { label: n.label, table: n.table } };
|
||||
}),
|
||||
edges: (data.edges || [])
|
||||
.slice()
|
||||
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); })
|
||||
.map(function (e) {
|
||||
return { source: e.source, target: e.target, data: { position: e.position } };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function renderGraph(g6data) {
|
||||
if (g6graph) {
|
||||
g6graph.setData(g6data);
|
||||
return g6graph.render();
|
||||
}
|
||||
g6graph = new G6.Graph({
|
||||
container: "graph-canvas",
|
||||
data: g6data,
|
||||
autoFit: "view",
|
||||
padding: 20,
|
||||
layout: { type: "antv-dagre", rankdir: "BT", nodesep: 10, ranksep: 40 },
|
||||
node: {
|
||||
type: function (d) { return nodeStyle(d.data.table).glyph; },
|
||||
style: {
|
||||
size: function (d) { return nodeStyle(d.data.table).size; },
|
||||
fill: function (d) { return nodeStyle(d.data.table).color; },
|
||||
stroke: "#52514e",
|
||||
lineWidth: 0.5,
|
||||
labelText: function (d) { return d.data.label; },
|
||||
labelFontSize: 9,
|
||||
labelFill: "#0b0b0b",
|
||||
labelPlacement: "bottom"
|
||||
}
|
||||
},
|
||||
edge: {
|
||||
style: { stroke: "#b3b0a8", endArrow: true, endArrowSize: 6 }
|
||||
},
|
||||
behaviors: ["zoom-canvas", "drag-canvas", "drag-element"]
|
||||
});
|
||||
return g6graph.render();
|
||||
}
|
||||
|
||||
function renderGraphLegend() {
|
||||
const el = document.getElementById("graph-legend");
|
||||
el.innerHTML = Object.keys(NODE_STYLES).map(function (table) {
|
||||
const s = NODE_STYLES[table];
|
||||
return '<span style="margin-right: 1em;">'
|
||||
+ '<i style="display: inline-block; width: 10px; height: 10px;'
|
||||
+ ' margin-right: 3px; border: 1px solid #52514e;'
|
||||
+ ' background: ' + s.color + ';"></i>'
|
||||
+ escapeHtml(table) + " (" + s.glyph + ")</span>";
|
||||
}).join("");
|
||||
}
|
||||
|
||||
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.ok) throw new Error("graph-data HTTP " + resp.status);
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
graphViewStatus.textContent =
|
||||
data.nodes.length + " nodes, " + data.edges.length + " edges"
|
||||
+ " (graph revn " + data.revn + ")"
|
||||
+ (data.truncated ? " — WARNING: truncated at row cap, view is partial" : "");
|
||||
return renderGraph(toG6Data(data));
|
||||
})
|
||||
.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;
|
||||
}
|
||||
@ -274,6 +404,7 @@ Graph Console
|
||||
|
||||
appendChange(msg.revn, summarizeChanges(msg.changes));
|
||||
setTimeout(refreshSyncStatus, 150);
|
||||
scheduleGraphRefetch();
|
||||
}
|
||||
|
||||
function subscribe() {
|
||||
@ -377,6 +508,8 @@ Graph Console
|
||||
|
||||
connect();
|
||||
refreshSyncStatus();
|
||||
renderGraphLegend();
|
||||
refetchGraph();
|
||||
|
||||
window.addEventListener("beforeunload", function () {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
[app.common.time :as ct]
|
||||
[app.graph.ingest :as graph.ingest]
|
||||
[app.graph.ladybug :as ladybug]
|
||||
[app.graph.schema.nodes :as nodes]
|
||||
[app.graph.sync :as graph.sync]
|
||||
[app.msgbus :as mbus]
|
||||
[clojure.string :as str]
|
||||
@ -180,6 +181,54 @@
|
||||
:code :graph-session-not-loaded
|
||||
:hint "load a file graph before running queries")))
|
||||
|
||||
(def ^:private export-max-rows
|
||||
"Row cap for graph-view export queries; far above expected per-file node
|
||||
and edge counts. `:truncated` in the export signals when it was hit."
|
||||
100000)
|
||||
|
||||
(defn- export-nodes
|
||||
[conn]
|
||||
(reduce
|
||||
(fn [acc {:keys [table]}]
|
||||
(let [stmt (str "MATCH (n:" (nodes/match-label table)
|
||||
") RETURN n.id AS id, n.name AS name;")
|
||||
{:keys [rows truncated?]}
|
||||
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
|
||||
(-> acc
|
||||
(update :nodes into
|
||||
(map (fn [[id label]]
|
||||
{:id (str id) :label (str label) :table table}))
|
||||
rows)
|
||||
(update :truncated? #(or % truncated?)))))
|
||||
{:nodes [] :truncated? false}
|
||||
nodes/node-types))
|
||||
|
||||
(defn- export-edges
|
||||
[conn]
|
||||
(let [stmt (str "MATCH (a)-[r:IsChildOf]->(b) "
|
||||
"RETURN a.id AS source, b.id AS target, r.position AS position;")
|
||||
{:keys [rows truncated?]}
|
||||
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
|
||||
{:edges (mapv (fn [[source target position]]
|
||||
{:source (str source) :target (str target) :position position})
|
||||
rows)
|
||||
:truncated? truncated?}))
|
||||
|
||||
(defn export-graph-data!
|
||||
"Export the node/edge inventory of the in-memory graph for `profile-id`
|
||||
as plain data for the debug graph view. Returns nil when no session is
|
||||
loaded. Queries the Ladybug database (not the sync index) so the view
|
||||
reflects actual DB state, including drift."
|
||||
[profile-id]
|
||||
(when-let [{:keys [conn file-id index]} (get @sessions (session-key profile-id))]
|
||||
(let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn)
|
||||
{:keys [edges] edges-truncated? :truncated?} (export-edges conn)]
|
||||
{:file-id (str file-id)
|
||||
:revn (:revn index)
|
||||
:truncated (boolean (or nodes-truncated? edges-truncated?))
|
||||
:nodes nodes
|
||||
:edges edges})))
|
||||
|
||||
(defn console-context
|
||||
"Build template data for the graph debug console page."
|
||||
[profile-id & {:keys [query query-result error message]}]
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.json :as json]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.time :as ct]
|
||||
@ -406,6 +407,18 @@
|
||||
::yres/headers {"content-type" "application/json; charset=utf-8"}
|
||||
::yres/body (t/encode-str {:error "no-session"} {:type :json-verbose})}))
|
||||
|
||||
(defn graph-data-handler
|
||||
"Export the in-memory session graph as plain JSON (not transit) for the
|
||||
G6 graph view embedded in the console page."
|
||||
[_cfg {:keys [::session/profile-id]}]
|
||||
(if-let [data (graph.debug/export-graph-data! profile-id)]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "application/json; charset=utf-8"}
|
||||
::yres/body (json/encode data)}
|
||||
{::yres/status 404
|
||||
::yres/headers {"content-type" "application/json; charset=utf-8"}
|
||||
::yres/body (json/encode {:error "no-session"})}))
|
||||
|
||||
(defn- json-request?
|
||||
[request]
|
||||
(some-> request
|
||||
@ -696,6 +709,7 @@
|
||||
["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
|
||||
["/graph-reload" {:handler (partial graph-reload-handler cfg)}]
|
||||
["/graph-sync-status" {:handler (partial graph-sync-status-handler cfg)}]
|
||||
["/graph-data" {:handler (partial graph-data-handler cfg)}]
|
||||
["/file-import" {:handler (partial import-handler cfg)}]
|
||||
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user