Skip to content

Commit 91e2dfd

Browse files
Highlighting relationships and connected classes in 2D popup graph (#21)
Highlighting relationships and connected classes in 2D popup graph when a class is hovered over
1 parent 9a3b17f commit 91e2dfd

2 files changed

Lines changed: 178 additions & 3 deletions

File tree

‎lib/report-view.js‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,58 @@ export function showPopup(popupId, containerName, dot) {
216216
popup.style.display = 'block';
217217
if (window.graphlibDot && window.graphology && window.Sigma) {
218218
const graph = buildGraphologyGraph(dot);
219-
new window.Sigma(graph, document.getElementById(containerName), sigmaPopupSettings());
219+
const instance = new window.Sigma(graph, document.getElementById(containerName), sigmaPopupSettings());
220+
bindSigmaHoverHighlight(instance, graph);
220221
}
221222
}
222223

224+
/**
225+
* Highlights a hovered sigma popup node's surroundings: the edges directly
226+
* connected to it and the nodes on their opposite ends. The hovered node
227+
* itself already gets sigma's built-in hover rendering. "Accent only" — the
228+
* rest of the graph stays visible and untouched: neighbor nodes are flagged
229+
* `highlighted` so sigma re-renders them above the labels, and incident
230+
* edges thicken. Rebuilt on every popup open, the highlight state lives
231+
* only as long as the sigma instance.
232+
*
233+
* @param {object} sigma - Live Sigma instance (needs on/setSetting/
234+
* scheduleRefresh).
235+
* @param {object} graph - Graphology graph backing the instance.
236+
* @returns {void}
237+
*/
238+
export function bindSigmaHoverHighlight(sigma, graph) {
239+
if (!sigma || typeof sigma.on !== 'function' || typeof sigma.setSetting !== 'function' || !graph) return;
240+
const highlightedNodes = new Set();
241+
const highlightedEdges = new Set();
242+
243+
sigma.on('enterNode', payload => {
244+
const node = payload && payload.node;
245+
if (!node) return;
246+
// Clear-then-populate so hopping straight from one node to another
247+
// (leaveNode fires first, but re-entering stays correct either way)
248+
// never leaves stale members behind.
249+
highlightedNodes.clear();
250+
highlightedEdges.clear();
251+
graph.forEachNeighbor(node, neighbor => highlightedNodes.add(neighbor));
252+
if (typeof graph.edges === 'function') {
253+
graph.edges(node).forEach(edge => highlightedEdges.add(edge));
254+
}
255+
// Scheduled, not synchronous: sigma coalesces per animation frame, so
256+
// the leave/enter pair of one pointer movement re-renders only once.
257+
if (typeof sigma.scheduleRefresh === 'function') sigma.scheduleRefresh();
258+
});
259+
sigma.on('leaveNode', () => {
260+
if (!highlightedNodes.size && !highlightedEdges.size) return;
261+
highlightedNodes.clear();
262+
highlightedEdges.clear();
263+
if (typeof sigma.scheduleRefresh === 'function') sigma.scheduleRefresh();
264+
});
265+
sigma.setSetting('nodeReducer', (node, data) =>
266+
(highlightedNodes.has(node) ? {...data, highlighted: true} : data));
267+
sigma.setSetting('edgeReducer', (edge, data) =>
268+
(highlightedEdges.has(edge) ? {...data, size: 3} : data));
269+
}
270+
223271
export function hidePopup() {
224272
const overlay = document.getElementById('overlay');
225273
if (!overlay) return; // report was torn down (shared test DOM / SPA re-render)

‎tests/unit/report-view.test.js‎

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
enhanceReport,
1515
resolveLegendTextColor,
1616
bindThemeChartRedraw,
17+
bindSigmaHoverHighlight,
1718
destroyBubbleCharts,
1819
statefulElementIds,
1920
stashStatefulDom,
@@ -1067,7 +1068,8 @@ describe('showPopup sigma theme settings', () => {
10671068

10681069
// Minimal widget stubs so showPopup reaches the Sigma constructor: an
10691070
// empty graphlib-dot graph (the sugiyama layout then no-ops) and a Sigma
1070-
// "constructor" recording its arguments.
1071+
// "constructor" recording its arguments and returning an instance stub
1072+
// (handlers by event, settings installed via setSetting, refresh count).
10711073
function stubSigmaStack() {
10721074
const constructed = [];
10731075
window.graphlibDot = { read: () => ({ nodes: () => [], edges: () => [] }) };
@@ -1081,7 +1083,19 @@ describe('showPopup sigma theme settings', () => {
10811083
}
10821084
};
10831085
window.Sigma = function (graph, container, settings) {
1084-
constructed.push({ graph, container, settings });
1086+
const entry = {
1087+
graph, settings,
1088+
instance: {
1089+
handlers: {},
1090+
installed: {},
1091+
scheduledRefreshes: 0,
1092+
on(event, handler) { this.handlers[event] = handler; },
1093+
setSetting(key, value) { this.installed[key] = value; },
1094+
scheduleRefresh() { this.scheduledRefreshes += 1; }
1095+
}
1096+
};
1097+
constructed.push(entry);
1098+
return entry.instance;
10851099
};
10861100
return constructed;
10871101
}
@@ -1167,6 +1181,119 @@ describe('showPopup sigma theme settings', () => {
11671181
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
11681182
expect(constructed[0].settings).toBeUndefined();
11691183
});
1184+
1185+
it('binds neighbor and edge hover highlighting onto the sigma instance', () => {
1186+
installRadiosAndPopup('light');
1187+
const constructed = stubSigmaStack();
1188+
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
1189+
const instance = constructed[0].instance;
1190+
expect(typeof instance.handlers.enterNode).toBe('function');
1191+
expect(typeof instance.handlers.leaveNode).toBe('function');
1192+
expect(typeof instance.installed.nodeReducer).toBe('function');
1193+
expect(typeof instance.installed.edgeReducer).toBe('function');
1194+
});
1195+
});
1196+
1197+
describe('bindSigmaHoverHighlight', () => {
1198+
// A stub graphology graph: edges eAB (A-B), eAC (A-C), eBD (B-D). D is a
1199+
// non-neighbor of A; eBD is not incident to A.
1200+
function stubHoverGraph() {
1201+
return {
1202+
forEachNeighbor(node, callback) {
1203+
({ A: ['B', 'C'], B: ['A', 'D'], C: ['A'], D: ['B'] })[node].forEach(callback);
1204+
},
1205+
edges(node) {
1206+
return ({ A: ['eAB', 'eAC'], B: ['eAB', 'eBD'], C: ['eAC'], D: ['eBD'] })[node];
1207+
}
1208+
};
1209+
}
1210+
1211+
function stubSigmaInstance() {
1212+
const instance = {
1213+
handlers: {},
1214+
installed: {},
1215+
scheduledRefreshes: 0,
1216+
on(event, handler) { instance.handlers[event] = handler; },
1217+
setSetting(key, value) { instance.installed[key] = value; },
1218+
scheduleRefresh() { instance.scheduledRefreshes += 1; },
1219+
fire(event, payload) { instance.handlers[event](payload); }
1220+
};
1221+
return instance;
1222+
}
1223+
1224+
it('flags neighbor nodes and thickens the incident edges of the hovered node', () => {
1225+
const instance = stubSigmaInstance();
1226+
bindSigmaHoverHighlight(instance, stubHoverGraph());
1227+
instance.fire('enterNode', { node: 'A' });
1228+
expect(instance.scheduledRefreshes).toBe(1);
1229+
1230+
const neighbor = { label: 'B', size: 5 };
1231+
const stranger = { label: 'D', size: 5 };
1232+
const hovered = { label: 'A', size: 5 };
1233+
// Nodes across the hovered node's edges are re-rendered above labels…
1234+
expect(instance.installed.nodeReducer('B', neighbor)).toEqual({ label: 'B', size: 5, highlighted: true });
1235+
// …while the hovered node itself (already emphasized by sigma's hover
1236+
// layer) and everything else keep their display data untouched.
1237+
expect(instance.installed.nodeReducer('D', stranger)).toBe(stranger);
1238+
expect(instance.installed.nodeReducer('A', hovered)).toBe(hovered);
1239+
1240+
const incident = { size: 1, color: 'black' };
1241+
const unrelated = { size: 1, color: 'black' };
1242+
// Edges directly connected to the hovered node thicken…
1243+
expect(instance.installed.edgeReducer('eAB', incident)).toEqual({ size: 3, color: 'black' });
1244+
expect(instance.installed.edgeReducer('eAC', incident)).toEqual({ size: 3, color: 'black' });
1245+
// …while unrelated edges keep their display data untouched.
1246+
expect(instance.installed.edgeReducer('eBD', unrelated)).toBe(unrelated);
1247+
});
1248+
1249+
it('clears the highlighting when the node is left', () => {
1250+
const instance = stubSigmaInstance();
1251+
bindSigmaHoverHighlight(instance, stubHoverGraph());
1252+
instance.fire('enterNode', { node: 'A' });
1253+
instance.fire('leaveNode', {});
1254+
expect(instance.scheduledRefreshes).toBe(2);
1255+
1256+
const data = { label: 'B', size: 5 };
1257+
expect(instance.installed.nodeReducer('B', data)).toBe(data);
1258+
expect(instance.installed.edgeReducer('eAB', data)).toBe(data);
1259+
});
1260+
1261+
it('re-populates cleanly when hopping straight from one node to another', () => {
1262+
const instance = stubSigmaInstance();
1263+
bindSigmaHoverHighlight(instance, stubHoverGraph());
1264+
instance.fire('enterNode', { node: 'A' });
1265+
instance.fire('enterNode', { node: 'B' });
1266+
1267+
// Highlighting follows B: neighbors A and D, incident edges eAB and eBD.
1268+
const gone = { label: 'C', size: 5 };
1269+
expect(instance.installed.nodeReducer('C', gone)).toBe(gone);
1270+
expect(instance.installed.nodeReducer('D', { size: 5 })).toEqual({ size: 5, highlighted: true });
1271+
const dropped = { size: 1, color: 'black' };
1272+
expect(instance.installed.edgeReducer('eAC', dropped)).toBe(dropped);
1273+
expect(instance.installed.edgeReducer('eBD', { size: 1, color: 'black' })).toEqual({ size: 3, color: 'black' });
1274+
});
1275+
1276+
it('ignores leave events when nothing is highlighted', () => {
1277+
const instance = stubSigmaInstance();
1278+
bindSigmaHoverHighlight(instance, stubHoverGraph());
1279+
instance.fire('leaveNode', {});
1280+
expect(instance.scheduledRefreshes).toBe(0);
1281+
});
1282+
1283+
it('ignores enter payloads without a node', () => {
1284+
const instance = stubSigmaInstance();
1285+
bindSigmaHoverHighlight(instance, stubHoverGraph());
1286+
instance.fire('enterNode', {});
1287+
expect(instance.scheduledRefreshes).toBe(0);
1288+
const data = { label: 'B', size: 5 };
1289+
expect(instance.installed.nodeReducer('B', data)).toBe(data);
1290+
});
1291+
1292+
it('does not throw for a missing sigma, event-less sigma or missing graph', () => {
1293+
expect(() => bindSigmaHoverHighlight(null, stubHoverGraph())).not.toThrow();
1294+
expect(() => bindSigmaHoverHighlight({ scheduleRefresh() {} }, stubHoverGraph())).not.toThrow();
1295+
expect(() => bindSigmaHoverHighlight(stubSigmaInstance(), null)).not.toThrow();
1296+
});
11701297
});
11711298

11721299
describe('bindPopupHandlers (template uses data attributes, no inline handlers)', () => {

0 commit comments

Comments
 (0)