Skip to content
289 changes: 289 additions & 0 deletions docs/demos/AggregateEdges.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import React, { useState, useMemo } from 'react';
import { GraphCanvas } from '../../src';

export default {
title: 'Demos/AggregateEdges',
component: GraphCanvas
};

export const AggregateEdgesSimple = () => {
const [aggregateEdges, setAggregateEdges] = useState(false);

const edges = [
{
source: '1',
target: '2',
id: '1-2-1'
},
{
source: '1',
target: '2',
id: '1-2-2'
},
{
source: '1',
target: '2',
id: '1-2-3'
},
{
source: '2',
target: '1',
id: '2-1-1'
},
{
source: '2',
target: '1',
id: '2-1-2'
},
{
source: '2',
target: '1',
id: '2-1-3'
}
];

// Calculate how many visual edges would be displayed when aggregated
const aggregatedCount = useMemo(() => {
const sourceTargetPairs = new Set();
edges.forEach(edge => {
sourceTargetPairs.add(`${edge.source}-${edge.target}`);
});
return sourceTargetPairs.size;
}, [edges]);

return (
<div>
<div
style={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 100,
background: 'rgba(255, 255, 255, 0.8)',
padding: '8px',
borderRadius: '4px'
}}
>
<div style={{ marginBottom: '8px' }}>
<strong>Edges:</strong>{' '}
{aggregateEdges ? aggregatedCount : edges.length}
{aggregateEdges && ` (aggregated from ${edges.length})`}
</div>
<button
onClick={() => setAggregateEdges(!aggregateEdges)}
style={{
padding: '6px 12px',
cursor: 'pointer',
background: '#4285f4',
color: 'white',
border: 'none',
borderRadius: '4px',
fontWeight: 'bold'
}}
>
{aggregateEdges ? 'Ungroup Edges' : 'Group Edges'}
</button>
</div>
<GraphCanvas
nodes={[
{
id: '1',
label: 'Node 1'
},
{
id: '2',
label: 'Node 2'
}
]}
edges={edges}
aggregateEdges={aggregateEdges}
layoutType="forceDirected2d"
layoutOverrides={{
linkDistance: 200,
nodeStrength: -1000
}}
/>
</div>
);
};

export const AggregateEdgesOneDirection = () => {
const [aggregateEdges, setAggregateEdges] = useState(false);

const edges = [
{
source: '1',
target: '2',
id: '1-2-1'
},
{
source: '1',
target: '2',
id: '1-2-2'
},
{
source: '1',
target: '2',
id: '1-2-3'
}
];

// Calculate how many visual edges would be displayed when aggregated
const aggregatedCount = useMemo(() => {
const sourceTargetPairs = new Set();
edges.forEach(edge => {
sourceTargetPairs.add(`${edge.source}-${edge.target}`);
});
return sourceTargetPairs.size;
}, [edges]);

return (
<div>
<div
style={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 100,
background: 'rgba(255, 255, 255, 0.8)',
padding: '8px',
borderRadius: '4px'
}}
>
<div style={{ marginBottom: '8px' }}>
<strong>Edges:</strong>{' '}
{aggregateEdges ? aggregatedCount : edges.length}
{aggregateEdges && ` (aggregated from ${edges.length})`}
</div>
<button
onClick={() => setAggregateEdges(!aggregateEdges)}
style={{
padding: '6px 12px',
cursor: 'pointer',
background: '#4285f4',
color: 'white',
border: 'none',
borderRadius: '4px',
fontWeight: 'bold'
}}
>
{aggregateEdges ? 'Ungroup Edges' : 'Group Edges'}
</button>
</div>
<GraphCanvas
nodes={[
{
id: '1',
label: 'Node 1'
},
{
id: '2',
label: 'Node 2'
}
]}
edges={edges}
aggregateEdges={aggregateEdges}
layoutType="forceDirected2d"
layoutOverrides={{
linkDistance: 200,
nodeStrength: -1000
}}
/>
</div>
);
};

