Style Component nodes and IsInstanceOf edges in graph console

Slice-3 export sends edges with a rel field. Derive tree ranking, combo derivation and fold-ability from IsChildOf only; draw other rels as overlay edges with per-rel styles (EDGE_STYLES: IsInstanceOf violet dashed, matching the new Component diamond in NODE_STYLES). Legend now lists only displayed node tables and rels, re-rendered per redraw; help text trimmed to essentials.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-07-16 17:31:21 +02:00 committed by Andrey Antukh
parent 65d8402953
commit 7aa8e4c19c

View File

@ -166,13 +166,9 @@ Graph Console
</label> </label>
</legend> </legend>
<desc> <desc>
Renders the in-memory Ladybug graph with AntV G6 (CDN). Node color Live view of the in-memory Ladybug graph (AntV G6); the legend
and glyph encode the node table; edges are <code>IsChildOf</code> shows what is displayed. Double-click folds containers when
(arrow points to parent). With "fold containers" on, anything with folding is on.
children (except the root) renders as a foldable box: double-click
to collapse or expand. Redraws automatically after live changes
(fold state survives redraws). Very large graphs need an explicit
"Render anyway".
</desc> </desc>
<div id="graph-legend" style="font-size: 12px; margin: 4px 0;"></div> <div id="graph-legend" style="font-size: 12px; margin: 4px 0;"></div>
<div id="graph-canvas" <div id="graph-canvas"
@ -336,9 +332,19 @@ Graph Console
"Circle": { color: "#1baf7a", glyph: "circle", size: 14 }, "Circle": { color: "#1baf7a", glyph: "circle", size: 14 },
"Path": { color: "#e34948", glyph: "triangle", size: 14 }, "Path": { color: "#e34948", glyph: "triangle", size: 14 },
"Text": { color: "#e87ba4", glyph: "circle", size: 14 }, "Text": { color: "#e87ba4", glyph: "circle", size: 14 },
"Image": { color: "#eda100", glyph: "star", size: 14 } "Image": { color: "#eda100", glyph: "star", size: 14 },
"Component": { color: "#8250df", glyph: "diamond", size: 20 }
}; };
const FALLBACK_STYLE = { color: "#767470", glyph: "circle", size: 14 }; const FALLBACK_STYLE = { color: "#767470", glyph: "circle", size: 14 };
// Edge style keyed on rel (wire field since slice-3); future edge
// attributes can feed styling the same way. IsChildOf stays the
// recessive default; IsInstanceOf echoes the Component color, dashed
// (line style is the secondary encoding, color validated vs the gray).
const EDGE_STYLES = {
"IsChildOf": { stroke: "#b3b0a8" },
"IsInstanceOf": { stroke: "#8250df", lineDash: [4, 3] }
};
const FALLBACK_EDGE_STYLE = { stroke: "#b3b0a8" };
// Legend glyph characters mirroring the G6 node types above. // Legend glyph characters mirroring the G6 node types above.
const GLYPH_CHARS = { diamond: "◆", rect: "■", hexagon: "⬢", const GLYPH_CHARS = { diamond: "◆", rect: "■", hexagon: "⬢",
circle: "●", triangle: "▲", star: "★" }; circle: "●", triangle: "▲", star: "★" };
@ -397,6 +403,10 @@ Graph Console
return NODE_STYLES[table] || FALLBACK_STYLE; return NODE_STYLES[table] || FALLBACK_STYLE;
} }
function edgeStyle(rel) {
return EDGE_STYLES[rel] || FALLBACK_EDGE_STYLE;
}
function comboIdFor(nodeId) { function comboIdFor(nodeId) {
return "combo:" + nodeId; return "combo:" + nodeId;
} }
@ -441,9 +451,13 @@ Graph Console
const edges = (data.edges || []) const edges = (data.edges || [])
.slice() .slice()
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); }); .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 parentOf = {};
const childrenOf = {}; const childrenOf = {};
edges.forEach(function (e) { edges.forEach(function (e) {
if ((e.rel || "IsChildOf") !== "IsChildOf") return;
parentOf[e.source] = e.target; parentOf[e.source] = e.target;
(childrenOf[e.target] = childrenOf[e.target] || []).push(e.source); (childrenOf[e.target] = childrenOf[e.target] || []).push(e.source);
}); });
@ -484,7 +498,8 @@ Graph Console
return out; return out;
}); });
const g6edges = edges.map(function (e) { const g6edges = edges.map(function (e) {
return { source: e.source, target: e.target, data: { position: e.position } }; return { source: e.source, target: e.target,
data: { position: e.position, rel: e.rel || "IsChildOf" } };
}); });
return { nodes: g6nodes, edges: g6edges, combos: combos }; return { nodes: g6nodes, edges: g6edges, combos: combos };
} }
@ -538,7 +553,12 @@ Graph Console
} }
}, },
edge: { edge: {
style: { stroke: "#b3b0a8", endArrow: true, endArrowSize: 6 } style: {
stroke: function (d) { return edgeStyle(d.data.rel).stroke; },
lineDash: function (d) { return edgeStyle(d.data.rel).lineDash; },
endArrow: true,
endArrowSize: 6
}
}, },
combo: { combo: {
type: "rect", type: "rect",
@ -583,17 +603,39 @@ Graph Console
}); });
} }
function renderGraphLegend() { // 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"); const el = document.getElementById("graph-legend");
el.innerHTML = Object.keys(NODE_STYLES).map(function (table) { if (!el) return;
const s = NODE_STYLES[table]; 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;">' return '<span style="margin-right: 1em; white-space: nowrap;">'
+ '<span style="color: ' + s.color + '; font-size: 14px;">' + '<span style="color: ' + s.color + '; font-size: 14px;">'
+ (GLYPH_CHARS[s.glyph] || "●") + '</span> ' + (GLYPH_CHARS[s.glyph] || "●") + '</span> '
+ escapeHtml(table) + "</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>");
});
// join with a space: the spans are nowrap, so the separator is the // join with a space: the spans are nowrap, so the separator is the
// only soft-wrap opportunity in the legend row // only soft-wrap opportunity in the legend row
}).join(" "); el.innerHTML = items.join(" ");
} }
function graphStatusText(data) { function graphStatusText(data) {
@ -634,6 +676,7 @@ Graph Console
function renderCurrent() { function renderCurrent() {
if (!lastGraphData) return; if (!lastGraphData) return;
const data = filteredGraphData(); const data = filteredGraphData();
renderGraphLegend(data);
const anywayBtn = document.getElementById("graph-render-anyway"); const anywayBtn = document.getElementById("graph-render-anyway");
const filterBtn = document.getElementById("graph-filter-reset"); const filterBtn = document.getElementById("graph-filter-reset");
if (filterBtn) filterBtn.style.display = graphFilterIds ? "inline" : "none"; if (filterBtn) filterBtn.style.display = graphFilterIds ? "inline" : "none";
@ -1072,7 +1115,6 @@ Graph Console
connect(); connect();
refreshSyncStatus(); refreshSyncStatus();
renderGraphLegend();
refetchGraph(); refetchGraph();
window.addEventListener("beforeunload", function () { window.addEventListener("beforeunload", function () {