Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Constrain Nodes to Clusters #287

Merged
merged 5 commits into from
Oct 28, 2024
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
103 changes: 87 additions & 16 deletions docs/demos/Cluster.story.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,107 @@
import React from 'react';
import React, { useCallback, useState } from 'react';
import { GraphCanvas, lightTheme } from '../../src';
import { clusterNodes, clusterEdges, random, singleNodeClusterNodes, imbalancedClusterNodes, manyClusterNodes } from '../assets/demo';
import {
clusterNodes,
clusterEdges,
random,
singleNodeClusterNodes,
imbalancedClusterNodes,
manyClusterNodes
} from '../assets/demo';

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

export const Simple = () => (
<GraphCanvas nodes={clusterNodes} draggable edges={[]} clusterAttribute="type" />
);
export const Simple = () => {
const [nodes, setNodes] = useState(clusterNodes);

const addNode = useCallback(() => {
const next = nodes.length + 2;
setNodes(prev => [
...prev,
{
id: `${next}`,
label: `Node ${next}`,
fill: '#3730a3',
data: {
type: 'IP',
segment: next % 2 === 0 ? 'A' : undefined
}
}
]);
}, [nodes]);

return (
<>
<GraphCanvas nodes={nodes} draggable edges={[]} clusterAttribute="type" constrainDragging />
<div style={{ zIndex: 9, position: 'absolute', top: 15, right: 15 }}>
<button type="button" onClick={addNode}>
Add node
</button>
</div>
</>
);
};

const clusterNodesWithSizes = clusterNodes.map(node => ({
...node,
size: random(0, 50)
}));

export const Sizes = () => (
<GraphCanvas nodes={clusterNodesWithSizes} draggable edges={[]} clusterAttribute="type" />
<GraphCanvas
nodes={clusterNodesWithSizes}
draggable
edges={[]}
clusterAttribute="type"
/>
);

export const SingleNodeClusters = () => (
<GraphCanvas nodes={singleNodeClusterNodes} draggable edges={[]} clusterAttribute="type" />
<GraphCanvas
nodes={singleNodeClusterNodes}
draggable
edges={[]}
clusterAttribute="type"
/>
);

export const ImbalancedClusters = () => (
<GraphCanvas nodes={imbalancedClusterNodes} draggable edges={[]} clusterAttribute="type" />
<GraphCanvas
nodes={imbalancedClusterNodes}
draggable
edges={[]}
clusterAttribute="type"
/>
);

export const LargeDataset = () => (
<GraphCanvas nodes={manyClusterNodes} draggable edges={[]} clusterAttribute="type" />
<GraphCanvas
nodes={manyClusterNodes}
draggable
edges={[]}
clusterAttribute="type"
/>
);

export const Edges = () => (
<GraphCanvas nodes={clusterNodes} draggable edges={clusterEdges} clusterAttribute="type" />
<GraphCanvas
nodes={clusterNodes}
draggable
edges={clusterEdges}
clusterAttribute="type"
/>
);

export const Selections = () => (
<GraphCanvas nodes={clusterNodes} selections={[clusterNodes[0].id]} edges={clusterEdges} clusterAttribute="type" />
<GraphCanvas
nodes={clusterNodes}
selections={[clusterNodes[0].id]}
edges={clusterEdges}
clusterAttribute="type"
/>
);

export const Events = () => (
Expand All @@ -47,7 +111,9 @@ export const Events = () => (
edges={clusterEdges}
clusterAttribute="type"
onClusterPointerOut={cluster => console.log('cluster pointer out', cluster)}
onClusterPointerOver={cluster => console.log('cluster pointer over', cluster)}
onClusterPointerOver={cluster =>
console.log('cluster pointer over', cluster)
}
onClusterClick={cluster => console.log('cluster click', cluster)}
/>
);
Expand Down Expand Up @@ -100,15 +166,20 @@ export const LabelsOnly = () => (
export const ThreeDimensions = () => (
<GraphCanvas
nodes={clusterNodesWithSizes}
draggable edges={[]}
draggable
edges={[]}
layoutType="forceDirected3d"
clusterAttribute="type"
>
<directionalLight position={[0, 5, -4]} intensity={1} />
</GraphCanvas>
);


export const Partial = () => (
<GraphCanvas nodes={clusterNodes} draggable edges={[]} clusterAttribute="segment" />
);
<GraphCanvas
nodes={clusterNodes}
draggable
edges={[]}
clusterAttribute="segment"
/>
);
11 changes: 10 additions & 1 deletion src/GraphScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ export interface GraphSceneProps {
*/
draggable?: boolean;

/**
* Constrain dragging to the cluster bounds. Default is `false`.
*/
constrainDragging?: boolean;

/**
* Render a custom node
*/
Expand Down Expand Up @@ -322,6 +327,7 @@ export const GraphScene: FC<GraphSceneProps & { ref?: Ref<GraphSceneRef> }> =
animated,
disabled,
draggable,
constrainDragging,
edgeLabelPosition,
edgeArrowPosition,
edgeInterpolation,
Expand Down Expand Up @@ -384,6 +390,7 @@ export const GraphScene: FC<GraphSceneProps & { ref?: Ref<GraphSceneRef> }> =
id={n?.id}
labelFontUrl={labelFontUrl}
draggable={draggable}
constrainDragging={constrainDragging}
disabled={disabled}
animated={animated}
contextMenu={contextMenu}
Expand All @@ -397,6 +404,7 @@ export const GraphScene: FC<GraphSceneProps & { ref?: Ref<GraphSceneRef> }> =
/>
)),
[
constrainDragging,
animated,
contextMenu,
disabled,
Expand Down Expand Up @@ -503,5 +511,6 @@ export const GraphScene: FC<GraphSceneProps & { ref?: Ref<GraphSceneRef> }> =
);

