Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,6 @@ src/**/*.css.d.ts

# IDE
.idea
/.claude/settings.local.json
.playwright-mcp/
/nul
13 changes: 11 additions & 2 deletions src/GraphCanvas/GraphCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ export interface GraphCanvasProps extends Omit<GraphSceneProps, 'theme'> {
* Whether to aggregate edges with the same source and target.
*/
aggregateEdges?: boolean;

/**
* Enable web workers for layout calculations.
* This moves heavy layout computations off the main thread,
* improving UI responsiveness especially for large graphs.
* Default: false
*/
webWorkers?: boolean;
}

export type GraphCanvasRef = Omit<GraphSceneRef, 'graph' | 'renderScene'> &
Expand Down Expand Up @@ -212,8 +220,9 @@ export const GraphCanvas = forwardRef<GraphCanvasRef, GraphCanvasProps>(
// Defaults to pass to the store
const { selections, actives, collapsedNodeIds } = rest;

// It's pretty hard to get good animation performance with large n of edges/nodes
const finalAnimated = edges.length + nodes.length > 400 ? false : animated;
// The unified edge implementation performs well with larger graphs
// Raise threshold significantly since batched rendering handles animations efficiently
const finalAnimated = edges.length + nodes.length > 2000 ? false : animated;

const gl = useMemo(() => ({ ...glOptions, ...GL_DEFAULTS }), [glOptions]);
// zustand/context migration (https://github.com/pmndrs/zustand/discussions/1180)
Expand Down
121 changes: 63 additions & 58 deletions src/GraphScene.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ThreeEvent } from '@react-three/fiber';

Check failure on line 1 in src/GraphScene.tsx

View workflow job for this annotation

GitHub Actions / build

Run autofix to sort these imports!
import { useThree } from '@react-three/fiber';
import type Graph from 'graphology';
import type { ReactNode } from 'react';
Expand All @@ -19,11 +19,15 @@
import type { LayoutOverrides, LayoutTypes } from './layout';
import type { SizingType } from './sizing';
import { useStore } from './store';
import { Node } from './symbols';

Check failure on line 22 in src/GraphScene.tsx

View workflow job for this annotation

GitHub Actions / build

'Node' is defined but never used
import { Nodes } from './symbols/nodes';
import type { ClusterEventArgs } from './symbols/Cluster';
import { Cluster } from './symbols/Cluster';
import type { EdgeInterpolation, EdgeLabelPosition } from './symbols/Edge';
import { Edge } from './symbols/Edge';
import type {
EdgeInterpolation,
EdgeLabelPosition,
EdgeSubLabelPosition
} from './symbols/Edge';
import { Edges } from './symbols/edges';
import type { EdgeArrowPosition } from './symbols/edges/Edge';
import type {
Expand Down Expand Up @@ -97,6 +101,11 @@
*/
edgeLabelPosition?: EdgeLabelPosition;

/**
* Place of visibility for edge sub-labels.
*/
edgeSubLabelPosition?: EdgeSubLabelPosition;

/**
* Placement of edge arrows.
*/
Expand Down Expand Up @@ -174,6 +183,14 @@
*/
aggregateEdges?: boolean;

/**
* Enable web workers for layout calculations.
* This moves heavy layout computations off the main thread,
* improving UI responsiveness especially for large graphs.
* Default: false
*/
webWorkers?: boolean;

/**
* When a node was clicked.
*/
Expand Down Expand Up @@ -345,6 +362,7 @@
draggable,
constrainDragging = false,
edgeLabelPosition,
edgeSubLabelPosition,
edgeArrowPosition,
edgeInterpolation = 'linear',
labelFontUrl,
Expand Down Expand Up @@ -429,27 +447,28 @@
[clusterAttribute, onNodeDragged, updateLayout]
);

