project-graph-mcp 2.2.6 → 2.3.1
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.
- package/ARCHITECTURE.md +81 -0
- package/CHANGELOG.md +57 -0
- package/README.md +9 -4
- package/package.json +4 -13
- package/project-graph-mcp-2.3.0.tgz +0 -0
- package/src/compact/expand.js +1 -1
- package/src/core/graph-builder.js +2 -2
- package/src/core/parser.js +2 -2
- package/src/network/server.js +1 -2
- package/src/network/web-server.js +1 -1
- package/vendor/symbiote-node/CHANGELOG.md +31 -0
- package/vendor/symbiote-node/LICENSE +21 -0
- package/vendor/symbiote-node/README.md +206 -0
- package/vendor/symbiote-node/canvas/AutoLayout.js +725 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +73 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +93 -0
- package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +9 -0
- package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +962 -0
- package/vendor/symbiote-node/canvas/ConnectionRenderer.js +1468 -0
- package/vendor/symbiote-node/canvas/FlowSimulator.js +323 -0
- package/vendor/symbiote-node/canvas/ForceLayout.js +189 -0
- package/vendor/symbiote-node/canvas/ForceWorker.js +1325 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +97 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +176 -0
- package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
- package/vendor/symbiote-node/canvas/LODManager.js +88 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +71 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.js +207 -0
- package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +9 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +261 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +1840 -0
- package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +97 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +132 -0
- package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +21 -0
- package/vendor/symbiote-node/canvas/NodeViewManager.js +584 -0
- package/vendor/symbiote-node/canvas/PinExpansion.js +131 -0
- package/vendor/symbiote-node/canvas/PseudoConnection.js +80 -0
- package/vendor/symbiote-node/canvas/SubgraphManager.js +201 -0
- package/vendor/symbiote-node/canvas/SubgraphRouter.js +443 -0
- package/vendor/symbiote-node/canvas/ViewportActions.js +446 -0
- package/vendor/symbiote-node/core/Connection.js +45 -0
- package/vendor/symbiote-node/core/Editor.js +451 -0
- package/vendor/symbiote-node/core/Frame.js +31 -0
- package/vendor/symbiote-node/core/GraphMermaid.js +348 -0
- package/vendor/symbiote-node/core/GraphText.js +210 -0
- package/vendor/symbiote-node/core/Node.js +143 -0
- package/vendor/symbiote-node/core/Portal.js +104 -0
- package/vendor/symbiote-node/core/Socket.js +185 -0
- package/vendor/symbiote-node/core/SubgraphNode.js +125 -0
- package/vendor/symbiote-node/index.js +103 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +361 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +332 -0
- package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +133 -0
- package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
- package/vendor/symbiote-node/interactions/ConnectFlow.js +307 -0
- package/vendor/symbiote-node/interactions/Drag.js +102 -0
- package/vendor/symbiote-node/interactions/Selector.js +132 -0
- package/vendor/symbiote-node/interactions/SnapGrid.js +65 -0
- package/vendor/symbiote-node/interactions/Zoom.js +140 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +88 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +254 -0
- package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +11 -0
- package/vendor/symbiote-node/layout/Layout/Layout.css.js +88 -0
- package/vendor/symbiote-node/layout/Layout/Layout.js +622 -0
- package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +25 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +293 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +467 -0
- package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +33 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +46 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +102 -0
- package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
- package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +156 -0
- package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +250 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +379 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +263 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +20 -0
- package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +183 -0
- package/vendor/symbiote-node/layout/LayoutTree.js +246 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +43 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +89 -0
- package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +14 -0
- package/vendor/symbiote-node/layout/index.js +16 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +61 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +79 -0
- package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +19 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +41 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +24 -0
- package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +16 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +65 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +29 -0
- package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +13 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +683 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.js +92 -0
- package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +17 -0
- package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +25 -0
- package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +7 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.css.js +90 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.js +87 -0
- package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +10 -0
- package/vendor/symbiote-node/package.json +59 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +131 -0
- package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +16 -0
- package/vendor/symbiote-node/plugins/History.js +384 -0
- package/vendor/symbiote-node/plugins/Readonly.js +59 -0
- package/vendor/symbiote-node/shapes/CircleShape.js +80 -0
- package/vendor/symbiote-node/shapes/CommentShape.js +35 -0
- package/vendor/symbiote-node/shapes/DiamondShape.js +115 -0
- package/vendor/symbiote-node/shapes/NodeShape.js +80 -0
- package/vendor/symbiote-node/shapes/PillShape.js +91 -0
- package/vendor/symbiote-node/shapes/RectShape.js +72 -0
- package/vendor/symbiote-node/shapes/SVGShape.js +494 -0
- package/vendor/symbiote-node/shapes/index.js +53 -0
- package/vendor/symbiote-node/themes/Palette.js +32 -0
- package/vendor/symbiote-node/themes/Skin.js +113 -0
- package/vendor/symbiote-node/themes/Theme.js +84 -0
- package/vendor/symbiote-node/themes/carbon.js +137 -0
- package/vendor/symbiote-node/themes/dark.js +137 -0
- package/vendor/symbiote-node/themes/ebook.js +138 -0
- package/vendor/symbiote-node/themes/grey.js +137 -0
- package/vendor/symbiote-node/themes/light.js +137 -0
- package/vendor/symbiote-node/themes/neon.js +138 -0
- package/vendor/symbiote-node/themes/pcb.js +273 -0
- package/vendor/symbiote-node/themes/synthwave.js +137 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +86 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +128 -0
- package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +29 -0
- package/web/app.js +9 -5
- package/web/components/canvas-graph.js +1705 -0
- package/web/components/code-block.js +1 -1
- package/web/components/event-feed/CodeWidget.js +32 -0
- package/web/components/event-feed/EventWidget.js +97 -0
- package/web/components/event-feed/ListWidget.js +57 -0
- package/web/components/event-feed/MiniGraphWidget.js +159 -0
- package/web/components/follow-ribbon.js +134 -0
- package/web/dashboard.js +1 -1
- package/web/follow-controller.js +241 -0
- package/web/index.html +4 -0
- package/web/panels/ActionBoard/ActionBoard.js +1 -1
- package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -1
- package/web/panels/code-viewer.js +50 -15
- package/web/panels/dep-graph.js +2691 -7
- package/web/panels/file-tree.js +5 -2
- package/web/panels/live-monitor.js +75 -3
- package/web/style.css +39 -0
- package/docs/img/explorer-compact.jpg +0 -0
- package/docs/img/explorer-expanded.jpg +0 -0
- package/src/.contextignore +0 -22
- package/src/.project-graph-cache.json +0 -1
- package/src/compact/.project-graph-cache.json +0 -1
- package/web/.project-graph-cache.json +0 -1
- 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
|
+
}
|