diff --git a/backend/resources/app/templates/graph-console.tmpl b/backend/resources/app/templates/graph-console.tmpl
index 1f7283fa01..1b18839ac8 100644
--- a/backend/resources/app/templates/graph-console.tmpl
+++ b/backend/resources/app/templates/graph-console.tmpl
@@ -166,6 +166,10 @@ Graph Console
fade: steps
+
@@ -213,6 +217,22 @@ Graph Console
#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; }
@@ -323,31 +343,34 @@ 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).
+ // 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: "#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 },
- "Component": { color: "#8250df", glyph: "diamond", size: 20 }
+ "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: "#767470", glyph: "circle", size: 14 };
+ 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. IsChildOf stays the
- // recessive default; IsInstanceOf echoes the Component color, dashed
- // (line style is the secondary encoding, color validated vs the gray).
+ // 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: "#8250df", lineDash: [4, 3] }
+ "IsInstanceOf": { stroke: "#8b98a9", lineDash: [4, 3] }
};
const FALLBACK_EDGE_STYLE = { stroke: "#b3b0a8" };
// --- graph diff: add/remove marks with step fade --------------------
@@ -456,6 +479,78 @@ Graph Console
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: "⬢",
@@ -558,7 +653,7 @@ Graph Console
return pos;
}
- function toG6Data(data, collapsedIds, withCombos, presetPositions) {
+ function toG6Data(data, collapsedIds, withCombos, presetPositions, diffFold) {
const nodes = data.nodes || [];
const edges = (data.edges || [])
.slice()
@@ -586,6 +681,22 @@ Graph Console
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;
@@ -655,9 +766,25 @@ Graph Console
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,
+ 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",
@@ -667,14 +794,10 @@ Graph Console
const m = nodeMark(d.id);
return m ? diffColor(m) : "#ffffff";
},
- haloLineWidth: 8,
- haloLineDash: function (d) {
- const m = nodeMark(d.id);
- return m && m.kind === "removed" ? [3, 3] : 0;
- },
+ haloLineWidth: 12,
haloStrokeOpacity: function (d) {
const m = nodeMark(d.id);
- return m ? 0.9 * diffFade(m) : 0;
+ return m ? 0.35 * diffFade(m) : 0;
},
opacity: function (d) {
const m = nodeMark(d.id);
@@ -762,7 +885,7 @@ Graph Console
function nodeItem(table, s) {
return ''
+ ''
- + (GLYPH_CHARS[s.glyph] || "●") + ' '
+ + (s.hollow ? "⬡" : (GLYPH_CHARS[s.glyph] || "●")) + ' '
+ escapeHtml(table) + "";
}
const items = Object.keys(NODE_STYLES)
@@ -778,6 +901,14 @@ Graph Console
+ (s.lineDash ? "⇢" : "→") + ' '
+ escapeHtml(rel) + "");
});
+ if (anyLiveMarks()) {
+ items.push(''
+ + '+'
+ + ' added');
+ items.push(''
+ + '−'
+ + ' removed');
+ }
// 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(" ");
@@ -838,8 +969,8 @@ Graph Console
}
if (anywayBtn) anywayBtn.style.display = "none";
graphViewStatus.textContent = graphStatusText(data) + filterNote;
- renderGraph(toG6Data(shown, collapsedComboIds(), foldEnabled(),
- LAYOUTS[currentLayoutName()] == null));
+ renderGraph(toG6Data(shown, collapsedComboIds(), foldEnabled() || diffFoldEnabled(),
+ LAYOUTS[currentLayoutName()] == null, diffFoldEnabled()));
}
function refetchGraph() {
@@ -868,6 +999,7 @@ Graph Console
lastGraphData = data;
lastGraphSig = sig;
renderCurrent();
+ schedulePulses();
})
.catch(function (err) {
graphViewStatus.textContent = "graph view error: " + err;
@@ -1241,6 +1373,15 @@ Graph Console
});
}
+ 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) {