// Use batched Nodes component for better performance with large graphs
// Individual Node components are used internally for custom renderers and dragging
const nodeComponents = useMemo(
() =>
nodes.map(n => (
<Node
key={n?.id}
id={n?.id}
labelFontUrl={labelFontUrl}
draggable={draggable}
constrainDragging={constrainDragging}
disabled={disabled}
animated={animated}
contextMenu={contextMenu}
renderNode={renderNode}
onClick={onNodeClick}
onDoubleClick={onNodeDoubleClick}
onContextMenu={onNodeContextMenu}
onPointerOver={onNodePointerOver}
onPointerOut={onNodePointerOut}
onDragged={onNodeDraggedHandler}
/>
)),
() => (
<Nodes
nodes={nodes}
animated={animated}
disabled={disabled}
draggable={draggable}
constrainDragging={constrainDragging}
labelFontUrl={labelFontUrl}
renderNode={renderNode}
contextMenu={contextMenu}
defaultNodeSize={rest.defaultNodeSize}
onClick={onNodeClick}
onDoubleClick={onNodeDoubleClick}
onContextMenu={onNodeContextMenu}
onPointerOver={onNodePointerOver}
onPointerOut={onNodePointerOut}
onDragged={onNodeDraggedHandler}
/>
),
[
constrainDragging,
animated,
Expand All @@ -464,53 +483,39 @@
onNodeDraggedHandler,
onNodePointerOut,
onNodePointerOver,
renderNode
renderNode,
rest.defaultNodeSize
]
);

