diff --git a/src/layout/forceDirected.ts b/src/layout/forceDirected.ts index 9664b486..f7dce7f9 100644 --- a/src/layout/forceDirected.ts +++ b/src/layout/forceDirected.ts @@ -13,6 +13,7 @@ import { LayoutFactoryProps, LayoutStrategy } from './types'; import { buildNodeEdges } from './layoutUtils'; import { forceInABox } from './forceInABox'; import { FORCE_LAYOUTS } from './layoutProvider'; +import { ClusterGroup } from '../utils/cluster'; export interface ForceDirectedLayoutInputs extends LayoutFactoryProps { /** @@ -45,6 +46,11 @@ export interface ForceDirectedLayoutInputs extends LayoutFactoryProps { */ clusterStrength?: number; + /** + * The clusters dragged position to reuse for the layout. + */ + clusters: Map; + /** * The type of clustering. */ @@ -102,6 +108,7 @@ export function forceDirected({ forceCharge = -700, getNodePosition, drags, + clusters, clusterAttribute, forceLayout }: ForceDirectedLayoutInputs): LayoutStrategy { @@ -156,6 +163,8 @@ export function forceDirected({ } groupingForce = forceInABox() + // The clusters dragged position to reuse for the layout + .setClusters(clusters) // Strength to foci .strength(clusterStrength) // Either treemap or force diff --git a/src/layout/forceInABox.ts b/src/layout/forceInABox.ts index 45e43f37..89234c2e 100644 --- a/src/layout/forceInABox.ts +++ b/src/layout/forceInABox.ts @@ -7,6 +7,7 @@ import { forceCollide } from 'd3-force-3d'; import { treemap, hierarchy } from 'd3-hierarchy'; +import { ClusterGroup } from '../utils/cluster'; /** * Used for calculating clusterings of nodes. @@ -28,6 +29,7 @@ export function forceInABox() { let id = index; let nodes = []; let links = []; // needed for the force version + let clusters: Map; let tree; let size = [100, 100]; let forceNodeSize = constant(1); // The expected node size used for computing the cluster node @@ -277,6 +279,15 @@ export function forceInABox() { checkLinksAsObjects(); net = getGroupsGraph(); + + // Use dragged clusters position if available + if (clusters.size > 0) { + net.nodes.forEach(n => { + n.fx = clusters.get(n.id)?.position?.x; + n.fy = clusters.get(n.id)?.position?.y; + }); + } + templateForce = forceSimulation(net.nodes) .force('x', forceX(size[0] / 2).strength(0.1)) .force('y', forceY(size[1] / 2).strength(0.1)) @@ -463,5 +474,11 @@ export function forceInABox() { force.getFocis = getFocisFromTemplate; + force.setClusters = function (value: any) { + clusters = value; + + return force; + }; + return force; } diff --git a/src/useGraph.ts b/src/useGraph.ts index 290adbbb..c881f6f5 100644 --- a/src/useGraph.ts +++ b/src/useGraph.ts @@ -10,7 +10,7 @@ import { } from './layout'; import { LabelVisibilityType, calcLabelVisibility } from './utils/visibility'; import { tick } from './layout/layoutUtils'; -import { GraphEdge, GraphNode } from './types'; +import { GraphEdge, GraphNode, InternalGraphNode } from './types'; import { buildGraph, transformGraph } from './utils/graph'; import { DragReferences, useStore } from './store'; import { getVisibleEntities } from './collapse'; @@ -50,6 +50,8 @@ export const useGraph = ({ layoutOverrides }: GraphInputs) => { const graph = useStore(state => state.graph); + const clusters = useStore(state => state.clusters); + const storedNodes = useStore(state => state.nodes); const setClusters = useStore(state => state.setClusters); const stateCollapsedNodeIds = useStore(state => state.collapsedNodeIds); const setEdges = useStore(state => state.setEdges); @@ -64,6 +66,31 @@ export const useGraph = ({ const layout = useRef(null); const camera = useThree(state => state.camera) as PerspectiveCamera; const dragRef = useRef(drags); + const clustersRef = useRef([]); + + // When a new node is added, remove the dragged position of the cluster nodes to put new node in the right place + useEffect(() => { + if (!clusterAttribute) { + return; + } + + const existedNodesIds = storedNodes.map(n => n.id); + const newNode = nodes.find(n => !existedNodesIds.includes(n.id)); + if (newNode) { + const clusterName = newNode.data[clusterAttribute]; + const cluster = clusters.get(clusterName); + const drags = { ...dragRef.current }; + + cluster?.nodes?.forEach(node => { + drags[node.id] = undefined; + }); + + dragRef.current = drags; + setDrags({ + ...drags + }); + } + }, [storedNodes, nodes, clusterAttribute, clusters, setDrags]); // Calculate the visible entities const { visibleEdges, visibleNodes } = useMemo( @@ -76,6 +103,20 @@ export const useGraph = ({ [stateCollapsedNodeIds, nodes, edges] ); + const updateDrags = useCallback( + (nodes: InternalGraphNode[]) => { + const drags = { ...dragRef.current }; + nodes.forEach(node => { + drags[node.id] = node; + }); + dragRef.current = drags; + setDrags({ + ...drags + }); + }, + [setDrags] + ); + const updateLayout = useCallback( async (curLayout?: any) => { // Cache the layout provider @@ -86,6 +127,7 @@ export const useGraph = ({ type: layoutType, graph, drags: dragRef.current, + clusters: clustersRef?.current, clusterAttribute }); @@ -115,6 +157,10 @@ export const useGraph = ({ setEdges(result.edges); setNodes(result.nodes); setClusters(clusters); + if (clusterAttribute) { + // Set drag positions for nodes to prevent them from being moved by the layout update + updateDrags(result.nodes); + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -138,6 +184,10 @@ export const useGraph = ({ dragRef.current = drags; }, [drags, clusterAttribute, updateLayout]); + useEffect(() => { + clustersRef.current = clusters; + }, [clusters]); + useEffect(() => { // When the camera position/zoom changes, update the label visibility const nodes = stateNodes.map(node => ({