Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion lib/report-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
131 changes: 129 additions & 2 deletions tests/unit/report-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
enhanceReport,
resolveLegendTextColor,
bindThemeChartRedraw,
bindSigmaHoverHighlight,
destroyBubbleCharts,
statefulElementIds,
stashStatefulDom,
Expand Down Expand Up @@ -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: () => [] }) };
Expand All @@ -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;
}
Expand Down Expand Up @@ -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)', () => {
Expand Down
Loading