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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"graphology-metrics": "^2.4.0",
"graphology-shortest-path": "^2.1.0",
"marked": "^18.0.2",
"polygon-clipping": "^0.15.7",
"sigma": "^3.0.3"
Expand Down
35 changes: 35 additions & 0 deletions src/graph/selection.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StaticUtilities } from '../utilities/static.js';
import { findShortestPath } from './shortest_path.js';

class GraphSelectionManager {
constructor(cache) {
Expand Down Expand Up @@ -291,6 +292,38 @@ class GraphSelectionManager {
await update();
}

/**
* Adds the unweighted shortest path between the two currently selected
* nodes — its nodes and the edges between them — to the selection. Computed
* on the visible subgraph so it honours active filters. Requires exactly two
* selected nodes (warns otherwise); reuses updateSelectedState so the path
* lights up through the existing selection machinery.
*/
async selectShortestPathBetweenSelected() {
if (!this.cache.graph) return;

const selected = this.cache.selectedNodes;
if (selected.length !== 2) {
this.cache.ui.warning('Select exactly two nodes to find the shortest path between them.');
return;
}

const [source, target] = selected;
const { found, nodes, edges } = findShortestPath(this.cache, source, target);

if (!found) {
this.cache.ui.warning('No path connects the two selected nodes in the visible graph.');
return;
}

const elemData = [
...nodes.map((id) => this.cache.nodeRef.get(id)),
...edges.map((id) => this.cache.edgeRef.get(id)),
].filter(Boolean);

await this.updateSelectedState(elemData, true);
}

updateSelectionCache() {
const { selectedNodes, selectedEdges, selectionMemory, selectedMemoryIndex } = this.cache;

Expand Down Expand Up @@ -383,13 +416,15 @@ class GraphSelectionManager {
}
}
const moreThanOneNodeSelected = selectedNodesCount > 1;
const exactlyTwoNodesSelected = selectedNodesCount === 2;

this.cache.ui.toggleStyleElementsThatRequireAtLeastOneSelectedNode(atLeastOneNodeSelected);
this.cache.ui.toggleStyleElementsThatRequireAtLeastOneSelectedEdge(atLeastOneEdgeSelected);
this.cache.ui.toggleStyleElementsThatRequireAtLeastOneSelectedNodeOrEdge(
atLeastOneNodeOrEdgeSelected
);
this.cache.ui.toggleStyleElementsThatRequireMoreThanOneSelectedNode(moreThanOneNodeSelected);
this.cache.ui.toggleStyleElementsThatRequireExactlyTwoSelectedNodes(exactlyTwoNodesSelected);

this.updateSelectionCache();
this.updateEnabledStateUndoRedoSelectionButtons();
Expand Down
47 changes: 47 additions & 0 deletions src/graph/shortest_path.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Node-safe (no DOM): unweighted (BFS) shortest path over the visible
// subgraph. The pure algorithm lives here so it can be unit-tested; the
// DOM-bound application path (adding the path to the selection) lives in
// graph/selection.js. Mirrors graph/communities.js and visible_graph.js.
import {bidirectional} from "../lib/graphology.bundle.mjs";
import {buildVisibleGraph} from "./visible_graph.js";

/**
* Finds the unweighted (fewest-hops) shortest path between two nodes on the
* currently visible subgraph, so it honours the active filters. The visible
* graph is an undirected multigraph keyed by edge id.
*
* @param {{nodeIDsToBeShown: Set<string>, edgeIDsToBeShown: Set<string>, edgeRef: Map<string, {source: string, target: string}>}} cache
* @param {string} source source node id
* @param {string} target target node id
* @returns {{found: boolean, nodes: string[], edges: string[], hops: number}}
* found: whether a path exists in the visible graph. nodes: the path's node
* ids in order (source → target). edges: one edge id per consecutive node
* pair (the first of any parallel edges). hops: number of edges on the path.
* When no path exists — disconnected, or an endpoint is not visible — found
* is false and nodes/edges are empty.
*/
function findShortestPath(cache, source, target) {
const graph = buildVisibleGraph(cache);

// Both endpoints must be in the visible subgraph; bidirectional throws on a
// missing node, so guard here and treat it as "no path".
if (!graph.hasNode(source) || !graph.hasNode(target)) {
return {found: false, nodes: [], edges: [], hops: 0};
}

const nodes = bidirectional(graph, source, target);
if (!nodes) return {found: false, nodes: [], edges: [], hops: 0};

// Derive one edge id per consecutive node pair. graph.edges(u, v) returns
// every parallel edge between u and v on the undirected multigraph; the
// first keeps the result deterministic (any of them is a valid hop).
const edges = [];
for (let i = 0; i < nodes.length - 1; i++) {
const between = graph.edges(nodes[i], nodes[i + 1]);
if (between.length) edges.push(between[0]);
}

return {found: true, nodes, edges, hops: nodes.length - 1};
}

export {findShortestPath};
8 changes: 4 additions & 4 deletions src/lib/graphology.bundle.mjs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/managers/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ class UIManager {
this.toggleDisabledElements(["Arrange Selection"], enable);
}

toggleStyleElementsThatRequireExactlyTwoSelectedNodes(enable) {
this.toggleDisabledElements(["Shortest Path"], enable);
}

toggleDisabledElements(headingLabels, enable) {
for (let elemID of headingLabels) {
const elem = document.getElementById(elemID);
Expand Down
5 changes: 5 additions & 0 deletions src/managers/ui_style_div.js
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,11 @@ function createStyleDiv(cache) {
"Remove the outermost layer of selected neighbor nodes (and their edges) from the ",
async () => await cache.sm.toggleSelectionByNeighbors("reduce-neighbors"));

const rowShortestPath = createNewRow(selDiv);
appendButton(rowShortestPath, "Shortest Path",
"Add the shortest path connecting the two selected nodes — its nodes and the edges between them — to the selection.\nComputed on the visible graph, so it honours active filters.\nRequires exactly two selected nodes.",
async () => await cache.sm.selectShortestPathBetweenSelected());

appendHorizontalRule(selDiv);

const rowFour = createNewRow(selDiv);
Expand Down
3 changes: 3 additions & 0 deletions src/package/vendor_entry_graphology.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ export {default as diameter} from 'graphology-metrics/graph/diameter.js';
export {default as modularity} from 'graphology-metrics/graph/modularity.js';
// Louvain community detection (pure JS, node-safe).
export {default as louvain} from 'graphology-communities-louvain';
// Unweighted (BFS) shortest path (pure JS, node-safe). bidirectional finds the
// shortest hop-count path between two nodes — used by graph/shortest_path.js.
export {bidirectional} from 'graphology-shortest-path/unweighted';
2 changes: 1 addition & 1 deletion src/package/vendor_libs.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const bundles = [
{
entry: path.join(__dirname, 'vendor_entry_graphology.mjs'),
out: path.join(libDir, 'graphology.bundle.mjs'),
pkgs: ['graphology', 'graphology-layout', 'graphology-layout-forceatlas2', 'graphology-layout-noverlap', 'graphology-metrics', 'graphology-communities-louvain', '@antv/layout', 'bubblesets-js', 'polygon-clipping'],
pkgs: ['graphology', 'graphology-layout', 'graphology-layout-forceatlas2', 'graphology-layout-noverlap', 'graphology-metrics', 'graphology-communities-louvain', 'graphology-shortest-path', '@antv/layout', 'bubblesets-js', 'polygon-clipping'],
},
{
entry: path.join(__dirname, 'vendor_entry_sigma.mjs'),
Expand Down
1 change: 1 addition & 0 deletions tests/selection-group-button-sync.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function makeCache() {
toggleStyleElementsThatRequireAtLeastOneSelectedEdge: vi.fn(),
toggleStyleElementsThatRequireAtLeastOneSelectedNodeOrEdge: vi.fn(),
toggleStyleElementsThatRequireMoreThanOneSelectedNode: vi.fn(),
toggleStyleElementsThatRequireExactlyTwoSelectedNodes: vi.fn(),
},
};
}
Expand Down
154 changes: 154 additions & 0 deletions tests/shortest-path.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, it, expect } from 'vitest'
import { findShortestPath } from '../src/graph/shortest_path.js'

// ==========================================================================
// Unweighted (BFS) shortest path over the visible subgraph (pure, node-safe).
// The DOM-bound application path (adding the path to the selection) lives in
// graph/selection.js and is exercised through GraphSelectionManager.
// ==========================================================================

// Helper: build a minimal cache-like object (same shape as metrics.test.js).
// Edge ids carry an index suffix so parallel (multi) edges stay distinct.
// A third tuple element { hidden: true } keeps the edge out of the visible
// subgraph (in edgeRef but not edgeIDsToBeShown), mirroring an active filter.
function makeCache(nodeIds, edges) {
const nodeIDsToBeShown = new Set(nodeIds)
const edgeRef = new Map()
const edgeIDsToBeShown = new Set()

edges.forEach(([source, target, opts = {}], i) => {
const id = `${source}::${target}::${i}`
edgeRef.set(id, { source, target })
if (!opts.hidden) edgeIDsToBeShown.add(id)
})

return { nodeIDsToBeShown, edgeIDsToBeShown, edgeRef }
}

const edgeId = (source, target, i) => `${source}::${target}::${i}`

describe('findShortestPath', () => {
it('finds a direct one-hop path', () => {
const cache = makeCache(['A', 'B'], [['A', 'B']])
const result = findShortestPath(cache, 'A', 'B')

expect(result.found).toBe(true)
expect(result.nodes).toEqual(['A', 'B'])
expect(result.edges).toEqual([edgeId('A', 'B', 0)])
expect(result.hops).toBe(1)
})

it('finds a multi-hop path along a chain', () => {
// A -- B -- C -- D -- E
const cache = makeCache(
['A', 'B', 'C', 'D', 'E'],
[['A', 'B'], ['B', 'C'], ['C', 'D'], ['D', 'E']]
)
const result = findShortestPath(cache, 'A', 'E')

expect(result.found).toBe(true)
expect(result.nodes).toEqual(['A', 'B', 'C', 'D', 'E'])
expect(result.edges).toEqual([
edgeId('A', 'B', 0),
edgeId('B', 'C', 1),
edgeId('C', 'D', 2),
edgeId('D', 'E', 3),
])
expect(result.hops).toBe(4)
})

it('picks the shorter of two routes', () => {
// Short: A-B-D (2 hops). Long: A-C-E-D (3 hops).
const cache = makeCache(
['A', 'B', 'C', 'D', 'E'],
[['A', 'B'], ['B', 'D'], ['A', 'C'], ['C', 'E'], ['E', 'D']]
)
const result = findShortestPath(cache, 'A', 'D')

expect(result.found).toBe(true)
expect(result.nodes).toEqual(['A', 'B', 'D'])
expect(result.hops).toBe(2)
})

it('treats the graph as undirected (reverse direction)', () => {
// Edges stored A->B, B->C; a C->A query must still find the path.
const cache = makeCache(['A', 'B', 'C'], [['A', 'B'], ['B', 'C']])
const result = findShortestPath(cache, 'C', 'A')

expect(result.found).toBe(true)
expect(result.nodes).toEqual(['C', 'B', 'A'])
expect(result.hops).toBe(2)
// Edge ids are derived regardless of stored endpoint order.
expect(result.edges).toEqual([edgeId('B', 'C', 1), edgeId('A', 'B', 0)])
})

it('returns found=false for disconnected components', () => {
// A--B and C--D are separate components.
const cache = makeCache(['A', 'B', 'C', 'D'], [['A', 'B'], ['C', 'D']])
const result = findShortestPath(cache, 'A', 'D')

expect(result.found).toBe(false)
expect(result.nodes).toEqual([])
expect(result.edges).toEqual([])
expect(result.hops).toBe(0)
})

it('returns a single-node, zero-hop path when source equals target', () => {
const cache = makeCache(['A', 'B'], [['A', 'B']])
const result = findShortestPath(cache, 'A', 'A')

expect(result.found).toBe(true)
expect(result.nodes).toEqual(['A'])
expect(result.edges).toEqual([])
expect(result.hops).toBe(0)
})

it('returns found=false when an endpoint is not visible', () => {
// C exists as an edge endpoint but is filtered out of the visible nodes.
const cache = makeCache(['A', 'B'], [['A', 'B'], ['B', 'C']])
const result = findShortestPath(cache, 'A', 'C')

expect(result.found).toBe(false)
expect(result.nodes).toEqual([])
expect(result.hops).toBe(0)
})

it('ignores a hidden/filtered edge and routes around it', () => {
// Direct A-B edge is hidden, but A-C-B is visible → 2-hop detour.
const cache = makeCache(
['A', 'B', 'C'],
[['A', 'B', { hidden: true }], ['A', 'C'], ['C', 'B']]
)
const result = findShortestPath(cache, 'A', 'B')

expect(result.found).toBe(true)
expect(result.nodes).toEqual(['A', 'C', 'B'])
expect(result.hops).toBe(2)
expect(result.edges).toEqual([edgeId('A', 'C', 1), edgeId('C', 'B', 2)])
})

it('returns found=false when the only connecting edge is hidden', () => {
const cache = makeCache(['A', 'B'], [['A', 'B', { hidden: true }]])
const result = findShortestPath(cache, 'A', 'B')

expect(result.found).toBe(false)
expect(result.hops).toBe(0)
})

it('handles parallel edges, emitting one edge id per hop', () => {
// A == B (two parallel edges), B -- C.
const cache = makeCache(
['A', 'B', 'C'],
[['A', 'B'], ['A', 'B'], ['B', 'C']]
)
const result = findShortestPath(cache, 'A', 'C')

expect(result.found).toBe(true)
expect(result.nodes).toEqual(['A', 'B', 'C'])
expect(result.hops).toBe(2)
expect(result.edges).toHaveLength(2)
// First hop is one of the two parallel A--B edges; second is the B--C edge.
expect([edgeId('A', 'B', 0), edgeId('A', 'B', 1)]).toContain(result.edges[0])
expect(result.edges[1]).toBe(edgeId('B', 'C', 2))
})
})
Loading