GraphScene.defaultProps = {
edgeInterpolation: 'linear'
edgeInterpolation: 'linear',
constrainDragging: false
};
11 changes: 10 additions & 1 deletion src/symbols/Node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export interface NodeProps {
*/
draggable?: boolean;

/**
* Constrain dragging to the cluster bounds.
*/
constrainDragging?: boolean;

/**
* The url for the label font.
*/
Expand Down Expand Up @@ -127,7 +132,8 @@ export const Node: FC<NodeProps> = ({
onDragged,
onPointerOut,
onContextMenu,
renderNode
renderNode,
constrainDragging
}) => {
const cameraControls = useCameraControls();
const theme = useStore(state => state.theme);
Expand All @@ -143,6 +149,7 @@ export const Node: FC<NodeProps> = ({
const isSelected = useStore(state => state.selections?.includes(id));
const hasSelections = useStore(state => state.selections?.length > 0);
const center = useStore(state => state.centerPosition);
const cluster = useStore(state => state.clusters.get(node.cluster));

const isDragging = draggingId === id;
const {
Expand Down Expand Up @@ -211,6 +218,8 @@ export const Node: FC<NodeProps> = ({
const bind = useDrag({
draggable,
position,
// If dragging is constrained to the cluster, use the cluster's position as the bounds
bounds: constrainDragging ? cluster?.position : undefined,
// @ts-ignore
set: pos => setNodePosition(id, pos),
onDragStart: () => {
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export interface GraphNode extends GraphElementBaseAttributes {
* Fill color for the node.
*/
fill?: string;

/**
* Cluster ID for the node.
*/
cluster?: string;
}

export interface GraphEdge extends GraphElementBaseAttributes {
Expand Down
15 changes: 8 additions & 7 deletions src/useGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const useGraph = ({
const layoutMounted = useRef<boolean>(false);
const layout = useRef<LayoutStrategy | null>(null);
const camera = useThree(state => state.camera) as PerspectiveCamera;
const dragRef = useRef<DragReferences>(drags);

// Calculate the visible entities
const { visibleEdges, visibleNodes } = useMemo(
Expand All @@ -75,12 +76,6 @@ export const useGraph = ({
[stateCollapsedNodeIds, nodes, edges]
);

// Transient updates
const dragRef = useRef<DragReferences>(drags);
useEffect(() => {
dragRef.current = drags;
}, [drags]);

const updateLayout = useCallback(
async (curLayout?: any) => {
// Cache the layout provider
Expand All @@ -106,7 +101,8 @@ export const useGraph = ({
sizingAttribute,
maxNodeSize,
minNodeSize,
defaultNodeSize
defaultNodeSize,
clusterAttribute
});

// Calculate clusters
Expand Down Expand Up @@ -137,6 +133,11 @@ export const useGraph = ({
]
);

// Transient updates
useEffect(() => {
dragRef.current = drags;
}, [drags, clusterAttribute, updateLayout]);

useEffect(() => {
// When the camera position/zoom changes, update the label visibility
const nodes = stateNodes.map(node => ({
Expand Down
8 changes: 5 additions & 3 deletions src/utils/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ export interface CalculateClustersInput {

export interface ClusterGroup {
/**
* The nodes in the cluster.
* Nodes in the cluster.
*/
nodes: InternalGraphNode[];

/**
* The position of the cluster.
* Center position of the cluster.
*/
position: CenterPositionVector;

/**
* The label of the cluster.
* Label of the cluster.
*/
label: string;
}
Expand Down
5 changes: 4 additions & 1 deletion src/utils/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface TransformGraphInput {
minNodeSize?: number;
maxNodeSize?: number;
defaultNodeSize?: number;
clusterAttribute?: string;
}

/**
Expand All @@ -62,7 +63,8 @@ export function transformGraph({
sizingAttribute,
defaultNodeSize,
minNodeSize,
maxNodeSize
maxNodeSize,
clusterAttribute
}: TransformGraphInput) {
const nodes: InternalGraphNode[] = [];
const edges: InternalGraphEdge[] = [];
Expand Down Expand Up @@ -96,6 +98,7 @@ export function transformGraph({
label,
icon,
fill,
cluster: clusterAttribute ? data[clusterAttribute] : undefined,
parents,
data: {
...rest,
Expand Down
23 changes: 23 additions & 0 deletions src/utils/useDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { useMemo } from 'react';
import { useGesture } from '@use-gesture/react';
import { Vector2, Vector3, Plane } from 'three';
import { InternalGraphPosition } from '../types';
import { CenterPositionVector } from './layout';

interface DragParams {
draggable: boolean;
position: InternalGraphPosition;
bounds?: CenterPositionVector;
set: (position: Vector3) => void;
onDragStart: () => void;
onDragEnd: () => void;
Expand All @@ -16,6 +18,7 @@ export const useDrag = ({
draggable,
set,
position,
bounds,
onDragStart,
onDragEnd
}: DragParams) => {
Expand Down Expand Up @@ -86,6 +89,26 @@ export const useDrag = ({
.copy(mouse3D)
.add(offset);

// If there's a cluster, clamp the position within its circular bounds
if (bounds) {
const center = new Vector3(
(bounds.minX + bounds.maxX) / 2,
(bounds.minY + bounds.maxY) / 2,
(bounds.minZ + bounds.maxZ) / 2
);
const radius = (bounds.maxX - bounds.minX) / 2;

// Calculate direction from center to updated position
const direction = updated.clone().sub(center);
const distance = direction.length();

// If outside the circle, clamp to the circle's edge
if (distance > radius) {
direction.normalize().multiplyScalar(radius);
updated.copy(center).add(direction);
}
}

return set(updated);
},
onDragEnd
Expand Down
Loading