Skip to content

Commit bbb65b2

Browse files
committed
test
1 parent 51c0349 commit bbb65b2

14 files changed

Lines changed: 453 additions & 36 deletions

File tree

cmd/ui/src/components/SigmaChart/GraphEvents.tsx

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
// SPDX-License-Identifier: Apache-2.0
1616

1717
import { useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core';
18+
import { useTheme } from 'bh-shared-ui';
1819
import type { Attributes } from 'graphology-types';
19-
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useState } from 'react';
20+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useState } from 'react';
2021
import type { SigmaNodeEventPayload } from 'sigma/sigma';
2122
import type { Coordinates } from 'sigma/types';
2223
import {
@@ -30,10 +31,11 @@ import {
3031
resetCamera,
3132
} from 'src/ducks/graph/utils';
3233
import { bezier } from 'src/rendering/utils/bezier';
33-
import { getNodeRadius } from 'src/rendering/utils/utils';
34+
import { blendHexColors, getNodeRadius } from 'src/rendering/utils/utils';
3435
import { useAppSelector } from 'src/store';
3536
import { preventAllDefaults } from 'src/utils';
3637
import { sequentialLayout, standardLayout } from 'src/views/Explore/utils';
38+
import { getFullPathHighlightedEntities, getIsHighlightedItemInGraph } from './utils';
3739

3840
interface SigmaChartRef {
3941
resetCamera: () => void;
@@ -91,6 +93,9 @@ export const GraphEvents = forwardRef(function GraphEvents(
9193
ref
9294
) {
9395
const exploreLayout = useAppSelector((state) => state.global.view.exploreLayout);
96+
const darkMode = useAppSelector((state) => state.global.view.darkMode);
97+
const theme = useTheme();
98+
const isExploreGraphHighlight = useAppSelector((state) => state.global.view.isExploreGraphHighlight);
9499

95100
const sigma = useSigma();
96101
const graph = sigma.getGraph();
@@ -274,23 +279,67 @@ export const GraphEvents = forwardRef(function GraphEvents(
274279
sigmaContainer,
275280
]);
276281

282+
const isHighlightedItemInGraph = useMemo(
283+
() => getIsHighlightedItemInGraph(graph, highlightedItem),
284+
[graph, highlightedItem]
285+
);
286+
287+
const { highlightedNodeIds, highlightedEdgeIds } = useMemo(
288+
() => getFullPathHighlightedEntities(graph, highlightedItem),
289+
[graph, highlightedItem]
290+
);
291+
277292
useEffect(() => {
293+
const bgColor = theme.neutral.primary;
294+
// dimFactor is how much of the original color shows through (1 = full, 0 = fully hidden).
295+
// Equivalent to 1 - nodeBlend from the old CPU-blend approach: dark 1-0.5=0.5, light 1-0.8=0.2.
296+
const nodeDimFactor = darkMode ? 0.5 : 0.2;
297+
const edgeBlend = darkMode ? 0.6 : 0.9;
298+
const labelDimFactor = darkMode ? 0.3 : 0.1;
299+
278300
setSettings({
279301
nodeReducer: (node, data) => {
280302
const camera = sigma.getCamera();
303+
const isDimmed =
304+
isExploreGraphHighlight !== false &&
305+
!!highlightedItem &&
306+
!highlightedNodeIds.has(node) &&
307+
isHighlightedItemInGraph;
308+
281309
return {
282310
...data,
283311
highlighted: node === highlightedItem,
284312
inverseSqrtZoomRatio: 1 / Math.sqrt(camera.ratio),
313+
isDimmed,
314+
// Always pass the canvas background color so the shader can dim toward it.
315+
graphBgColor: bgColor,
316+
...(isDimmed && {
317+
labelDimFactor,
318+
dimFactor: nodeDimFactor,
319+
// Keep label background dimmed — labels are rendered on canvas 2D, not by the shader.
320+
backgroundColor: blendHexColors(data.backgroundColor ?? bgColor, bgColor, 1 - nodeDimFactor),
321+
}),
285322
};
286323
},
287324
edgeReducer: (edge, data) => {
288325
const camera = sigma.getCamera();
326+
const isDimmed =
327+
isExploreGraphHighlight !== false &&
328+
!!highlightedItem &&
329+
!highlightedEdgeIds.has(edge) &&
330+
isHighlightedItemInGraph;
331+
289332
const newData: Attributes = {
290333
...data,
291334
hidden: false,
292335
highlighted: edge === highlightedItem,
293336
inverseSqrtZoomRatio: 1 / Math.sqrt(camera.ratio),
337+
isDimmed,
338+
backgroundColor: bgColor,
339+
...(isDimmed && {
340+
labelDimFactor,
341+
color: blendHexColors(data.color, bgColor, edgeBlend),
342+
}),
294343
};
295344

296345
if (data.type === 'curved') {
@@ -302,7 +351,19 @@ export const GraphEvents = forwardRef(function GraphEvents(
302351
return newData;
303352
},
304353
});
305-
}, [curvedEdgeReducer, highlightedItem, selfEdgeReducer, setSettings, sigma]);
354+
}, [
355+
curvedEdgeReducer,
356+
darkMode,
357+
highlightedEdgeIds,
358+
highlightedItem,
359+
highlightedNodeIds,
360+
selfEdgeReducer,
361+
setSettings,
362+
sigma,
363+
theme.neutral.primary,
364+
isHighlightedItemInGraph,
365+
isExploreGraphHighlight,
366+
]);
306367

307368
// Toggle off edge labels when dragging a node to avoid performance hit
308369
useLayoutEffect(() => {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright 2026 Specter Ops, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
import { MultiDirectedGraph } from 'graphology';
18+
import { getFullPathHighlightedEntities, getIsHighlightedItemInGraph } from './utils';
19+
20+
// Linear directed graph with a split inbound on node-c:
21+
// node-a → node-b → node-c ← node-d
22+
const buildTestGraph = () => {
23+
const graph = new MultiDirectedGraph();
24+
graph.addNode('node-a');
25+
graph.addNode('node-b');
26+
graph.addNode('node-c');
27+
graph.addNode('node-d');
28+
graph.addDirectedEdgeWithKey('edge-ab', 'node-a', 'node-b');
29+
graph.addDirectedEdgeWithKey('edge-bc', 'node-b', 'node-c');
30+
graph.addDirectedEdgeWithKey('edge-dc', 'node-d', 'node-c');
31+
return graph;
32+
};
33+
34+
describe('SigmaChart Utils', () => {
35+
let graph: MultiDirectedGraph;
36+
37+
beforeEach(() => {
38+
graph = buildTestGraph();
39+
});
40+
41+
describe('getIsHighlightedItemInGraph', () => {
42+
it('returns true when the highlighted item is a node in the graph', () => {
43+
expect(getIsHighlightedItemInGraph(graph, 'node-a')).toBe(true);
44+
});
45+
46+
it('returns true when the highlighted item is an edge in the graph', () => {
47+
expect(getIsHighlightedItemInGraph(graph, 'edge-ab')).toBe(true);
48+
});
49+
50+
it('returns undefined when highlightedItem is null', () => {
51+
expect(getIsHighlightedItemInGraph(graph, null)).toBeUndefined();
52+
});
53+
54+
it('returns false when the highlighted item is not in the graph', () => {
55+
expect(getIsHighlightedItemInGraph(graph, 'node-unknown')).toBe(false);
56+
});
57+
});
58+
59+
describe('getFullPathHighlightedEntities', () => {
60+
describe('when a node is selected', () => {
61+
it('returns the full inbound and outbound path when selecting a middle node', () => {
62+
const { highlightedNodeIds, highlightedEdgeIds } = getFullPathHighlightedEntities(graph, 'node-b');
63+
64+
expect([...highlightedNodeIds]).toEqual(expect.arrayContaining(['node-a', 'node-b', 'node-c']));
65+
expect([...highlightedEdgeIds]).toEqual(expect.arrayContaining(['edge-ab', 'edge-bc']));
66+
});
67+
68+
it('returns only the outbound path when selecting the start node', () => {
69+
const { highlightedNodeIds, highlightedEdgeIds } = getFullPathHighlightedEntities(graph, 'node-a');
70+
71+
expect([...highlightedNodeIds]).toEqual(expect.arrayContaining(['node-a', 'node-b', 'node-c']));
72+
expect([...highlightedEdgeIds]).toEqual(expect.arrayContaining(['edge-ab', 'edge-bc']));
73+
});
74+
75+
it('returns all inbound paths when selecting the end node with multiple inbound edges', () => {
76+
const { highlightedNodeIds, highlightedEdgeIds } = getFullPathHighlightedEntities(graph, 'node-c');
77+
78+
expect([...highlightedNodeIds]).toEqual(
79+
expect.arrayContaining(['node-a', 'node-b', 'node-c', 'node-d'])
80+
);
81+
expect([...highlightedEdgeIds]).toEqual(expect.arrayContaining(['edge-ab', 'edge-bc', 'edge-dc']));
82+
});
83+
84+
it('returns only the selected node when it has no connections', () => {
85+
const isolatedGraph = new MultiDirectedGraph();
86+
isolatedGraph.addNode('node-isolated');
87+
const { highlightedNodeIds, highlightedEdgeIds } = getFullPathHighlightedEntities(
88+
isolatedGraph,
89+
'node-isolated'
90+
);
91+
92+
expect([...highlightedNodeIds]).toEqual(['node-isolated']);
93+
expect(highlightedEdgeIds.size).toBe(0);
94+
});
95+
});
96+
97+
describe('when an edge is selected', () => {
98+
it('returns the edge and both of its endpoint nodes', () => {
99+
const { highlightedNodeIds, highlightedEdgeIds } = getFullPathHighlightedEntities(graph, 'edge-ab');
100+
101+
expect([...highlightedNodeIds]).toEqual(expect.arrayContaining(['node-a', 'node-b']));
102+
expect([...highlightedEdgeIds]).toEqual(['edge-ab']);
103+
});
104+
});
105+
106+
it('returns empty sets when highlightedItem is null', () => {
107+
const { highlightedNodeIds, highlightedEdgeIds } = getFullPathHighlightedEntities(graph, null);
108+
109+
expect(highlightedNodeIds.size).toBe(0);
110+
expect(highlightedEdgeIds.size).toBe(0);
111+
});
112+
});
113+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright 2026 Specter Ops, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
import AbstractGraph, { Attributes } from 'graphology-types';
18+
19+
export const getIsHighlightedItemInGraph = (
20+
graph: AbstractGraph<Attributes, Attributes, Attributes>,
21+
highlightedItem: string | null
22+
) => {
23+
if (!highlightedItem) return;
24+
return graph.hasNode(highlightedItem) || graph.hasEdge(highlightedItem);
25+
};
26+
27+
// Compute which nodes and edges should remain fully visible when an item is selected.
28+
// Nodes: two independent directional BFS passes from the selected node —
29+
// outbound (follows edges pointing away) and inbound (follows edges pointing toward).
30+
// This highlights the whole directed path in both directions without mixing traversal directions.
31+
// Edges: all edges directly connected to the selected edge endpoints (1-hop, unchanged).
32+
33+
export const getFullPathHighlightedEntities = (
34+
graph: AbstractGraph<Attributes, Attributes, Attributes>,
35+
highlightedItem: string | null
36+
) => {
37+
const highlightedNodeIds = new Set<string>();
38+
const highlightedEdgeIds = new Set<string>();
39+
40+
if (highlightedItem) {
41+
if (graph.hasNode(highlightedItem)) {
42+
highlightedNodeIds.add(highlightedItem);
43+
44+
// Outbound BFS: follow edges pointing away (source → target).
45+
const outboundQueue: string[] = [highlightedItem];
46+
while (outboundQueue.length > 0) {
47+
const current = outboundQueue.shift()!;
48+
graph.outEdges(current).forEach((edge) => {
49+
highlightedEdgeIds.add(edge);
50+
const neighbor = graph.target(edge);
51+
if (!highlightedNodeIds.has(neighbor)) {
52+
highlightedNodeIds.add(neighbor);
53+
outboundQueue.push(neighbor);
54+
}
55+
});
56+
}
57+
58+
// Inbound BFS: follow edges pointing toward (target → source).
59+
// Uses its own visited set so outbound discoveries don't cause early stops.
60+
const inboundVisited = new Set<string>([highlightedItem]);
61+
const inboundQueue: string[] = [highlightedItem];
62+
while (inboundQueue.length > 0) {
63+
const current = inboundQueue.shift()!;
64+
graph.inEdges(current).forEach((edge) => {
65+
highlightedEdgeIds.add(edge);
66+
const neighbor = graph.source(edge);
67+
if (!inboundVisited.has(neighbor)) {
68+
inboundVisited.add(neighbor);
69+
highlightedNodeIds.add(neighbor);
70+
inboundQueue.push(neighbor);
71+
}
72+
});
73+
}
74+
} else if (graph.hasEdge(highlightedItem)) {
75+
highlightedEdgeIds.add(highlightedItem);
76+
graph.extremities(highlightedItem).forEach((directNodes) => highlightedNodeIds.add(directNodes));
77+
}
78+
}
79+
80+
return { highlightedNodeIds, highlightedEdgeIds };
81+
};

cmd/ui/src/ducks/global/actions.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,6 @@ export const setIsExploreTableSelected = (isExploreTableSelected: boolean): type
8585
};
8686
};
8787

88-
export const setIsExploreLayoutSelected = (isExploreLayoutSelected: boolean): types.GlobalViewActionTypes => {
89-
return {
90-
type: types.GLOBAL_SET_IS_EXPLORE_LAYOUT_SELECTED,
91-
isExploreLayoutSelected,
92-
};
93-
};
94-
9588
export const setSelectedExploreTableColumns = (
9689
selectedExploreTableColumns: Record<string, boolean>
9790
): types.GlobalViewActionTypes => {
@@ -148,3 +141,10 @@ export const setAssetGroupEdit = (assetGroupId: number | null): types.GlobalOpti
148141
assetGroupId,
149142
};
150143
};
144+
145+
export const setIsExploreGraphHighlight = (isExploreGraphHighlight: boolean): types.GlobalViewActionTypes => {
146+
return {
147+
type: types.GLOBAL_SET_IS_EXPLORE_GRAPH_HIGHLIGHT,
148+
isExploreGraphHighlight,
149+
};
150+
};

cmd/ui/src/ducks/global/reducer.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
// SPDX-License-Identifier: Apache-2.0
1616

1717
import { combineReducers } from '@reduxjs/toolkit';
18-
import { DEFAULT_PINNED_COLUMN_KEYS, defaultColumns, defaultGraphLayout } from 'bh-shared-ui';
1918
import { castDraft, produce } from 'immer';
2019
import assign from 'lodash/assign';
2120
import * as types from './types';
@@ -24,12 +23,12 @@ const initialGlobalState: types.GlobalViewState = {
2423
notifications: [],
2524
darkMode: false,
2625
autoRunQueries: true,
27-
exploreLayout: defaultGraphLayout,
26+
exploreLayout: undefined,
2827
isExploreTableSelected: false,
29-
isExploreLayoutSelected: false,
30-
selectedExploreTableColumns: defaultColumns,
31-
pinnedExploreTableColumns: DEFAULT_PINNED_COLUMN_KEYS,
28+
selectedExploreTableColumns: undefined,
29+
pinnedExploreTableColumns: undefined,
3230
timeoutSetting: false,
31+
isExploreGraphHighlight: true,
3332
};
3433

3534
const globalViewReducer = (state = initialGlobalState, action: types.GlobalViewActionTypes) => {
@@ -50,8 +49,6 @@ const globalViewReducer = (state = initialGlobalState, action: types.GlobalViewA
5049
draft.exploreLayout = action.exploreLayout;
5150
} else if (action.type === types.GLOBAL_SET_IS_EXPLORE_TABLE_SELECTED) {
5251
draft.isExploreTableSelected = action.isExploreTableSelected;
53-
} else if (action.type === types.GLOBAL_SET_IS_EXPLORE_LAYOUT_SELECTED) {
54-
draft.isExploreLayoutSelected = action.isExploreLayoutSelected;
5552
} else if (action.type === types.GLOBAL_SET_AUTO_RUN_QUERIES) {
5653
draft.autoRunQueries = action.autoRunQueries;
5754
} else if (action.type === types.GLOBAL_SET_TIMEOUT_SETTING) {
@@ -60,6 +57,8 @@ const globalViewReducer = (state = initialGlobalState, action: types.GlobalViewA
6057
draft.selectedExploreTableColumns = action.selectedExploreTableColumns;
6158
} else if (action.type === types.GLOBAL_SET_PINNED_EXPLORE_TABLE_COLUMNS) {
6259
draft.pinnedExploreTableColumns = action.pinnedExploreTableColumns;
60+
} else if (action.type === types.GLOBAL_SET_IS_EXPLORE_GRAPH_HIGHLIGHT) {
61+
draft.isExploreGraphHighlight = action.isExploreGraphHighlight;
6362
}
6463
});
6564
};

0 commit comments

Comments
 (0)