diff --git a/backend/resources/app/templates/graph-console.tmpl b/backend/resources/app/templates/graph-console.tmpl index 837006f277..1f7283fa01 100644 --- a/backend/resources/app/templates/graph-console.tmpl +++ b/backend/resources/app/templates/graph-console.tmpl @@ -161,6 +161,11 @@ Graph Console + @@ -345,6 +350,113 @@ Graph Console "IsInstanceOf": { stroke: "#8250df", 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; + } + // --- end graph diff -------------------------------------------------- // Legend glyph characters mirroring the G6 node types above. const GLYPH_CHARS = { diamond: "◆", rect: "■", hexagon: "⬢", circle: "●", triangle: "▲", star: "★" }; @@ -549,13 +661,46 @@ Graph Console labelText: function (d) { return d.data.label; }, labelFontSize: 9, labelFill: "#0b0b0b", - labelPlacement: "bottom" + labelPlacement: "bottom", + halo: function (d) { return !!nodeMark(d.id); }, + haloStroke: function (d) { + 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; + }, + haloStrokeOpacity: function (d) { + const m = nodeMark(d.id); + return m ? 0.9 * 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) { return edgeStyle(d.data.rel).stroke; }, + 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 } @@ -676,7 +821,8 @@ Graph Console function renderCurrent() { if (!lastGraphData) return; const data = filteredGraphData(); - renderGraphLegend(data); + 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"; @@ -684,7 +830,7 @@ Graph Console ? " — query filter: " + data.nodes.length + " of " + lastGraphData.nodes.length + " nodes" : ""; - if (data.nodes.length > RENDER_GUARD_NODES && !renderForced) { + if (shown.nodes.length > RENDER_GUARD_NODES && !renderForced) { graphViewStatus.textContent = graphStatusText(data) + filterNote + " — too large to render automatically"; if (anywayBtn) anywayBtn.style.display = "inline"; @@ -692,7 +838,7 @@ Graph Console } if (anywayBtn) anywayBtn.style.display = "none"; graphViewStatus.textContent = graphStatusText(data) + filterNote; - renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled(), + renderGraph(toG6Data(shown, collapsedComboIds(), foldEnabled(), LAYOUTS[currentLayoutName()] == null)); } @@ -714,8 +860,12 @@ Graph Console // 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; - if (sig === lastGraphSig) return; lastGraphSig = sig; renderCurrent(); }) @@ -1082,6 +1232,15 @@ Graph Console }); } + 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 layoutSelect = document.getElementById("graph-layout-select"); if (layoutSelect) { Object.keys(LAYOUTS).forEach(function (name) {