// Always use the unified Edges component for better performance
// The Edges component supports animations and handles all edge rendering efficiently
const edgeComponents = useMemo(
() =>
animated ? (
edges.map(e => (
<Edge
key={e.id}
id={e.id}
disabled={disabled}
animated={animated}
labelFontUrl={labelFontUrl}
labelPlacement={edgeLabelPosition}
arrowPlacement={edgeArrowPosition}
interpolation={edgeInterpolation}
contextMenu={contextMenu}
onClick={onEdgeClick}
onContextMenu={onEdgeContextMenu}
onPointerOver={onEdgePointerOver}
onPointerOut={onEdgePointerOut}
/>
))
) : (
<Edges
edges={edges}
disabled={disabled}
animated={animated}
labelFontUrl={labelFontUrl}
labelPlacement={edgeLabelPosition}
arrowPlacement={edgeArrowPosition}
interpolation={edgeInterpolation}
contextMenu={contextMenu}
onClick={onEdgeClick}
onContextMenu={onEdgeContextMenu}
onPointerOver={onEdgePointerOver}
onPointerOut={onEdgePointerOut}
/>
),
() => (
<Edges
edges={edges}
disabled={disabled}
animated={animated}
labelFontUrl={labelFontUrl}
labelPlacement={edgeLabelPosition}
subLabelPlacement={edgeSubLabelPosition}
arrowPlacement={edgeArrowPosition}
interpolation={edgeInterpolation}
contextMenu={contextMenu}
onClick={onEdgeClick}
onContextMenu={onEdgeContextMenu}
onPointerOver={onEdgePointerOver}
onPointerOut={onEdgePointerOut}
/>
),
[
animated,
contextMenu,
disabled,
edgeArrowPosition,
edgeInterpolation,
edgeLabelPosition,
edgeSubLabelPosition,
edges,
labelFontUrl,
onEdgeClick,
Expand Down
92 changes: 92 additions & 0 deletions src/layout/graphPositionAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Layout adapter that reads positions directly from graphology node attributes.
* Used when a worker (like FA2Layout) has already computed positions
* and stored them as x/y attributes on the graph nodes.
*/

import type Graph from 'graphology';

Check failure on line 7 in src/layout/graphPositionAdapter.ts

View workflow job for this annotation

GitHub Actions / build

Run autofix to sort these imports!
import type { DragReferences } from '../store';
import type { InternalGraphPosition, InternalVector3 } from '../types';
import type { LayoutStrategy } from './types';

export interface GraphPositionAdapterOptions {
graph: Graph;
drags?: DragReferences;
/** Whether the layout is 3D (reads z attribute) */
is3d?: boolean;
}

/**
* Creates a LayoutStrategy adapter that reads positions from graph node attributes.
* This allows using `transformGraph` instead of `transformGraphWithPositions`
* when positions are stored directly on the graph (e.g., from FA2 worker).
*/
export function createGraphPositionAdapter({
graph,
drags,
is3d = false
}: GraphPositionAdapterOptions): LayoutStrategy {
return {
getNodePosition(id: string): InternalGraphPosition {
// Check for drag position first
if (drags?.[id]?.position) {
const dragPos = drags[id].position;
return {
x: dragPos.x,
y: dragPos.y,
z: dragPos.z ?? (is3d ? 0 : 1)
} as InternalGraphPosition;
}

// Read position from graph node attributes
const attrs = graph.getNodeAttributes(id);
return {
x: (attrs as any).x ?? 0,
y: (attrs as any).y ?? 0,
z: is3d ? ((attrs as any).z ?? 0) : 1
} as InternalGraphPosition;
},

// No-op step since the worker already ran the layout
step(): boolean {
return false;
}
};
}

/**
* Creates a LayoutStrategy adapter from a pre-computed positions Map.
* Used when positions come from a custom worker that returns a Map.
*/
export function createPositionMapAdapter(
positions: Map<string, InternalVector3>,
drags?: DragReferences,
is3d = false
): LayoutStrategy {
return {
getNodePosition(id: string): InternalGraphPosition {
// Check for drag position first
if (drags?.[id]?.position) {
const dragPos = drags[id].position;
return {
x: dragPos.x,
y: dragPos.y,
z: dragPos.z ?? (is3d ? 0 : 1)
} as InternalGraphPosition;
}

// Read position from positions map
const pos = positions.get(id);
return {
x: pos?.x ?? 0,
y: pos?.y ?? 0,
z: is3d ? (pos?.z ?? 0) : 1
} as InternalGraphPosition;
},

// No-op step since the worker already ran the layout
step(): boolean {
return false;
}
};
}
2 changes: 2 additions & 0 deletions src/layout/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export * from './circular2d';

Check failure on line 1 in src/layout/index.ts

View workflow job for this annotation

GitHub Actions / build

Run autofix to sort these exports!
export * from './concentric2d';
export * from './custom';
export * from './depthUtils';
Expand All @@ -12,3 +12,5 @@
export * from './nooverlap';
export * from './recommender';
export * from './types';
export * from './useForceAtlas2Worker';
export * from './graphPositionAdapter';
13 changes: 9 additions & 4 deletions src/layout/layoutUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
* Promise based tick helper.
*/
export function tick(layout: LayoutStrategy) {
return new Promise((resolve, _reject) => {
return new Promise(async (resolve, _reject) => {

Check failure on line 10 in src/layout/layoutUtils.ts

View workflow job for this annotation

GitHub Actions / build

Promise executor functions should not be async
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

antipattern here, should be synchronous ... if layout.step() below throws, errors may no be caught properly and unhandled promise rejections can crash node (or show console errors/warnings at the very least)

let stable: boolean | undefined;

function run() {
async function run() {
if (!stable) {
stable = layout.step();
run();
stable = await layout.step();
if (!stable) {
// Use requestAnimationFrame for better performance in async scenarios
requestAnimationFrame(run);
} else {
resolve(stable);
}
} else {
Comment on lines +13 to 22
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async function 'run' is defined but uses recursion which could cause stack overflow with deep recursion. Although requestAnimationFrame is used in the updated code, ensure the recursion depth is bounded for very slow layout convergence scenarios.

Copilot uses AI. Check for mistakes.
resolve(stable);
}
Expand Down
4 changes: 2 additions & 2 deletions src/layout/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export interface LayoutStrategy {
getNodePosition: (id: string) => InternalGraphPosition;

/**
* Async stepper.
* Async stepper. Can return a boolean synchronously or a Promise<boolean> for web worker support.
*/
step: () => boolean | undefined;
step: () => boolean | undefined | Promise<boolean | undefined>;
}
Loading
Loading