project-graph-mcp 2.2.6 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/ARCHITECTURE.md +81 -0
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +9 -4
  4. package/package.json +6 -13
  5. package/src/compact/expand.js +1 -1
  6. package/src/core/graph-builder.js +2 -2
  7. package/src/core/parser.js +2 -2
  8. package/src/network/server.js +1 -2
  9. package/vendor/symbiote-node/CHANGELOG.md +31 -0
  10. package/vendor/symbiote-node/LICENSE +21 -0
  11. package/vendor/symbiote-node/README.md +206 -0
  12. package/vendor/symbiote-node/canvas/AutoLayout.js +725 -0
  13. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +73 -0
  14. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +93 -0
  15. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +9 -0
  16. package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +962 -0
  17. package/vendor/symbiote-node/canvas/ConnectionRenderer.js +1468 -0
  18. package/vendor/symbiote-node/canvas/FlowSimulator.js +323 -0
  19. package/vendor/symbiote-node/canvas/ForceLayout.js +189 -0
  20. package/vendor/symbiote-node/canvas/ForceWorker.js +1325 -0
  21. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +97 -0
  22. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +176 -0
  23. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
  24. package/vendor/symbiote-node/canvas/LODManager.js +88 -0
  25. package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +71 -0
  26. package/vendor/symbiote-node/canvas/Minimap/Minimap.js +207 -0
  27. package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +9 -0
  28. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +261 -0
  29. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +1840 -0
  30. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
  31. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +97 -0
  32. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +132 -0
  33. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +21 -0
  34. package/vendor/symbiote-node/canvas/NodeViewManager.js +584 -0
  35. package/vendor/symbiote-node/canvas/PinExpansion.js +131 -0
  36. package/vendor/symbiote-node/canvas/PseudoConnection.js +80 -0
  37. package/vendor/symbiote-node/canvas/SubgraphManager.js +201 -0
  38. package/vendor/symbiote-node/canvas/SubgraphRouter.js +443 -0
  39. package/vendor/symbiote-node/canvas/ViewportActions.js +446 -0
  40. package/vendor/symbiote-node/core/Connection.js +45 -0
  41. package/vendor/symbiote-node/core/Editor.js +451 -0
  42. package/vendor/symbiote-node/core/Frame.js +31 -0
  43. package/vendor/symbiote-node/core/GraphMermaid.js +348 -0
  44. package/vendor/symbiote-node/core/GraphText.js +210 -0
  45. package/vendor/symbiote-node/core/Node.js +143 -0
  46. package/vendor/symbiote-node/core/Portal.js +104 -0
  47. package/vendor/symbiote-node/core/Socket.js +185 -0
  48. package/vendor/symbiote-node/core/SubgraphNode.js +125 -0
  49. package/vendor/symbiote-node/index.js +103 -0
  50. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +361 -0
  51. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +332 -0
  52. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
  53. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
  54. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +133 -0
  55. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
  56. package/vendor/symbiote-node/interactions/ConnectFlow.js +307 -0
  57. package/vendor/symbiote-node/interactions/Drag.js +102 -0
  58. package/vendor/symbiote-node/interactions/Selector.js +132 -0
  59. package/vendor/symbiote-node/interactions/SnapGrid.js +65 -0
  60. package/vendor/symbiote-node/interactions/Zoom.js +140 -0
  61. package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +88 -0
  62. package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +254 -0
  63. package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +11 -0
  64. package/vendor/symbiote-node/layout/Layout/Layout.css.js +88 -0
  65. package/vendor/symbiote-node/layout/Layout/Layout.js +622 -0
  66. package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +25 -0
  67. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +293 -0
  68. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +467 -0
  69. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +33 -0
  70. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +46 -0
  71. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +102 -0
  72. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
  73. package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +156 -0
  74. package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +250 -0
  75. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +379 -0
  76. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +263 -0
  77. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +20 -0
  78. package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +183 -0
  79. package/vendor/symbiote-node/layout/LayoutTree.js +246 -0
  80. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +43 -0
  81. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +89 -0
  82. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +14 -0
  83. package/vendor/symbiote-node/layout/index.js +16 -0
  84. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +61 -0
  85. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +79 -0
  86. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +19 -0
  87. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +41 -0
  88. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +24 -0
  89. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +16 -0
  90. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +65 -0
  91. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +29 -0
  92. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +13 -0
  93. package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +683 -0
  94. package/vendor/symbiote-node/node/GraphNode/GraphNode.js +92 -0
  95. package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +17 -0
  96. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +25 -0
  97. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +7 -0
  98. package/vendor/symbiote-node/node/PortItem/PortItem.css.js +90 -0
  99. package/vendor/symbiote-node/node/PortItem/PortItem.js +87 -0
  100. package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +10 -0
  101. package/vendor/symbiote-node/package.json +59 -0
  102. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
  103. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +131 -0
  104. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +16 -0
  105. package/vendor/symbiote-node/plugins/History.js +384 -0
  106. package/vendor/symbiote-node/plugins/Readonly.js +59 -0
  107. package/vendor/symbiote-node/shapes/CircleShape.js +80 -0
  108. package/vendor/symbiote-node/shapes/CommentShape.js +35 -0
  109. package/vendor/symbiote-node/shapes/DiamondShape.js +115 -0
  110. package/vendor/symbiote-node/shapes/NodeShape.js +80 -0
  111. package/vendor/symbiote-node/shapes/PillShape.js +91 -0
  112. package/vendor/symbiote-node/shapes/RectShape.js +72 -0
  113. package/vendor/symbiote-node/shapes/SVGShape.js +494 -0
  114. package/vendor/symbiote-node/shapes/index.js +53 -0
  115. package/vendor/symbiote-node/themes/Palette.js +32 -0
  116. package/vendor/symbiote-node/themes/Skin.js +113 -0
  117. package/vendor/symbiote-node/themes/Theme.js +84 -0
  118. package/vendor/symbiote-node/themes/carbon.js +137 -0
  119. package/vendor/symbiote-node/themes/dark.js +137 -0
  120. package/vendor/symbiote-node/themes/ebook.js +138 -0
  121. package/vendor/symbiote-node/themes/grey.js +137 -0
  122. package/vendor/symbiote-node/themes/light.js +137 -0
  123. package/vendor/symbiote-node/themes/neon.js +138 -0
  124. package/vendor/symbiote-node/themes/pcb.js +273 -0
  125. package/vendor/symbiote-node/themes/synthwave.js +137 -0
  126. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +86 -0
  127. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +128 -0
  128. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +29 -0
  129. package/web/app.js +6 -5
  130. package/web/components/canvas-graph.js +1666 -0
  131. package/web/components/event-feed/CodeWidget.js +32 -0
  132. package/web/components/event-feed/EventWidget.js +97 -0
  133. package/web/components/event-feed/ListWidget.js +57 -0
  134. package/web/components/event-feed/MiniGraphWidget.js +69 -0
  135. package/web/dashboard.js +1 -1
  136. package/web/index.html +4 -0
  137. package/web/panels/ActionBoard/ActionBoard.js +1 -1
  138. package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -1
  139. package/web/panels/code-viewer.js +50 -15
  140. package/web/panels/dep-graph.js +2712 -7
  141. package/web/panels/file-tree.js +5 -2
  142. package/web/panels/live-monitor.js +75 -3
  143. package/web/style.css +33 -0
  144. package/docs/img/explorer-compact.jpg +0 -0
  145. package/docs/img/explorer-expanded.jpg +0 -0
  146. package/src/.contextignore +0 -22
  147. package/src/.project-graph-cache.json +0 -1
  148. package/src/compact/.project-graph-cache.json +0 -1
  149. package/web/.project-graph-cache.json +0 -1
  150. package/web/panels/SettingsPanel/.project-graph-cache.json +0 -1
