From eeb91adc3f960e43dfc1ebd834030705951cf9ae Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Sat, 10 Oct 2026 14:13:07 -0500 Subject: [PATCH] Highlighting relationships and connected classes in 2D popup graph Highlighting relationships and connected classes in 2D popup graph when a class is hovered over --- lib/report-view.js | 50 ++++++++++++- tests/unit/report-view.test.js | 131 ++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 3 deletions(-) diff --git a/lib/report-view.js b/lib/report-view.js index 7c38887..6249beb 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -216,10 +216,58 @@ export function showPopup(popupId, containerName, dot) { popup.style.display = 'block'; if (window.graphlibDot && window.graphology && window.Sigma) { const graph = buildGraphologyGraph(dot); - new window.Sigma(graph, document.getElementById(containerName), sigmaPopupSettings()); + const instance = new window.Sigma(graph, document.getElementById(containerName), sigmaPopupSettings()); + bindSigmaHoverHighlight(instance, graph); } } +/** + * Highlights a hovered sigma popup node's surroundings: the edges directly + * connected to it and the nodes on their opposite ends. The hovered node + * itself already gets sigma's built-in hover rendering. "Accent only" — the + * rest of the graph stays visible and untouched: neighbor nodes are flagged + * `highlighted` so sigma re-renders them above the labels, and incident + * edges thicken. Rebuilt on every popup open, the highlight state lives + * only as long as the sigma instance. + * + * @param {object} sigma - Live Sigma instance (needs on/setSetting/ + * scheduleRefresh). + * @param {object} graph - Graphology graph backing the instance. + * @returns {void} + */ +export function bindSigmaHoverHighlight(sigma, graph) { + if (!sigma || typeof sigma.on !== 'function' || typeof sigma.setSetting !== 'function' || !graph) return; + const highlightedNodes = new Set(); + const highlightedEdges = new Set(); + + sigma.on('enterNode', payload => { + const node = payload && payload.node; + if (!node) return; + // Clear-then-populate so hopping straight from one node to another + // (leaveNode fires first, but re-entering stays correct either way) + // never leaves stale members behind. + highlightedNodes.clear(); + highlightedEdges.clear(); + graph.forEachNeighbor(node, neighbor => highlightedNodes.add(neighbor)); + if (typeof graph.edges === 'function') { + graph.edges(node).forEach(edge => highlightedEdges.add(edge)); + } + // Scheduled, not synchronous: sigma coalesces per animation frame, so + // the leave/enter pair of one pointer movement re-renders only once. + if (typeof sigma.scheduleRefresh === 'function') sigma.scheduleRefresh(); + }); + sigma.on('leaveNode', () => { + if (!highlightedNodes.size && !highlightedEdges.size) return; + highlightedNodes.clear(); + highlightedEdges.clear(); + if (typeof sigma.scheduleRefresh === 'function') sigma.scheduleRefresh(); + }); + sigma.setSetting('nodeReducer', (node, data) => + (highlightedNodes.has(node) ? {...data, highlighted: true} : data)); + sigma.setSetting('edgeReducer', (edge, data) => + (highlightedEdges.has(edge) ? {...data, size: 3} : data)); +} + export function hidePopup() { const overlay = document.getElementById('overlay'); if (!overlay) return; // report was torn down (shared test DOM / SPA re-render) diff --git a/tests/unit/report-view.test.js b/tests/unit/report-view.test.js index 2cdf4af..9527ee3 100644 --- a/tests/unit/report-view.test.js +++ b/tests/unit/report-view.test.js @@ -14,6 +14,7 @@ import { enhanceReport, resolveLegendTextColor, bindThemeChartRedraw, + bindSigmaHoverHighlight, destroyBubbleCharts, statefulElementIds, stashStatefulDom, @@ -1067,7 +1068,8 @@ describe('showPopup sigma theme settings', () => { // Minimal widget stubs so showPopup reaches the Sigma constructor: an // empty graphlib-dot graph (the sugiyama layout then no-ops) and a Sigma - // "constructor" recording its arguments. + // "constructor" recording its arguments and returning an instance stub + // (handlers by event, settings installed via setSetting, refresh count). function stubSigmaStack() { const constructed = []; window.graphlibDot = { read: () => ({ nodes: () => [], edges: () => [] }) }; @@ -1081,7 +1083,19 @@ describe('showPopup sigma theme settings', () => { } }; window.Sigma = function (graph, container, settings) { - constructed.push({ graph, container, settings }); + const entry = { + graph, settings, + instance: { + handlers: {}, + installed: {}, + scheduledRefreshes: 0, + on(event, handler) { this.handlers[event] = handler; }, + setSetting(key, value) { this.installed[key] = value; }, + scheduleRefresh() { this.scheduledRefreshes += 1; } + } + }; + constructed.push(entry); + return entry.instance; }; return constructed; } @@ -1167,6 +1181,119 @@ describe('showPopup sigma theme settings', () => { showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}'); expect(constructed[0].settings).toBeUndefined(); }); + + it('binds neighbor and edge hover highlighting onto the sigma instance', () => { + installRadiosAndPopup('light'); + const constructed = stubSigmaStack(); + showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}'); + const instance = constructed[0].instance; + expect(typeof instance.handlers.enterNode).toBe('function'); + expect(typeof instance.handlers.leaveNode).toBe('function'); + expect(typeof instance.installed.nodeReducer).toBe('function'); + expect(typeof instance.installed.edgeReducer).toBe('function'); + }); +}); + +describe('bindSigmaHoverHighlight', () => { + // A stub graphology graph: edges eAB (A-B), eAC (A-C), eBD (B-D). D is a + // non-neighbor of A; eBD is not incident to A. + function stubHoverGraph() { + return { + forEachNeighbor(node, callback) { + ({ A: ['B', 'C'], B: ['A', 'D'], C: ['A'], D: ['B'] })[node].forEach(callback); + }, + edges(node) { + return ({ A: ['eAB', 'eAC'], B: ['eAB', 'eBD'], C: ['eAC'], D: ['eBD'] })[node]; + } + }; + } + + function stubSigmaInstance() { + const instance = { + handlers: {}, + installed: {}, + scheduledRefreshes: 0, + on(event, handler) { instance.handlers[event] = handler; }, + setSetting(key, value) { instance.installed[key] = value; }, + scheduleRefresh() { instance.scheduledRefreshes += 1; }, + fire(event, payload) { instance.handlers[event](payload); } + }; + return instance; + } + + it('flags neighbor nodes and thickens the incident edges of the hovered node', () => { + const instance = stubSigmaInstance(); + bindSigmaHoverHighlight(instance, stubHoverGraph()); + instance.fire('enterNode', { node: 'A' }); + expect(instance.scheduledRefreshes).toBe(1); + + const neighbor = { label: 'B', size: 5 }; + const stranger = { label: 'D', size: 5 }; + const hovered = { label: 'A', size: 5 }; + // Nodes across the hovered node's edges are re-rendered above labels… + expect(instance.installed.nodeReducer('B', neighbor)).toEqual({ label: 'B', size: 5, highlighted: true }); + // …while the hovered node itself (already emphasized by sigma's hover + // layer) and everything else keep their display data untouched. + expect(instance.installed.nodeReducer('D', stranger)).toBe(stranger); + expect(instance.installed.nodeReducer('A', hovered)).toBe(hovered); + + const incident = { size: 1, color: 'black' }; + const unrelated = { size: 1, color: 'black' }; + // Edges directly connected to the hovered node thicken… + expect(instance.installed.edgeReducer('eAB', incident)).toEqual({ size: 3, color: 'black' }); + expect(instance.installed.edgeReducer('eAC', incident)).toEqual({ size: 3, color: 'black' }); + // …while unrelated edges keep their display data untouched. + expect(instance.installed.edgeReducer('eBD', unrelated)).toBe(unrelated); + }); + + it('clears the highlighting when the node is left', () => { + const instance = stubSigmaInstance(); + bindSigmaHoverHighlight(instance, stubHoverGraph()); + instance.fire('enterNode', { node: 'A' }); + instance.fire('leaveNode', {}); + expect(instance.scheduledRefreshes).toBe(2); + + const data = { label: 'B', size: 5 }; + expect(instance.installed.nodeReducer('B', data)).toBe(data); + expect(instance.installed.edgeReducer('eAB', data)).toBe(data); + }); + + it('re-populates cleanly when hopping straight from one node to another', () => { + const instance = stubSigmaInstance(); + bindSigmaHoverHighlight(instance, stubHoverGraph()); + instance.fire('enterNode', { node: 'A' }); + instance.fire('enterNode', { node: 'B' }); + + // Highlighting follows B: neighbors A and D, incident edges eAB and eBD. + const gone = { label: 'C', size: 5 }; + expect(instance.installed.nodeReducer('C', gone)).toBe(gone); + expect(instance.installed.nodeReducer('D', { size: 5 })).toEqual({ size: 5, highlighted: true }); + const dropped = { size: 1, color: 'black' }; + expect(instance.installed.edgeReducer('eAC', dropped)).toBe(dropped); + expect(instance.installed.edgeReducer('eBD', { size: 1, color: 'black' })).toEqual({ size: 3, color: 'black' }); + }); + + it('ignores leave events when nothing is highlighted', () => { + const instance = stubSigmaInstance(); + bindSigmaHoverHighlight(instance, stubHoverGraph()); + instance.fire('leaveNode', {}); + expect(instance.scheduledRefreshes).toBe(0); + }); + + it('ignores enter payloads without a node', () => { + const instance = stubSigmaInstance(); + bindSigmaHoverHighlight(instance, stubHoverGraph()); + instance.fire('enterNode', {}); + expect(instance.scheduledRefreshes).toBe(0); + const data = { label: 'B', size: 5 }; + expect(instance.installed.nodeReducer('B', data)).toBe(data); + }); + + it('does not throw for a missing sigma, event-less sigma or missing graph', () => { + expect(() => bindSigmaHoverHighlight(null, stubHoverGraph())).not.toThrow(); + expect(() => bindSigmaHoverHighlight({ scheduleRefresh() {} }, stubHoverGraph())).not.toThrow(); + expect(() => bindSigmaHoverHighlight(stubSigmaInstance(), null)).not.toThrow(); + }); }); describe('bindPopupHandlers (template uses data attributes, no inline handlers)', () => {