export const ComplexExample = () => {
const [aggregateEdges, setAggregateEdges] = useState(false);

const edges = [
// Multiple edges between A and B
{ id: 'A-B-1', source: 'A', target: 'B', label: 'A to B 1' },
{ id: 'A-B-2', source: 'A', target: 'B', label: 'A to B 2' },
{ id: 'A-B-3', source: 'A', target: 'B', label: 'A to B 3' },
{ id: 'A-B-4', source: 'A', target: 'B', label: 'A to B 4' },

// Multiple edges between B and A (reverse direction)
{ id: 'B-A-1', source: 'B', target: 'A', label: 'B to A 1' },
{ id: 'B-A-2', source: 'B', target: 'A', label: 'B to A 2' },

// Multiple edges between B and C
{ id: 'B-C-1', source: 'B', target: 'C', label: 'B to C 1' },
{ id: 'B-C-2', source: 'B', target: 'C', label: 'B to C 2' },
{ id: 'B-C-3', source: 'B', target: 'C', label: 'B to C 3' },

// Single edge between C and D
{ id: 'C-D-1', source: 'C', target: 'D', label: 'C to D' },

// Multiple edges between D and E
{ id: 'D-E-1', source: 'D', target: 'E', label: 'D to E 1' },
{ id: 'D-E-2', source: 'D', target: 'E', label: 'D to E 2' },

// Multiple edges between E and A (completing the cycle)
{ id: 'E-A-1', source: 'E', target: 'A', label: 'E to A 1' },
{ id: 'E-A-2', source: 'E', target: 'A', label: 'E to A 2' },
{ id: 'E-A-3', source: 'E', target: 'A', label: 'E to A 3' }
];

// Calculate how many visual edges would be displayed when aggregated
const aggregatedCount = useMemo(() => {
const sourceTargetPairs = new Set();
edges.forEach(edge => {
sourceTargetPairs.add(`${edge.source}-${edge.target}`);
});
return sourceTargetPairs.size;
}, [edges]);

return (
<div>
<div
style={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 100,
background: 'rgba(255, 255, 255, 0.8)',
padding: '8px',
borderRadius: '4px'
}}
>
<div style={{ marginBottom: '8px' }}>
<strong>Edges:</strong>{' '}
{aggregateEdges ? aggregatedCount : edges.length}
{aggregateEdges && ` (aggregated from ${edges.length})`}
</div>
<button
onClick={() => setAggregateEdges(!aggregateEdges)}
style={{
padding: '6px 12px',
cursor: 'pointer',
background: '#4285f4',
color: 'white',
border: 'none',
borderRadius: '4px',
fontWeight: 'bold'
}}
>
{aggregateEdges ? 'Ungroup Edges' : 'Group Edges'}
</button>
</div>
<GraphCanvas
nodes={[
{ id: 'A', label: 'Node A' },
{ id: 'B', label: 'Node B' },
{ id: 'C', label: 'Node C' },
{ id: 'D', label: 'Node D' },
{ id: 'E', label: 'Node E' }
]}
edges={edges}
aggregateEdges={aggregateEdges}
layoutType="forceDirected2d"
labelType="all"
layoutOverrides={{
linkDistance: 300,
nodeStrength: -2000
}}
/>
</div>
);
};
7 changes: 7 additions & 0 deletions src/GraphCanvas/GraphCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ export interface GraphCanvasProps extends Omit<GraphSceneProps, 'theme'> {
* When the canvas was clicked but didn't hit a node/edge.
*/
onCanvasClick?: (event: MouseEvent) => void;

/**
* Whether to aggregate edges with the same source and target.
*/
aggregateEdges?: boolean;
}

export type GraphCanvasRef = Omit<GraphSceneRef, 'graph' | 'renderScene'> &
Expand Down Expand Up @@ -128,6 +133,7 @@ export const GraphCanvas: FC<GraphCanvasProps & { ref?: Ref<GraphCanvasRef> }> =
disabled,
onLasso,
onLassoEnd,
aggregateEdges,
...rest
},
ref: Ref<GraphCanvasRef>
Expand Down Expand Up @@ -228,6 +234,7 @@ export const GraphCanvas: FC<GraphCanvasProps & { ref?: Ref<GraphCanvasRef> }> =
defaultNodeSize={defaultNodeSize}
minNodeSize={minNodeSize}
maxNodeSize={maxNodeSize}
aggregateEdges={aggregateEdges}
{...rest}
/>
</Suspense>
Expand Down
32 changes: 29 additions & 3 deletions src/GraphScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import React, {
Ref,
useCallback,
useImperativeHandle,
useMemo
useMemo,
useEffect
} from 'react';
import { useGraph } from './useGraph';
import { LayoutOverrides, LayoutTypes } from './layout';
Expand Down Expand Up @@ -39,6 +40,7 @@ import { useStore } from './store';
import Graph from 'graphology';
import type { ThreeEvent } from '@react-three/fiber';
import { useThree } from '@react-three/fiber';
import { aggregateEdges as aggregateEdgesUtil } from './utils/aggregateEdges';

export interface GraphSceneProps {
/**
Expand Down Expand Up @@ -168,6 +170,11 @@ export interface GraphSceneProps {
*/
layoutOverrides?: LayoutOverrides;

/**
* Whether to aggregate edges with the same source and target.
*/
aggregateEdges?: boolean;

/**
* When a node was clicked.
*/
Expand Down Expand Up @@ -345,11 +352,12 @@ export const GraphScene: FC<GraphSceneProps & { ref?: Ref<GraphSceneRef> }> =
labelFontUrl,
renderNode,
onRenderCluster,
aggregateEdges,
...rest
},
ref
) => {
const { layoutType, clusterAttribute } = rest;
const { layoutType, clusterAttribute, labelType } = rest;

// Get the gl/scene/camera for render shortcuts
const gl = useThree(state => state.gl);
Expand All @@ -371,9 +379,27 @@ export const GraphScene: FC<GraphSceneProps & { ref?: Ref<GraphSceneRef> }> =
// Get the graph and nodes via the store for memo
const graph = useStore(state => state.graph);
const nodes = useStore(state => state.nodes);
const edges = useStore(state => state.edges);
const edgesStore = useStore(state => state.edges);
const setEdges = useStore(state => state.setEdges);
const clusters = useStore(state => [...state.clusters.values()]);

// Process edges based on aggregation setting and update store
const edges = useMemo(() => {
if (aggregateEdges) {
const aggregatedEdges = aggregateEdgesUtil(graph, labelType);
return aggregatedEdges;
} else {
return edgesStore;
}
}, [edgesStore, aggregateEdges, graph, labelType]);

// Update the store if edges were aggregated (moved to useEffect to avoid render cycle error)
useEffect(() => {
if (aggregateEdges && edgesStore.length !== edges.length) {
setEdges(edges);
}
}, [edges, edgesStore.length, setEdges, aggregateEdges]);

// Center the graph on the nodes
const { centerNodesById, fitNodesInViewById, isCentered } =
useCenterGraph({
Expand Down
2 changes: 1 addition & 1 deletion src/symbols/Label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const Label: FC<LabelProps> = ({
);

return (
<Billboard position={[0, 0, 1]}>
<Billboard position={[0, 0, 1]} renderOrder={1}>
<Text
font={fontUrl}
fontSize={fontSize}
Expand Down
Loading