1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
|
"use client"
import { useEffect, useRef, useCallback } from "react"
import * as d3 from "d3-force"
import { FORCE_CONFIG } from "@/constants"
import type { GraphNode, GraphEdge } from "@/types"
export interface ForceSimulationControls {
/** The d3 simulation instance */
simulation: d3.Simulation<GraphNode, GraphEdge> | null
/** Reheat the simulation (call on drag start) */
reheat: () => void
/** Cool down the simulation (call on drag end) */
coolDown: () => void
/** Check if simulation is currently active */
isActive: () => boolean
/** Stop the simulation completely */
stop: () => void
/** Get current alpha value */
getAlpha: () => number
}
/**
* Custom hook to manage d3-force simulation lifecycle
* Simulation only runs during interactions (drag) for performance
*/
export function useForceSimulation(
nodes: GraphNode[],
edges: GraphEdge[],
onTick: () => void,
enabled = true,
): ForceSimulationControls {
const simulationRef = useRef<d3.Simulation<GraphNode, GraphEdge> | null>(null)
// Initialize simulation ONCE
useEffect(() => {
if (!enabled || nodes.length === 0) {
return
}
// Only create simulation once
if (!simulationRef.current) {
const simulation = d3
.forceSimulation<GraphNode>(nodes)
.alphaDecay(FORCE_CONFIG.alphaDecay)
.alphaMin(FORCE_CONFIG.alphaMin)
.velocityDecay(FORCE_CONFIG.velocityDecay)
.on("tick", () => {
// Trigger re-render by calling onTick
// D3 has already mutated node.x and node.y
onTick()
})
// Configure forces
// 1. Link force - spring connections between nodes
simulation.force(
"link",
d3
.forceLink<GraphNode, GraphEdge>(edges)
.id((d) => d.id)
.distance(FORCE_CONFIG.linkDistance)
.strength((link) => {
// Different strength based on edge type
if (link.edgeType === "doc-memory") {
return FORCE_CONFIG.linkStrength.docMemory
}
if (link.edgeType === "version") {
return FORCE_CONFIG.linkStrength.version
}
// doc-doc: variable strength based on similarity
return link.similarity * FORCE_CONFIG.linkStrength.docDocBase
}),
)
// 2. Charge force - repulsion between nodes
simulation.force(
"charge",
d3.forceManyBody<GraphNode>().strength(FORCE_CONFIG.chargeStrength),
)
// 3. Collision force - prevent node overlap
simulation.force(
"collide",
d3
.forceCollide<GraphNode>()
.radius((d) =>
d.type === "document"
? FORCE_CONFIG.collisionRadius.document
: FORCE_CONFIG.collisionRadius.memory,
)
.strength(0.7),
)
// 4. forceX and forceY - weak centering forces (like reference code)
simulation.force("x", d3.forceX().strength(0.05))
simulation.force("y", d3.forceY().strength(0.05))
// Store reference
simulationRef.current = simulation
// Quick pre-settle to avoid initial chaos, then animate the rest
// This gives best of both worlds: fast initial render + smooth settling
simulation.alpha(1)
for (let i = 0; i < 50; ++i) simulation.tick() // Just 50 ticks = ~5-10ms
simulation.alphaTarget(0).restart() // Continue animating to full stability
}
// Cleanup on unmount
return () => {
if (simulationRef.current) {
simulationRef.current.stop()
simulationRef.current = null
}
}
// Only run on mount/unmount, not when nodes/edges/onTick change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled])
// Update simulation nodes and edges together to prevent race conditions
useEffect(() => {
if (!simulationRef.current) return
// Update nodes
if (nodes.length > 0) {
simulationRef.current.nodes(nodes)
}
// Update edges
if (edges.length > 0) {
const linkForce = simulationRef.current.force<
d3.ForceLink<GraphNode, GraphEdge>
>("link")
if (linkForce) {
linkForce.links(edges)
}
}
}, [nodes, edges])
// Reheat simulation (called on drag start)
const reheat = useCallback(() => {
if (simulationRef.current) {
simulationRef.current.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
}
}, [])
// Cool down simulation (called on drag end)
const coolDown = useCallback(() => {
if (simulationRef.current) {
simulationRef.current.alphaTarget(0)
}
}, [])
// Check if simulation is active
const isActive = useCallback(() => {
if (!simulationRef.current) return false
return simulationRef.current.alpha() > FORCE_CONFIG.alphaMin
}, [])
// Stop simulation completely
const stop = useCallback(() => {
if (simulationRef.current) {
simulationRef.current.stop()
}
}, [])
// Get current alpha
const getAlpha = useCallback(() => {
if (!simulationRef.current) return 0
return simulationRef.current.alpha()
}, [])
return {
simulation: simulationRef.current,
reheat,
coolDown,
isActive,
stop,
getAlpha,
}
}
|