@@ -0,0 +1,1325 @@
1
+ /**
2
+ * ForceWorker — Force-directed layout in a Web Worker.
3
+ *
4
+ * Pure implementation (zero dependencies) of proven graph layout algorithms:
5
+ *
6
+ * 1. Barnes-Hut N-body repulsion — O(n log n) via quadtree
7
+ * Paper: Barnes & Hut, "A hierarchical O(N log N) force-calculation algorithm", Nature 1986
8
+ *
9
+ * 2. Quadtree collision detection — prevents node overlap
10
+ * Based on d3-force forceCollide approach: traverse quadtree, push apart overlapping rectangles
11
+ *
12
+ * 3. Hooke's law spring forces — edges pull connected nodes together
13
+ * F = -k * (distance - restLength)
14
+ *
15
+ * 4. Center gravity — prevents drift
16
+ * Weak force pulling all nodes toward centroid
17
+ *
18
+ * Protocol:
19
+ * Main → Worker: { type: 'init', nodes, edges, groups, options }
20
+ * Worker → Main: { type: 'tick', positions, energy, iteration }
21
+ * Worker → Main: { type: 'done', positions, iterations }
22
+ * Main → Worker: { type: 'stop' }
23
+ *
24
+ * Continuous mode (options.mode = 'continuous'):
25
+ * Main → Worker: { type: 'pause' } — freeze simulation, keep state
26
+ * Main → Worker: { type: 'resume' } — unfreeze with gentle reheat
27
+ * Main → Worker: { type: 'pin', id, x, y } — fix node at position (drag)
28
+ * Main → Worker: { type: 'unpin', id } — release pinned node
29
+ * Worker → Main: { type: 'tick', packed: Float32Array } — packed positions
30
+ *
31
+ * @module symbiote-node/canvas/ForceWorker
32
+ */
33
+
34
+ // =====================================================================
35
+ // 1. QUADTREE (Barnes-Hut spatial index)
36
+ // =====================================================================
37
+
38
+ /**
39
+ * Adaptive quadtree supporting both charge computation and collision detection.
40
+ * Each leaf stores a linked list of bodies at same position (handles coincident nodes).
41
+ */
42
+ class Quad {
43
+ constructor(x0, y0, x1, y1) {
44
+ this.x0 = x0; // min x
45
+ this.y0 = y0; // min y
46
+ this.x1 = x1; // max x
47
+ this.y1 = y1; // max y
48
+ }
49
+ }
50
+
51
+ function quadtreeCreate(nodes) {
52
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
53
+ for (const n of nodes) {
54
+ if (n.x < x0) x0 = n.x;
55
+ if (n.y < y0) y0 = n.y;
56
+ if (n.x > x1) x1 = n.x;
57
+ if (n.y > y1) y1 = n.y;
58
+ }
59
+ // Make square and add padding
60
+ const dx = x1 - x0, dy = y1 - y0;
61
+ const size = Math.max(dx, dy, 1) + 200;
62
+ const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
63
+
64
+ const tree = {
65
+ x0: cx - size / 2, y0: cy - size / 2,
66
+ x1: cx + size / 2, y1: cy + size / 2,
67
+ root: null,
68
+ };
69
+
70
+ for (const n of nodes) {
71
+ qtInsert(tree, n);
72
+ }
73
+ return tree;
74
+ }
75
+
76
+ function qtInsert(tree, body) {
77
+ let node = tree.root;
78
+ if (!node) { tree.root = { data: body, next: null }; return; }
79
+
80
+ let x0 = tree.x0, y0 = tree.y0, x1 = tree.x1, y1 = tree.y1;
81
+ let parent, i;
82
+
83
+ // Navigate to leaf
84
+ while (node.length) { // internal node (array of 4 children)
85
+ const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
86
+ i = (body.x >= mx ? 1 : 0) | (body.y >= my ? 2 : 0);
87
+ parent = node;
88
+ if (body.x >= mx) x0 = mx; else x1 = mx;
89
+ if (body.y >= my) y0 = my; else y1 = my;
90
+ node = node[i];
91
+ if (!node) { parent[i] = { data: body, next: null }; return; }
92
+ }
93
+
94
+ // Leaf node — check for coincident point
95
+ const existing = node.data;
96
+ if (Math.abs(existing.x - body.x) < 0.01 && Math.abs(existing.y - body.y) < 0.01) {
97
+ // Coincident: append to linked list
98
+ body._qtNext = node.data;
99
+ node.data = body;
100
+ return;
101
+ }
102
+
103
+ // Split: replace leaf with internal node, re-insert both
104
+ // Walk up to find parent and replace
105
+ let leaf = node;
106
+ while (true) {
107
+ const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
108
+ const iNew = (body.x >= mx ? 1 : 0) | (body.y >= my ? 2 : 0);
109
+ const iOld = (existing.x >= mx ? 1 : 0) | (existing.y >= my ? 2 : 0);
110
+
111
+ const internal = [null, null, null, null];
112
+ internal.length = 4; // mark as internal
113
+ if (parent) parent[i] = internal; else tree.root = internal;
114
+
115
+ if (iNew !== iOld) {
116
+ internal[iNew] = { data: body, next: null };
117
+ internal[iOld] = leaf;
118
+ return;
119
+ }
120
+
121
+ // Same quadrant — descend further
122
+ parent = internal;
123
+ i = iNew;
124
+ if (body.x >= mx) x0 = mx; else x1 = mx;
125
+ if (body.y >= my) y0 = my; else y1 = my;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Visit each node in the quadtree (post-order for aggregation).
131
+ * callback(node, x0, y0, x1, y1) → return true to skip children.
132
+ */
133
+ function qtVisitAfter(tree, callback) {
134
+ const quads = [];
135
+ if (tree.root) quads.push({ node: tree.root, x0: tree.x0, y0: tree.y0, x1: tree.x1, y1: tree.y1 });
136
+
137
+ const stack = [];
138
+ while (quads.length) {
139
+ const q = quads.pop();
140
+ stack.push(q);
141
+ if (q.node.length) {
142
+ const { x0, y0, x1, y1 } = q;
143
+ const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
144
+ if (q.node[0]) quads.push({ node: q.node[0], x0, y0, x1: mx, y1: my });
145
+ if (q.node[1]) quads.push({ node: q.node[1], x0: mx, y0, x1, y1: my });
146
+ if (q.node[2]) quads.push({ node: q.node[2], x0, y0: my, x1: mx, y1 });
147
+ if (q.node[3]) quads.push({ node: q.node[3], x0: mx, y0: my, x1, y1 });
148
+ }
149
+ }
150
+ // Post-order: process children before parents
151
+ while (stack.length) {
152
+ const q = stack.pop();
153
+ callback(q.node, q.x0, q.y0, q.x1, q.y1);
154
+ }
155
+ }
156
+
157
+ function qtVisit(tree, callback) {
158
+ const quads = [];
159
+ if (tree.root) quads.push({ node: tree.root, x0: tree.x0, y0: tree.y0, x1: tree.x1, y1: tree.y1 });
160
+ while (quads.length) {
161
+ const q = quads.pop();
162
+ if (callback(q.node, q.x0, q.y0, q.x1, q.y1)) continue; // skip children
163
+ if (q.node.length) {
164
+ const { x0, y0, x1, y1 } = q;
165
+ const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
166
+ if (q.node[3]) quads.push({ node: q.node[3], x0: mx, y0: my, x1, y1 });
167
+ if (q.node[2]) quads.push({ node: q.node[2], x0, y0: my, x1: mx, y1 });
168
+ if (q.node[1]) quads.push({ node: q.node[1], x0: mx, y0, x1, y1: my });
169
+ if (q.node[0]) quads.push({ node: q.node[0], x0, y0, x1: mx, y1: my });
170
+ }
171
+ }
172
+ }
173
+
174
+ // =====================================================================
175
+ // 2. FORCES
176
+ // =====================================================================
177
+
178
+ /**
179
+ * Barnes-Hut charge force (Coulomb-like repulsion).
180
+ * Aggregates mass and center-of-mass up the quadtree.
181
+ * θ (theta) controls accuracy vs speed: region_size/distance < θ → treat as point mass.
182
+ */
183
+ function applyChargeForce(nodes, strength, theta) {
184
+ const tree = quadtreeCreate(nodes);
185
+
186
+ // Aggregate: compute total charge and center-of-mass for each internal node
187
+ qtVisitAfter(tree, (node) => {
188
+ if (!node.length) {
189
+ // Leaf: sum charge of all coincident points
190
+ let current = node.data;
191
+ let count = 0;
192
+ while (current) {
193
+ count++;
194
+ current = current._qtNext;
195
+ }
196
+ node.value = strength * count;
197
+ node.x = node.data.x;
198
+ node.y = node.data.y;
199
+ return;
200
+ }
201
+ // Internal: sum children
202
+ let value = 0, x = 0, y = 0, weight = 0;
203
+ for (let i = 0; i < 4; i++) {
204
+ const child = node[i];
205
+ if (!child || !child.value) continue;
206
+ const w = Math.abs(child.value);
207
+ value += child.value;
208
+ x += child.x * w;
209
+ y += child.y * w;
210
+ weight += w;
211
+ }
212
+ node.value = value;
213
+ node.x = weight > 0 ? x / weight : 0;
214
+ node.y = weight > 0 ? y / weight : 0;
215
+ });
216
+
217
+ // Apply forces using Barnes-Hut approximation
218
+ const thetaSq = theta * theta;
219
+ // Adaptive min distance: scale to largest side of node to prevent identical forces on small nodes
220
+ let avgSize = 20;
221
+ if (nodes.length > 0) {
222
+ avgSize = nodes.reduce((s, n) => s + Math.max(n.w, n.h), 0) / nodes.length;
223
+ }
224
+ const distMin2 = Math.max(1, avgSize * avgSize * 0.25);
225
+ for (const body of nodes) {
226
+ qtVisit(tree, (node, x0, y0, x1, y1) => {
227
+ if (!node.value) return true; // skip empty
228
+
229
+ let dx = node.x - body.x;
230
+ let dy = node.y - body.y;
231
+ let w = x1 - x0;
232
+
233
+ // Jitter coincident points (meaningful distance, not infinitesimal)
234
+ if (dx === 0 && dy === 0) {
235
+ dx = (Math.random() - 0.5) * 20;
236
+ dy = (Math.random() - 0.5) * 20;
237
+ }
238
+
239
+ let distSq = dx * dx + dy * dy;
240
+ if (distSq < distMin2) distSq = distMin2; // clamp
241
+
242
+ // Barnes-Hut criterion: if region width² / distance² < θ² → approximate
243
+ if (w * w / distSq < thetaSq) {
244
+ if (distSq < 1000 * 1000) { // distanceMax = 1000
245
+ const force = node.value / distSq;
246
+ body.vx -= dx * force;
247
+ body.vy -= dy * force;
248
+ }
249
+ return true; // don't recurse
250
+ }
251
+
252
+ // If leaf, iterate all coincident points
253
+ if (!node.length) {
254
+ let current = node.data;
255
+ while (current) {
256
+ if (current !== body) {
257
+ let dxLeaf = current.x - body.x;
258
+ let dyLeaf = current.y - body.y;
259
+ if (dxLeaf === 0 && dyLeaf === 0) {
260
+ dxLeaf = (Math.random() - 0.5) * 20;
261
+ dyLeaf = (Math.random() - 0.5) * 20;
262
+ }
263
+ let distSqLeaf = dxLeaf * dxLeaf + dyLeaf * dyLeaf;
264
+ if (distSqLeaf < distMin2) distSqLeaf = distMin2;
265
+ const force = strength / distSqLeaf;
266
+ body.vx -= dxLeaf * force;
267
+ body.vy -= dyLeaf * force;
268
+ }
269
+ current = current._qtNext;
270
+ }
271
+ return true;
272
+ }
273
+
274
+ return false; // recurse into children
275
+ });
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Collision force — prevents node overlap.
281
+ * Uses spatial hash grid for O(n) neighbor detection.
282
+ * Applies POSITIONAL separation (not just velocity) for hard constraints.
283
+ * Multi-pass (3 iterations) to resolve chain collisions.
284
+ */
285
+ function applyCollisionForce(nodes, strength, iterations) {
286
+ const iters = iterations || 3;
287
+ // Padding: add small gap between nodes
288
+ const padX = 8;
289
+ const padY = 4;
290
+
291
+ let maxW = 0;
292
+ let maxH = 0;
293
+ for (const n of nodes) {
294
+ if (n.w > maxW) maxW = n.w;
295
+ if (n.h > maxH) maxH = n.h;
296
+ }
297
+ // Ensure minimums for grid cell sizing
298
+ if (maxW < 20) maxW = 20;
299
+ if (maxH < 20) maxH = 20;
300
+
301
+ for (let pass = 0; pass < iters; pass++) {
302
+ // Rebuild spatial hash each pass (positions shift)
303
+ const cellW = maxW * 1.5;
304
+ const cellH = maxH * 3;
305
+ const grid = new Map();
306
+
307
+ for (let i = 0; i < nodes.length; i++) {
308
+ const n = nodes[i];
309
+ const gx = Math.floor(n.x / cellW);
310
+ const gy = Math.floor(n.y / cellH);
311
+ const key = `${gx},${gy}`;
312
+ if (!grid.has(key)) grid.set(key, []);
313
+ grid.get(key).push(i);
314
+ }
315
+
316
+ // Check each node against its cell + all 8 neighbors
317
+ for (let i = 0; i < nodes.length; i++) {
318
+ const n = nodes[i];
319
+ const gx = Math.floor(n.x / cellW);
320
+ const gy = Math.floor(n.y / cellH);
321
+ for (let dx = -1; dx <= 1; dx++) {
322
+ for (let dy = -1; dy <= 1; dy++) {
323
+ const neighbors = grid.get(`${gx + dx},${gy + dy}`);
324
+ if (!neighbors) continue;
325
+ for (const j of neighbors) {
326
+ if (j <= i) continue;
327
+ resolveOverlap(nodes, i, j, padX, padY, strength);
328
+ }
329
+ }
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ function resolveOverlap(nodes, i, j, padX, padY, strength) {
336
+ const a = nodes[i], b = nodes[j];
337
+
338
+ // Unified physics constraints:
339
+ // 1. Same parent (or both null) -> collide
340
+ // 2. Active group node collides with ALL root nodes
341
+ if (a.parentId !== b.parentId) {
342
+ if (a.id !== config.activeGroupId && b.id !== config.activeGroupId) {
343
+ return;
344
+ }
345
+ // Do not collide the active group with its own children
346
+ if ((a.id === config.activeGroupId && b.parentId === a.id) ||
347
+ (b.id === config.activeGroupId && a.parentId === b.id)) {
348
+ return;
349
+ }
350
+ }
351
+
352
+ // Calculate overlap using current positions
353
+ let dx = b.x - a.x;
354
+ let dy = b.y - a.y;
355
+
356
+ const hwA = a.w / 2 + padX;
357
+ const hhA = a.h / 2 + padY;
358
+ const hwB = b.w / 2 + padX;
359
+ const hhB = b.h / 2 + padY;
360
+
361
+ const overlapX = (hwA + hwB) - Math.abs(dx);
362
+ const overlapY = (hhA + hhB) - Math.abs(dy);
363
+
364
+ if (overlapX > 0 && overlapY > 0) {
365
+ // HARD CONSTRAINT: 100% impermeable space. Modifying positions directly.
366
+ // Also clearing velocities in the push direction to stop momentum.
367
+ if (overlapX < overlapY) {
368
+ const sign = dx < 0 ? -1 : (dx > 0 ? 1 : (Math.random() < 0.5 ? -1 : 1));
369
+ const push = overlapX * strength * 0.5;
370
+
371
+ a.x -= sign * push;
372
+ b.x += sign * push;
373
+
374
+ // Stop velocity pushing them together horizontally
375
+ if (Math.sign(a.vx) === sign) a.vx = 0;
376
+ if (Math.sign(b.vx) === -sign) b.vx = 0;
377
+
378
+ // Orthogonal jitter to prevent perfect 1D stacking
379
+ const jitter = (Math.random() - 0.5) * 0.5;
380
+ a.y -= jitter;
381
+ b.y += jitter;
382
+ } else {
383
+ const sign = dy < 0 ? -1 : (dy > 0 ? 1 : (Math.random() < 0.5 ? -1 : 1));
384
+ const push = overlapY * strength * 0.5;
385
+
386
+ a.y -= sign * push;
387
+ b.y += sign * push;
388
+
389
+ // Stop velocity pushing them together vertically
390
+ if (Math.sign(a.vy) === sign) a.vy = 0;
391
+ if (Math.sign(b.vy) === -sign) b.vy = 0;
392
+
393
+ // Orthogonal jitter to prevent perfect 1D stacking
394
+ const jitter = (Math.random() - 0.5) * 0.5;
395
+ a.x -= jitter;
396
+ b.x += jitter;
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Count overlapping node pairs using spatial hash. O(n) average.
403
+ * @returns {number} Number of overlapping pairs
404
+ */
405
+ function countOverlaps(nodes) {
406
+ let maxW = 260, maxH = 40;
407
+ for (const n of nodes) {
408
+ if (n.w > maxW) maxW = n.w;
409
+ if (n.h > maxH) maxH = n.h;
410
+ }
411
+ const cellW = maxW * 1.5;
412
+ const cellH = maxH * 3;
413
+ const grid = new Map();
414
+
415
+ for (let i = 0; i < nodes.length; i++) {
416
+ const n = nodes[i];
417
+ const key = `${Math.floor(n.x / cellW)},${Math.floor(n.y / cellH)}`;
418
+ if (!grid.has(key)) grid.set(key, []);
419
+ grid.get(key).push(i);
420
+ }
421
+
422
+ let count = 0;
423
+ for (let i = 0; i < nodes.length; i++) {
424
+ const n = nodes[i];
425
+ const gx = Math.floor(n.x / cellW);
426
+ const gy = Math.floor(n.y / cellH);
427
+ for (let dx = -1; dx <= 1; dx++) {
428
+ for (let dy = -1; dy <= 1; dy++) {
429
+ const neighbors = grid.get(`${gx + dx},${gy + dy}`);
430
+ if (!neighbors) continue;
431
+ for (const j of neighbors) {
432
+ if (j <= i) continue;
433
+ const b = nodes[j];
434
+ const hwA = n.w / 2, hhA = n.h / 2;
435
+ const hwB = b.w / 2, hhB = b.h / 2;
436
+ if (Math.abs(n.x - b.x) < hwA + hwB && Math.abs(n.y - b.y) < hhA + hhB) count++;
437
+ }
438
+ }
439
+ }
440
+ }
441
+ return count;
442
+ }
443
+
444
+ /**
445
+ * Jitter only nodes that are actually overlapping. Uses spatial hash for O(n).
446
+ * Small random displacement breaks deadlocks in post-convergence cleanup.
447
+ */
448
+ function jitterOverlappingNodes(nodes) {
449
+ let maxW = 260, maxH = 40;
450
+ for (const n of nodes) {
451
+ if (n.w > maxW) maxW = n.w;
452
+ if (n.h > maxH) maxH = n.h;
453
+ }
454
+ const cellW = maxW * 1.5;
455
+ const cellH = maxH * 3;
456
+ const grid = new Map();
457
+
458
+ for (let i = 0; i < nodes.length; i++) {
459
+ const n = nodes[i];
460
+ const key = `${Math.floor(n.x / cellW)},${Math.floor(n.y / cellH)}`;
461
+ if (!grid.has(key)) grid.set(key, []);
462
+ grid.get(key).push(i);
463
+ }
464
+
465
+ for (let i = 0; i < nodes.length; i++) {
466
+ const a = nodes[i];
467
+ const gx = Math.floor(a.x / cellW);
468
+ const gy = Math.floor(a.y / cellH);
469
+ for (let dx = -1; dx <= 1; dx++) {
470
+ for (let dy = -1; dy <= 1; dy++) {
471
+ const neighbors = grid.get(`${gx + dx},${gy + dy}`);
472
+ if (!neighbors) continue;
473
+ for (const j of neighbors) {
474
+ if (j <= i) continue;
475
+ const b = nodes[j];
476
+ const hwA = a.w / 2, hhA = a.h / 2;
477
+ const hwB = b.w / 2, hhB = b.h / 2;
478
+ const ox = (hwA + hwB) - Math.abs(a.x - b.x);
479
+ const oy = (hhA + hhB) - Math.abs(a.y - b.y);
480
+ if (ox > 0 && oy > 0) {
481
+ // Push apart along minimum-overlap axis + small random to break symmetry
482
+ if (ox < oy) {
483
+ const sign = a.x < b.x ? -1 : (a.x > b.x ? 1 : (Math.random() < 0.5 ? -1 : 1));
484
+ const push = (ox / 2 + 5 + Math.random() * 10);
485
+ a.x += sign * push;
486
+ b.x -= sign * push;
487
+ } else {
488
+ const sign = a.y < b.y ? -1 : (a.y > b.y ? 1 : (Math.random() < 0.5 ? -1 : 1));
489
+ const push = (oy / 2 + 3 + Math.random() * 6);
490
+ a.y += sign * push;
491
+ b.y -= sign * push;
492
+ }
493
+ }
494
+ }
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Spring force (Hooke's law) for linked nodes.
502
+ * F = strength * (distance - restLength)
503
+ */
504
+ function applyLinkForce(nodes, edges, alpha) {
505
+ for (const e of edges) {
506
+ const s = nodes[e.source];
507
+ const t = nodes[e.target];
508
+ if (!s || !t) continue;
509
+
510
+ let dx = t.x + t.vx - s.x - s.vx;
511
+ let dy = t.y + t.vy - s.y - s.vy;
512
+ if (dx === 0 && dy === 0) { dx = (Math.random() - 0.5) * 1e-6; dy = dx; }
513
+
514
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
515
+ const force = (dist - e.restLength) / dist * alpha * e.strength;
516
+ const fx = dx * force;
517
+ const fy = dy * force;
518
+
519
+ // Bias: split force based on link count (nodes with more links move less)
520
+ const bias = e.bias;
521
+ t.vx -= fx * bias;
522
+ t.vy -= fy * bias;
523
+ s.vx += fx * (1 - bias);
524
+ s.vy += fy * (1 - bias);
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Center force: pulls all nodes toward centroid or attractors.
530
+ * External nodes → global center (0,0). Internal nodes → parent center (bx,by).
531
+ */
532
+ function applyCenterForce(nodes, strength, attractors, bx = 0, by = 0) {
533
+ for (const n of nodes) {
534
+ let targetX, targetY;
535
+
536
+ if (n.parentId) {
537
+ // Internal node → pull toward parent center (+ optional attractor offset)
538
+ if (attractors && n.type && attractors[n.type]) {
539
+ targetX = bx + attractors[n.type].x;
540
+ targetY = by + attractors[n.type].y;
541
+ } else {
542
+ targetX = bx;
543
+ targetY = by;
544
+ }
545
+ } else {
546
+ // External node → pull toward global centroid
547
+ targetX = 0;
548
+ targetY = 0;
549
+ }
550
+
551
+ n.vx -= (n.x - targetX) * strength * 0.1;
552
+ n.vy -= (n.y - targetY) * strength * 0.1;
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Boundary force: pushes nodes back if they escape the boundary circle.
558
+ */
559
+ function applyBoundaryForce(nodes, radius, strength, bx, by, activeGroupId) {
560
+ if (!radius) return;
561
+ const rSq = radius * radius;
562
+ for (const n of nodes) {
563
+ if (n.parentId !== activeGroupId) continue; // Only constrain internal nodes
564
+ const dx = n.x - bx;
565
+ const dy = n.y - by;
566
+ const distSq = dx * dx + dy * dy;
567
+ if (distSq > rSq) {
568
+ const dist = Math.sqrt(distSq);
569
+ const overlap = dist - radius;
570
+ const nx = dx / dist;
571
+ const ny = dy / dist;
572
+ n.vx -= nx * overlap * strength;
573
+ n.vy -= ny * overlap * strength;
574
+ }
575
+ }
576
+ }
577
+
578
+ // =====================================================================
579
+ // 3. SIMULATION
580
+ // =====================================================================
581
+
582
+ let nodes = [];
583
+ let edges = [];
584
+ let running = false;
585
+ let paused = false;
586
+ let alpha = 1;
587
+ let iteration = 0;
588
+ let cachedActiveGroupNode = null;
589
+ let galacticSuns = []; // Hub nodes (high-degree or groups)
590
+ let planets = []; // Leaf nodes assigned to a sun
591
+ let simMode = 'converge'; // 'converge' | 'continuous'
592
+ let continuousTimer = null;
593
+
594
+ let config = {
595
+ chargeStrength: -250, // Repulsion (negative = repel). NOT scaled by alpha (d3 convention).
596
+ theta: 0.7, // Barnes-Hut accuracy (0.5=exact, 1.0=fast)
597
+ linkDistance: 180, // Spring rest length for edges
598
+ linkStrength: 0.15, // Spring stiffness for edges
599
+ groupDistance: 120, // Rest length for directory springs
600
+ groupStrength: 0.05, // Stiffness for directory springs
601
+ collideStrength: 0.95, // Collision response (0..1)
602
+ centerStrength: 0.01, // Center gravity
603
+ velocityDecay: 0.92, // Damping — higher = calmer (Ultra-Calm tuned)
604
+ alphaDecay: 0.015, // Cooling rate — slower than d3 default for smoother settling
605
+ alphaMin: 0.001, // Convergence threshold
606
+ alphaTarget: 0, // Target alpha for cooling
607
+ // Continuous mode params (Ultra-Calm tuned)
608
+ contAlphaFloor: 0.001, // Minimum alpha floor in continuous mode
609
+ contAlphaTarget: 0.001, // Alpha target for steady-state drift
610
+ brownian: 0.005, // Brownian motion impulse strength — very subtle
611
+ brownianThresh: 0.005, // Alpha threshold to start Brownian
612
+ pinReheat: 0.03, // Alpha bump on pin
613
+ pinCap: 0.1, // Max alpha from pin reheat
614
+ resumeReheat: 0.05, // Alpha bump on resume
615
+ resumeCap: 0.1, // Max alpha from resume
616
+
617
+ // Group physics
618
+ activeGroupId: null, // ID of the currently expanded group
619
+ boundaryRadius: null, // If set, constrains nodes to circle of this radius
620
+ boundaryStrength: 0.2, // Stiffness of boundary repulsion
621
+ attractors: null, // Object mapping node.type to {x, y} coordinates
622
+ // Galactic Physics params (live-tunable, all alpha-scaled)
623
+ wellStrength: 0.8, // Planet → Sun pull strength (was 0.06 non-alpha)
624
+ centerPull: 0.3, // Sun → origin pull
625
+ wellRepulsion: 5.0, // Inter-Sun overlap push strength
626
+ crossLinkScale: 0.2, // Cross-cluster link strength multiplier (0.2 = 20%)
627
+ };
628
+
629
+ function initSimulation(data) {
630
+ const { nodes: rawNodes, edges: rawEdges, groups = {}, options = {} } = data;
631
+
632
+ // Merge config
633
+ Object.assign(config, options);
634
+ simMode = options.mode || 'converge';
635
+
636
+ // Will be populated after nodes array is built
637
+ cachedActiveGroupNode = null;
638
+
639
+ // Initialize nodes — two-pass for hierarchy
640
+ nodes = rawNodes.map((n, i) => {
641
+ const angle = (2 * Math.PI * i) / rawNodes.length;
642
+ const radius = Math.sqrt(rawNodes.length) * 50;
643
+ const w = n.w || options.nodeWidth || 260;
644
+ const h = n.h || options.nodeHeight || 40;
645
+
646
+ // If position was provided (from smoothPositions), use it directly
647
+ // Otherwise fall back to circular layout
648
+ const hasPos = n.x !== undefined && n.y !== undefined;
649
+ return {
650
+ id: n.id,
651
+ x: hasPos ? n.x : Math.cos(angle) * radius + (Math.random() - 0.5) * 100,
652
+ y: hasPos ? n.y : Math.sin(angle) * radius + (Math.random() - 0.5) * 100,
653
+ _hadPos: hasPos, // flag for pass 2
654
+ vx: 0,
655
+ vy: 0,
656
+ group: n.group || null,
657
+ type: n.type || null,
658
+ parentId: n.parentId || null,
659
+ isGroup: n.isGroup || false,
660
+ children: n.children || [],
661
+ index: i,
662
+ w,
663
+ h,
664
+ };
665
+ });
666
+
667
+ // Pass 2: relocate NEW children (no prior position) to parent center in a small circle
668
+ if (options.activeGroupId) {
669
+ const parentNode = nodes.find(n => n.id === options.activeGroupId);
670
+ if (parentNode) {
671
+ // Collect new children
672
+ const newChildren = nodes.filter(n => n.parentId === options.activeGroupId && !n._hadPos);
673
+ for (let i = 0; i < newChildren.length; i++) {
674
+ const n = newChildren[i];
675
+ // Spread in circle at ~30% of bubble radius
676
+ const angle = (2 * Math.PI * i) / newChildren.length + (Math.random() - 0.5) * 0.5;
677
+ const spread = parentNode.w * 0.3;
678
+ n.x = parentNode.x + Math.cos(angle) * spread;
679
+ n.y = parentNode.y + Math.sin(angle) * spread;
680
+ // Outward kick — burst from center
681
+ n.vx = Math.cos(angle) * 15;
682
+ n.vy = Math.sin(angle) * 15;
683
+ }
684
+ }
685
+ }
686
+
687
+ const nodeIndex = {};
688
+ nodes.forEach((n, i) => { nodeIndex[n.id] = i; });
689
+
690
+ // Compute raw degree counts to find true hubs (most connected nodes)
691
+ const rawDegree = new Array(nodes.length).fill(0);
692
+ rawEdges.forEach(e => {
693
+ const si = nodeIndex[e.from], ti = nodeIndex[e.to];
694
+ if (si !== undefined) rawDegree[si]++;
695
+ if (ti !== undefined) rawDegree[ti]++;
696
+ });
697
+
698
+ // Compute degree counts for link bias
699
+ const degree = new Array(nodes.length).fill(0);
700
+
701
+ // Initialize edges
702
+ edges = rawEdges
703
+ .map(e => {
704
+ const si = nodeIndex[e.from], ti = nodeIndex[e.to];
705
+ if (si === undefined || ti === undefined) return null;
706
+ degree[si]++;
707
+ degree[ti]++;
708
+ return {
709
+ source: si,
710
+ target: ti,
711
+ strength: config.linkStrength,
712
+ restLength: config.linkDistance,
713
+ bias: 0.5,
714
+ };
715
+ })
716
+ .filter(Boolean);
717
+
718
+ // Directory springs (star topology)
719
+ for (const [, memberIds] of Object.entries(groups)) {
720
+ if (memberIds.length < 2) continue;
721
+
722
+ // Identify the true connection center for this group
723
+ let bestHubId = memberIds[0];
724
+ let maxConnections = -1;
725
+ for (const mId of memberIds) {
726
+ const idx = nodeIndex[mId];
727
+ if (idx !== undefined && rawDegree[idx] > maxConnections) {
728
+ maxConnections = rawDegree[idx];
729
+ bestHubId = mId;
730
+ }
731
+ }
732
+
733
+ const hubIdx = nodeIndex[bestHubId];
734
+ if (hubIdx === undefined) continue;
735
+
736
+ // Connect ALL members to the hub, no arbitrary limit
737
+ for (const mId of memberIds) {
738
+ if (mId === bestHubId) continue;
739
+ const ti = nodeIndex[mId];
740
+ if (ti !== undefined) {
741
+ degree[hubIdx]++;
742
+ degree[ti]++;
743
+ edges.push({
744
+ source: hubIdx,
745
+ target: ti,
746
+ strength: config.groupStrength,
747
+ restLength: config.groupDistance,
748
+ bias: 0.5,
749
+ });
750
+ }
751
+ }
752
+ }
753
+
754
+ // Compute link bias: nodes with more links are harder to move
755
+ for (const e of edges) {
756
+ const ds = degree[e.source] || 1;
757
+ const dt = degree[e.target] || 1;
758
+ e.bias = ds / (ds + dt);
759
+ }
760
+
761
+ // Cache active group
762
+ if (config.activeGroupId) {
763
+ cachedActiveGroupNode = nodes.find(n => n.id === config.activeGroupId) || null;
764
+ }
765
+
766
+ // ── Compute Gravity Wells ──
767
+ computeGravityWells(degree);
768
+ }
769
+
770
+ /**
771
+ * Galactic Physics: classify nodes as Suns (hubs) or Planets (leaves).
772
+ * Suns = group nodes OR high-degree nodes (> median * 1.5).
773
+ * Planets are assigned to the nearest connected Sun.
774
+ * Orphans are promoted to micro-suns.
775
+ */
776
+ function computeGravityWells(degree) {
777
+ galacticSuns = [];
778
+ planets = [];
779
+
780
+ // Clear stale state from previous computation
781
+ for (const n of nodes) { n.isSun = false; n.mySun = null; }
782
+
783
+ // 1. Identify "Suns" (Hubs) — nodes with many connections or explicit groups
784
+ const medianDeg = degree.length > 0 ? [...degree].sort((a, b) => a - b)[Math.floor(degree.length / 2)] : 1;
785
+ const hubThreshold = Math.max(3, medianDeg * 1.5);
786
+
787
+ for (let i = 0; i < nodes.length; i++) {
788
+ const n = nodes[i];
789
+ const deg = degree[i] || 0;
790
+ // A node is a Sun if it's a group, or highly connected
791
+ if (n.parentId && n.parentId === config.activeGroupId) continue; // internal children are planets
792
+ if (n.id === config.activeGroupId) continue; // active group is invisible
793
+
794
+ if (n.isGroup || deg >= hubThreshold || (!n.parentId && n.children && n.children.length > 0)) {
795
+ n.isSun = true;
796
+ n.mass = deg + 5; // heavier suns
797
+ galacticSuns.push(n);
798
+ } else {
799
+ n.isSun = false;
800
+ n.mass = 1;
801
+ }
802
+ }
803
+
804
+ // 2. Assign Planets to the nearest Sun
805
+ for (const e of edges) {
806
+ const s = nodes[e.source], t = nodes[e.target];
807
+ if (s.isSun && !t.isSun && !t.mySun) t.mySun = s;
808
+ else if (t.isSun && !s.isSun && !s.mySun) s.mySun = t;
809
+ }
810
+
811
+ // All remaining nodes
812
+ for (const n of nodes) {
813
+ if (n.id === config.activeGroupId) continue;
814
+ if (!n.isSun) {
815
+ if (n.mySun) planets.push(n);
816
+ else {
817
+ // Orphans act as tiny suns drifting to center
818
+ n.isSun = true;
819
+ n.mass = 2;
820
+ galacticSuns.push(n);
821
+ }
822
+ }
823
+ }
824
+
825
+ // 3. Weaken Inter-Galactic links
826
+ for (const e of edges) {
827
+ const s = nodes[e.source], t = nodes[e.target];
828
+ if (!s || !t) continue;
829
+
830
+ // Save original properties once
831
+ if (e._origStrength === undefined) {
832
+ e._origStrength = e.strength;
833
+ e._origRestLength = e.restLength;
834
+ }
835
+
836
+ // Cross-galactic link rules
837
+ e._isCrossGalactic = false;
838
+ if (s.isSun && t.isSun) e._isCrossGalactic = true;
839
+ else if (s.mySun && t.mySun && s.mySun !== t.mySun) e._isCrossGalactic = true;
840
+ else if (s.mySun && t.isSun && s.mySun !== t) e._isCrossGalactic = true;
841
+ else if (t.mySun && s.isSun && t.mySun !== s) e._isCrossGalactic = true;
842
+
843
+ if (e._isCrossGalactic) {
844
+ e.strength = e._origStrength * config.crossLinkScale;
845
+ // Gently stretch cross-galactic links (1.4x at crossLinkScale=0.2)
846
+ e.restLength = e._origRestLength * (1 + 0.5 * (1 - config.crossLinkScale));
847
+ } else {
848
+ e.strength = e._origStrength;
849
+ e.restLength = e._origRestLength;
850
+ }
851
+ }
852
+ }
853
+
854
+ function tick(alpha) {
855
+ // ═══ 1. Dark Energy (Global Repulsion) ═══
856
+ // All bodies repel each other to prevent clustering
857
+ applyChargeForce(nodes, config.chargeStrength * alpha, config.theta);
858
+
859
+ // ═══ 2. Springs (Orbital links) ═══
860
+ applyLinkForce(nodes, edges, alpha);
861
+
862
+ // ═══ 3. Collision (Prevent overlapping matter) ═══
863
+ applyCollisionForce(nodes, config.collideStrength, 4);
864
+
865
+ // ═══ 4. Hierarchical Gravity ═══
866
+
867
+ // a. Compute dynamic radius for suns
868
+ for (const sun of galacticSuns) {
869
+ sun.dynamicRadius = sun.w || 20;
870
+ sun.smoothRadius = sun.smoothRadius || sun.dynamicRadius;
871
+ }
872
+ for (const p of planets) {
873
+ if (p.mySun) {
874
+ const dx = p.x - p.mySun.x;
875
+ const dy = p.y - p.mySun.y;
876
+ const dist = Math.sqrt(dx * dx + dy * dy);
877
+ if (dist > p.mySun.dynamicRadius) {
878
+ p.mySun.dynamicRadius = dist;
879
+ }
880
+ }
881
+ }
882
+ for (const sun of galacticSuns) {
883
+ sun.smoothRadius += (sun.dynamicRadius - sun.smoothRadius) * 0.08;
884
+ }
885
+
886
+ // b. Suns are pulled towards the Galactic Center (0,0)
887
+ for (const sun of galacticSuns) {
888
+ if (sun.id === config.activeGroupId) continue;
889
+ sun.vx -= sun.x * config.centerPull * alpha;
890
+ sun.vy -= sun.y * config.centerPull * alpha;
891
+ }
892
+
893
+ // c. Inter-Sun Repulsion (Keep galaxies separated)
894
+ for (let i = 0; i < galacticSuns.length; i++) {
895
+ for (let j = i + 1; j < galacticSuns.length; j++) {
896
+ const si = galacticSuns[i], sj = galacticSuns[j];
897
+ const dx = sj.x - si.x;
898
+ const dy = sj.y - si.y;
899
+ const dist = Math.sqrt(dx * dx + dy * dy) + 1;
900
+ const combinedRadius = si.smoothRadius + sj.smoothRadius;
901
+ if (dist < combinedRadius) {
902
+ // Proportional overlap (0..1) prevents explosive forces when suns are co-located
903
+ const overlapRatio = (combinedRadius - dist) / combinedRadius;
904
+ const rawForce = overlapRatio * config.wellRepulsion * alpha;
905
+ // Cap maximum force to prevent runaway at start
906
+ const force = Math.min(rawForce, 50);
907
+ const nx = dx / dist, ny = dy / dist;
908
+ si.vx -= nx * force;
909
+ si.vy -= ny * force;
910
+ sj.vx += nx * force;
911
+ sj.vy += ny * force;
912
+ }
913
+ }
914
+ }
915
+
916
+ // d. Planets are pulled gently towards their Sun
917
+ for (const p of planets) {
918
+ const dx = p.x - p.mySun.x;
919
+ const dy = p.y - p.mySun.y;
920
+ p.vx -= dx * config.wellStrength * alpha;
921
+ p.vy -= dy * config.wellStrength * alpha;
922
+ }
923
+
924
+ // ═══ 5. Velocity Verlet integration ═══
925
+ let energy = 0;
926
+ const decay = 1 - config.velocityDecay;
927
+ const vMax = Math.max(200, Math.sqrt(nodes.length) * 10);
928
+ for (const n of nodes) {
929
+ if (n.fx !== undefined) { n.x = n.fx; n.vx = 0; }
930
+ else {
931
+ n.vx *= decay;
932
+ if (n.vx > vMax) n.vx = vMax;
933
+ else if (n.vx < -vMax) n.vx = -vMax;
934
+ n.x += n.vx;
935
+ }
936
+ if (n.fy !== undefined) { n.y = n.fy; n.vy = 0; }
937
+ else {
938
+ n.vy *= decay;
939
+ if (n.vy > vMax) n.vy = vMax;
940
+ else if (n.vy < -vMax) n.vy = -vMax;
941
+ n.y += n.vy;
942
+ }
943
+ energy += n.vx * n.vx + n.vy * n.vy;
944
+ }
945
+
946
+ return energy;
947
+ }
948
+
949
+ function getPositions() {
950
+ const positions = {};
951
+ for (const n of nodes) {
952
+ positions[n.id] = { x: Math.round(n.x - n.w / 2), y: Math.round(n.y - n.h / 2) };
953
+ }
954
+ return positions;
955
+ }
956
+
957
+ /**
958
+ * Pack positions into a Float32Array for efficient transfer.
959
+ * Layout: [x0, y0, x1, y1, ...] in node index order.
960
+ * The ID-to-index mapping is stable from initSimulation.
961
+ */
962
+ function getPositionsPacked() {
963
+ const buf = new Float32Array(nodes.length * 2);
964
+ for (let i = 0; i < nodes.length; i++) {
965
+ buf[i * 2] = nodes[i].x - nodes[i].w / 2;
966
+ buf[i * 2 + 1] = nodes[i].y - nodes[i].h / 2;
967
+ }
968
+ return buf;
969
+ }
970
+
971
+ /** Get ordered node IDs (sent once at init, used to unpack Float32Array). */
972
+ function getNodeIds() {
973
+ return nodes.map(n => n.id);
974
+ }
975
+
976
+ // =====================================================================
977
+ // 4. WORKER MESSAGE HANDLER
978
+ // =====================================================================
979
+
980
+ self.onmessage = function (e) {
981
+ const { type } = e.data;
982
+
983
+ if (type === 'init') {
984
+ running = true;
985
+ paused = false;
986
+ initSimulation(e.data);
987
+
988
+ if (simMode === 'continuous') {
989
+ startContinuous();
990
+ } else {
991
+ startConverge();
992
+ }
993
+ }
994
+
995
+ if (type === 'pause') {
996
+ paused = true;
997
+ if (continuousTimer !== null) {
998
+ clearTimeout(continuousTimer);
999
+ continuousTimer = null;
1000
+ }
1001
+ }
1002
+
1003
+ if (type === 'resume') {
1004
+ if (!running || !paused) return;
1005
+ paused = false;
1006
+ // Gentle reheat — enough to settle neighbors, not enough to explode
1007
+ continuousAlpha = Math.min(continuousAlpha + config.resumeReheat, config.resumeCap);
1008
+ startContinuousLoop();
1009
+ }
1010
+
1011
+ if (type === 'pin') {
1012
+ const { id, x, y } = e.data;
1013
+ const node = nodes.find(n => n.id === id);
1014
+ if (node) {
1015
+ // GUI sends top-left coordinate, physics needs center coordinate
1016
+ node.fx = x + node.w / 2;
1017
+ node.fy = y + node.h / 2;
1018
+ // Local reheat so neighbors react
1019
+ if (simMode === 'continuous') {
1020
+ continuousAlpha = Math.min(continuousAlpha + config.pinReheat, config.pinCap);
1021
+ if (paused) {
1022
+ paused = false;
1023
+ startContinuousLoop();
1024
+ }
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ if (type === 'unpin') {
1030
+ const { id } = e.data;
1031
+ const node = nodes.find(n => n.id === id);
1032
+ if (node) {
1033
+ delete node.fx;
1034
+ delete node.fy;
1035
+ if (simMode === 'continuous') {
1036
+ continuousAlpha = Math.min(continuousAlpha + config.pinReheat, config.pinCap);
1037
+ if (paused) {
1038
+ paused = false;
1039
+ startContinuousLoop();
1040
+ }
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ if (type === 'updateConfig') {
1046
+ const updates = e.data.config;
1047
+ if (updates) {
1048
+ Object.assign(config, updates);
1049
+ // Propagate link params to existing edges (skip group edges)
1050
+ if (updates.linkDistance !== undefined || updates.linkStrength !== undefined) {
1051
+ for (const edge of edges) {
1052
+ if (edge.restLength === config.groupDistance && edge.strength === config.groupStrength) continue;
1053
+ if (updates.linkDistance !== undefined) edge.restLength = config.linkDistance;
1054
+ if (updates.linkStrength !== undefined) edge.strength = config.linkStrength;
1055
+ }
1056
+ }
1057
+ if (updates.groupDistance !== undefined || updates.groupStrength !== undefined) {
1058
+ for (const edge of edges) {
1059
+ // Heuristic: group edges have old groupDistance/groupStrength
1060
+ if (edge.restLength !== config.linkDistance || edge.strength !== config.linkStrength) {
1061
+ if (updates.groupDistance !== undefined) edge.restLength = config.groupDistance;
1062
+ if (updates.groupStrength !== undefined) edge.strength = config.groupStrength;
1063
+ }
1064
+ }
1065
+ }
1066
+ // Recalculate cross-galactic link strengths when crossLinkScale changes
1067
+ if (updates.crossLinkScale !== undefined) {
1068
+ for (const edge of edges) {
1069
+ if (edge._isCrossGalactic && edge._origStrength !== undefined) {
1070
+ edge.strength = edge._origStrength * config.crossLinkScale;
1071
+ edge.restLength = edge._origRestLength * (1 + 0.5 * (1 - config.crossLinkScale));
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ // Reheat simulation so it reacts to the new config
1077
+ if (simMode === 'continuous') {
1078
+ continuousAlpha = Math.min(continuousAlpha + config.resumeReheat, config.resumeCap);
1079
+ if (!paused && continuousTimer === null) {
1080
+ startContinuousLoop();
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ if (type === 'stop') {
1087
+ running = false;
1088
+ paused = false;
1089
+ if (continuousTimer !== null) {
1090
+ clearTimeout(continuousTimer);
1091
+ continuousTimer = null;
1092
+ }
1093
+ self.postMessage({
1094
+ type: 'done',
1095
+ positions: getPositions(),
1096
+ energy: 0,
1097
+ iteration: -1,
1098
+ });
1099
+ }
1100
+ };
1101
+
1102
+ // =====================================================================
1103
+ // 5. CONVERGE MODE (original behavior — runs once, then stops)
1104
+ // =====================================================================
1105
+
1106
+ function startConverge() {
1107
+ const totalNodes = nodes.length;
1108
+ let adaptiveAlphaDecay = config.alphaDecay;
1109
+ let alpha = 1;
1110
+ let iteration = 0;
1111
+ const maxIter = Math.ceil(Math.log(config.alphaMin) / Math.log(1 - config.alphaDecay)) + 1;
1112
+ const batchSize = totalNodes > 1000 ? 8 : 4;
1113
+
1114
+ function runBatch() {
1115
+ if (!running) return;
1116
+
1117
+ for (let i = 0; i < batchSize && alpha > config.alphaMin && iteration < maxIter; i++) {
1118
+ tick(alpha);
1119
+ alpha += (config.alphaTarget - alpha) * adaptiveAlphaDecay;
1120
+ iteration++;
1121
+ }
1122
+
1123
+ if (iteration % 20 === 0) {
1124
+ const overlaps = countOverlaps(nodes);
1125
+ if (overlaps > 0 && alpha > 0.05) {
1126
+ adaptiveAlphaDecay = Math.max(0.005, adaptiveAlphaDecay * 0.9);
1127
+ }
1128
+ }
1129
+
1130
+ const isDone = alpha <= config.alphaMin || iteration >= maxIter;
1131
+
1132
+ if (!isDone) {
1133
+ self.postMessage({
1134
+ type: 'tick',
1135
+ positions: getPositions(),
1136
+ energy: Math.round(alpha * 1000) / 1000,
1137
+ iteration,
1138
+ overlaps: countOverlaps(nodes),
1139
+ });
1140
+ setTimeout(runBatch, 0);
1141
+ } else {
1142
+ // ── Gentle Expansion Post-Convergence Phase ──
1143
+ let attempt = 0;
1144
+ const maxExpansionAttempts = 2000;
1145
+ const expansionBatchSize = totalNodes > 1000 ? 10 : 20;
1146
+
1147
+ function runExpansionBatch() {
1148
+ if (!running) return;
1149
+
1150
+ let overlaps = countOverlaps(nodes);
1151
+ let bIter = 0;
1152
+
1153
+ while (overlaps > 0 && attempt < maxExpansionAttempts && bIter < expansionBatchSize) {
1154
+ applyCollisionForce(nodes, 1.0, 4);
1155
+
1156
+ let maxW = 260, maxH = 40;
1157
+ for (const n of nodes) {
1158
+ if (n.w > maxW) maxW = n.w;
1159
+ if (n.h > maxH) maxH = n.h;
1160
+ }
1161
+ const cellW = maxW * 1.5;
1162
+ const cellH = maxH * 3;
1163
+ const grid = new Map();
1164
+ for (let i = 0; i < nodes.length; i++) {
1165
+ const n = nodes[i];
1166
+ const key = `${Math.floor(n.x / cellW)},${Math.floor(n.y / cellH)}`;
1167
+ if (!grid.has(key)) grid.set(key, []);
1168
+ grid.get(key).push(i);
1169
+ }
1170
+
1171
+ for (let i = 0; i < nodes.length; i++) {
1172
+ const a = nodes[i];
1173
+ const gx = Math.floor(a.x / cellW);
1174
+ const gy = Math.floor(a.y / cellH);
1175
+ for (let dx = -1; dx <= 1; dx++) {
1176
+ for (let dy = -1; dy <= 1; dy++) {
1177
+ const neighbors = grid.get(`${gx + dx},${gy + dy}`);
1178
+ if (!neighbors) continue;
1179
+ for (const j of neighbors) {
1180
+ if (j <= i) continue;
1181
+ const b = nodes[j];
1182
+ let ddx = b.x - a.x;
1183
+ let ddy = b.y - a.y;
1184
+ const limitX = (a.w + b.w) / 2;
1185
+ const limitY = (a.h + b.h) / 2;
1186
+ if (Math.abs(ddx) < limitX && Math.abs(ddy) < limitY) {
1187
+ let len = Math.sqrt(ddx*ddx + ddy*ddy);
1188
+ if (len === 0) { ddx = Math.random()-0.5; ddy = Math.random()-0.5; len = Math.sqrt(ddx*ddx+ddy*ddy)||1; }
1189
+ const push = 2 / len;
1190
+ a.vx -= ddx * push;
1191
+ b.vx += ddx * push;
1192
+ a.vy -= ddy * push;
1193
+ b.vy += ddy * push;
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+ }
1199
+
1200
+ const decay = 0.8;
1201
+ for (const n of nodes) {
1202
+ n.vx *= decay;
1203
+ n.vy *= decay;
1204
+ if (n.vx > 10) n.vx = 10; else if (n.vx < -10) n.vx = -10;
1205
+ if (n.vy > 10) n.vy = 10; else if (n.vy < -10) n.vy = -10;
1206
+ n.x += n.vx;
1207
+ n.y += n.vy;
1208
+ }
1209
+
1210
+ overlaps = countOverlaps(nodes);
1211
+ attempt++;
1212
+ bIter++;
1213
+ }
1214
+
1215
+ if (overlaps > 0 && attempt < maxExpansionAttempts) {
1216
+ self.postMessage({
1217
+ type: 'tick',
1218
+ positions: getPositions(),
1219
+ energy: 0,
1220
+ iteration: iteration + attempt,
1221
+ overlaps,
1222
+ });
1223
+ setTimeout(runExpansionBatch, 0);
1224
+ } else {
1225
+ running = false;
1226
+ self.postMessage({
1227
+ type: 'done',
1228
+ positions: getPositions(),
1229
+ iterations: iteration + attempt,
1230
+ });
1231
+ }
1232
+ }
1233
+
1234
+ runExpansionBatch();
1235
+ }
1236
+ }
1237
+
1238
+ runBatch();
1239
+ }
1240
+
1241
+ // =====================================================================
1242
+ // 6. CONTINUOUS MODE (alive simulation — never stops until 'stop')
1243
+ // =====================================================================
1244
+
1245
+ let continuousAlpha = 1;
1246
+ let continuousIteration = 0;
1247
+
1248
+ function startContinuous() {
1249
+ continuousAlpha = 1;
1250
+ continuousIteration = 0;
1251
+ self._initialDoneSent = false;
1252
+
1253
+ // Send node ID order once so main thread can unpack Float32Array
1254
+ self.postMessage({ type: 'nodeIds', ids: getNodeIds() });
1255
+
1256
+ startContinuousLoop();
1257
+ }
1258
+
1259
+ function startContinuousLoop() {
1260
+ if (continuousTimer !== null) return; // already running
1261
+
1262
+ function runTick() {
1263
+ if (!running || paused) { continuousTimer = null; return; }
1264
+
1265
+ // Physics tick
1266
+ const energy = tick(continuousAlpha);
1267
+
1268
+ // Gentle Brownian motion: random impulses keep graph "breathing"
1269
+ if (config.brownian > 0 && continuousAlpha < config.brownianThresh) {
1270
+ const bStr = config.brownian;
1271
+ for (const n of nodes) {
1272
+ if (n.fx === undefined) n.vx += (Math.random() - 0.5) * bStr;
1273
+ if (n.fy === undefined) n.vy += (Math.random() - 0.5) * bStr;
1274
+ }
1275
+ }
1276
+
1277
+ // Alpha decay toward a low floor
1278
+ continuousAlpha += (config.contAlphaTarget - continuousAlpha) * config.alphaDecay;
1279
+ if (continuousAlpha < config.contAlphaFloor) continuousAlpha = config.contAlphaFloor;
1280
+
1281
+ // Apply extra damping when approaching sleep to kill oscillations
1282
+ if (continuousAlpha < config.contAlphaTarget + 0.001 && config.brownian === 0) {
1283
+ for (const n of nodes) {
1284
+ n.vx *= 0.5;
1285
+ n.vy *= 0.5;
1286
+ }
1287
+ }
1288
+
1289
+ continuousIteration++;
1290
+
1291
+ // Send packed positions every tick for smooth 60fps
1292
+ const packed = getPositionsPacked();
1293
+ self.postMessage({
1294
+ type: 'tick',
1295
+ packed: packed.buffer,
1296
+ alpha: continuousAlpha,
1297
+ energy: energy,
1298
+ iteration: continuousIteration,
1299
+ }, [packed.buffer]);
1300
+
1301
+ // Send a 'done' message once when the layout has mostly settled so the UI can restore view state
1302
+ if (!self._initialDoneSent && Math.abs(continuousAlpha - config.contAlphaTarget) < 0.05) {
1303
+ self._initialDoneSent = true;
1304
+ self.postMessage({
1305
+ type: 'done',
1306
+ positions: getPositions(),
1307
+ iterations: continuousIteration,
1308
+ });
1309
+ }
1310
+
1311
+ // Auto-sleep: if nodes are completely settled and brownian is disabled, stop the loop.
1312
+ // It will wake up on 'pin', 'resume', or 'updateConfig' with reheat.
1313
+ // Use an epsilon for alpha asymptote, and scale energy by node count (e.g., avg velocity < 0.1px/tick)
1314
+ if (Math.abs(continuousAlpha - config.contAlphaTarget) < 1e-4 && energy < nodes.length * 0.01 && config.brownian === 0) {
1315
+ paused = true;
1316
+ continuousTimer = null;
1317
+ console.log('[ForceWorker] Auto-sleep triggered (energy:', energy.toFixed(4), ')');
1318
+ return;
1319
+ }
1320
+
1321
+ continuousTimer = setTimeout(runTick, 16);
1322
+ }
1323
+
1324
+ runTick();
1325
+ }