@graphty/layout 1.1.1 → 1.2.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.
Files changed (100) hide show
  1. package/.env.example +11 -0
  2. package/.github/workflows/ci.yml +3 -7
  3. package/CHANGELOG.md +14 -0
  4. package/DEPLOYMENT.md +59 -0
  5. package/README.md +54 -1
  6. package/dist/vitest.config.js +2 -2
  7. package/dist/vitest.config.js.map +1 -1
  8. package/examples/3d-force-directed.html +611 -0
  9. package/examples/3d-kamada-kawai.html +379 -0
  10. package/examples/3d-layout-comparison.html +448 -0
  11. package/examples/3d-spherical-layout.html +319 -0
  12. package/examples/arf-layout.html +13 -1
  13. package/examples/bfs-layout.html +38 -32
  14. package/examples/bipartite-layout.html +16 -4
  15. package/examples/circular-layout.html +14 -2
  16. package/examples/forceatlas2-layout.html +118 -151
  17. package/examples/index.html +75 -0
  18. package/examples/kamada-kawai-layout.html +13 -1
  19. package/{dist → examples}/layout-helpers.js +58 -29
  20. package/examples/multipartite-layout.html +44 -54
  21. package/examples/planar-layout.html +13 -1
  22. package/examples/random-layout.html +13 -1
  23. package/examples/shell-layout.html +16 -2
  24. package/examples/spectral-layout.html +13 -1
  25. package/examples/spiral-layout.html +13 -1
  26. package/examples/spring-layout.html +15 -13
  27. package/package.json +6 -3
  28. package/src/algorithms/index.ts +6 -0
  29. package/src/algorithms/optimization/index.ts +12 -0
  30. package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
  31. package/src/algorithms/optimization/lbfgs.ts +68 -0
  32. package/src/algorithms/optimization/line-search.ts +50 -0
  33. package/src/algorithms/optimization/types.ts +8 -0
  34. package/src/algorithms/planarity/check.ts +38 -0
  35. package/src/algorithms/planarity/embedding.ts +216 -0
  36. package/src/algorithms/planarity/index.ts +12 -0
  37. package/src/algorithms/planarity/lr-test.ts +70 -0
  38. package/src/algorithms/planarity/special-graphs.ts +126 -0
  39. package/src/generators/basic.ts +93 -0
  40. package/src/generators/bipartite.ts +45 -0
  41. package/src/generators/grid.ts +42 -0
  42. package/src/generators/index.ts +15 -0
  43. package/src/generators/random.ts +39 -0
  44. package/src/generators/scale-free.ts +72 -0
  45. package/src/index.ts +18 -0
  46. package/src/layouts/basic/index.ts +5 -0
  47. package/src/layouts/basic/random.ts +32 -0
  48. package/src/layouts/force-directed/arf.ts +130 -0
  49. package/src/layouts/force-directed/forceatlas2.ts +407 -0
  50. package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
  51. package/src/layouts/force-directed/index.ts +9 -0
  52. package/src/layouts/force-directed/kamada-kawai.ts +112 -0
  53. package/src/layouts/force-directed/spring.ts +35 -0
  54. package/src/layouts/geometric/circular.ts +77 -0
  55. package/src/layouts/geometric/index.ts +7 -0
  56. package/src/layouts/geometric/shell.ts +80 -0
  57. package/src/layouts/geometric/spiral.ts +93 -0
  58. package/src/layouts/hierarchical/bfs.ts +81 -0
  59. package/src/layouts/hierarchical/bipartite.ts +94 -0
  60. package/src/layouts/hierarchical/index.ts +7 -0
  61. package/src/layouts/hierarchical/multipartite.ts +88 -0
  62. package/src/layouts/index.ts +9 -0
  63. package/src/layouts/specialized/index.ts +6 -0
  64. package/src/layouts/specialized/planar.ts +65 -0
  65. package/src/layouts/specialized/spectral.ts +128 -0
  66. package/src/types/embedding.ts +11 -0
  67. package/src/types/graph.ts +13 -0
  68. package/src/types/index.ts +7 -0
  69. package/src/types/layout.ts +9 -0
  70. package/src/utils/graph.ts +77 -0
  71. package/src/utils/index.ts +9 -0
  72. package/src/utils/numpy.ts +108 -0
  73. package/src/utils/params.ts +26 -0
  74. package/src/utils/random.ts +53 -0
  75. package/src/utils/rescale.ts +137 -0
  76. package/test/arf-layout.test.ts +1 -1
  77. package/test/bfs-layout.test.ts +1 -1
  78. package/test/bipartite-layout.test.ts +1 -1
  79. package/test/circular-layout.test.ts +145 -3
  80. package/test/forceatlas2-layout.test.ts +1 -1
  81. package/test/fruchterman-reingold-layout.test.ts +1 -1
  82. package/test/graph-generators.test.ts +1 -1
  83. package/test/kamada-kawai-layout.test.ts +273 -1
  84. package/test/multipartite-layout.test.ts +1 -1
  85. package/test/planar-layout.test.ts +1 -1
  86. package/test/random-layout.test.ts +1 -1
  87. package/test/rescale-layout.test.ts +1 -1
  88. package/test/shell-layout.test.ts +1 -1
  89. package/test/spectral-layout.test.ts +1 -1
  90. package/test/spiral-layout.test.ts +1 -1
  91. package/test/spring-layout.test.ts +1 -1
  92. package/vite.config.js +36 -0
  93. package/vitest.config.ts +2 -2
  94. package/dist/layout-helpers.d.ts +0 -123
  95. package/dist/layout-helpers.js.map +0 -1
  96. package/dist/layout.d.ts +0 -275
  97. package/dist/layout.js +0 -2280
  98. package/dist/layout.js.map +0 -1
  99. package/layout-helpers.ts +0 -559
  100. package/layout.ts +0 -2867
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>Multipartite Layout Test</title>
@@ -159,10 +171,13 @@
159
171
  multipartiteLayout,
160
172
  scaleFreeGraph,
161
173
  starGraph,
162
- randomGraph,
174
+ randomGraph
175
+ } from "./layout.js";
176
+
177
+ import {
163
178
  groupNodes,
164
179
  findBestRoot
165
- } from '../dist/layout.js';
180
+ } from "./layout-helpers.js";
166
181
 
167
182
  let currentGraph = null;
168
183
  let currentLayers = null;
@@ -209,46 +224,6 @@
209
224
  return { nodes: () => nodes, edges: () => edges };
210
225
  }
211
226
  }
212
- break;
213
-
214
- case 'hierarchical':
215
- // Hierarchical connections (each node connects to nodes in the next layer)
216
- for (let layer = 0; layer < numLayers - 1; layer++) {
217
- const currentLayer = layers[layer];
218
- const nextLayer = layers[layer + 1];
219
-
220
- currentLayer.forEach((node1, idx) => {
221
- // Connect to 1-2 nodes in the next layer
222
- const connections = Math.min(2, nextLayer.length);
223
- for (let c = 0; c < connections; c++) {
224
- const targetIdx = (idx + c) % nextLayer.length;
225
- edges.push([node1, nextLayer[targetIdx]]);
226
- }
227
- });
228
- }
229
- break;
230
-
231
- case 'complete':
232
- // Each layer is fully connected to the next
233
- for (let layer = 0; layer < numLayers - 1; layer++) {
234
- const currentLayer = layers[layer];
235
- const nextLayer = layers[layer + 1];
236
-
237
- currentLayer.forEach(node1 => {
238
- nextLayer.forEach(node2 => {
239
- edges.push([node1, node2]);
240
- });
241
- });
242
- }
243
- break;
244
- }
245
-
246
- return {
247
- nodes: () => allNodes,
248
- edges: () => edges,
249
- layers
250
- };
251
- }
252
227
 
253
228
  function visualizeGraph(graph, positions) {
254
229
  const svg = document.getElementById('graph-svg');
@@ -335,11 +310,13 @@
335
310
 
336
311
  // Determine color based on layer
337
312
  let nodeColor = '#666';
338
- Object.entries(graph.layers).forEach(([layerIdx, layerNodes]) => {
339
- if (layerNodes.includes(node)) {
340
- nodeColor = layerColors[layerIdx % layerColors.length];
341
- }
342
- });
313
+ if (currentLayers) {
314
+ currentLayers.forEach((layerNodes, layerIdx) => {
315
+ if (layerNodes.includes(node)) {
316
+ nodeColor = layerColors[layerIdx % layerColors.length];
317
+ }
318
+ });
319
+ }
343
320
 
344
321
  const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
345
322
  circle.setAttribute('cx', x);
@@ -356,7 +333,7 @@
356
333
  text.setAttribute('text-anchor', 'middle');
357
334
  text.setAttribute('font-size', '9');
358
335
  text.setAttribute('fill', 'white');
359
- text.textContent = node.replace(/L\d+N/, '');
336
+ text.textContent = String(node).replace(/L\d+N/, '') || String(node);
360
337
  svg.appendChild(text);
361
338
  });
362
339
  }
@@ -364,9 +341,9 @@
364
341
  window.newGraph = function() {
365
342
  const numLayers = parseInt(document.getElementById('num-layers').value);
366
343
  const nodesPerLayer = parseInt(document.getElementById('nodes-per-layer').value);
367
- const edgeType = document.getElementById('edge-type').value;
344
+ const graphType = document.getElementById('graph-type').value;
368
345
 
369
- currentGraph = generateMultipartiteGraph(numLayers, nodesPerLayer, edgeType);
346
+ currentGraph = generateGraph(graphType, numLayers, nodesPerLayer);
370
347
 
371
348
  // Just visualize with random positions initially
372
349
  const positions = {};
@@ -377,13 +354,26 @@
377
354
  ];
378
355
  });
379
356
 
357
+ // Generate layers for multipartite layout
358
+ const layerMethod = document.getElementById('layer-method').value;
359
+
360
+ if (layerMethod === 'bfs') {
361
+ const root = findBestRoot(currentGraph);
362
+ currentLayers = groupNodes(currentGraph, 'bfs', 0, { root });
363
+ } else if (layerMethod === 'degree') {
364
+ currentLayers = groupNodes(currentGraph, 'degree', numLayers);
365
+ } else {
366
+ // community-based
367
+ currentLayers = groupNodes(currentGraph, 'community', numLayers);
368
+ }
369
+
380
370
  visualizeGraph(currentGraph, positions);
381
371
 
382
372
  // Update layer info
383
373
  const infoDiv = document.getElementById('layers-info');
384
- const layerInfo = Object.entries(currentGraph.layers).map(([idx, nodes]) =>
374
+ const layerInfo = currentLayers ? currentLayers.map((nodes, idx) =>
385
375
  `Layer ${idx}: ${nodes.length} nodes`
386
- ).join('<br>');
376
+ ).join('<br>') : 'No layers generated';
387
377
 
388
378
  infoDiv.innerHTML = `
389
379
  <strong>Structure:</strong><br>
@@ -393,11 +383,11 @@
393
383
  };
394
384
 
395
385
  window.applyLayout = function() {
396
- if(!currentGraph) return;
386
+ if(!currentGraph || !currentLayers) return;
397
387
 
398
388
  const alignment = document.getElementById('alignment').value;
399
389
 
400
- const positions = multipartiteLayout(currentGraph, currentGraph.layers, alignment, 1, [0, 0]);
390
+ const positions = multipartiteLayout(currentGraph, currentLayers, alignment, 1, [0, 0]);
401
391
 
402
392
  visualizeGraph(currentGraph, positions);
403
393
  };
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>Planar Layout Test</title>
@@ -161,7 +173,7 @@
161
173
  </div>
162
174
 
163
175
  <script type="module">
164
- import { planarLayout } from '../dist/layout.js';
176
+ import { planarLayout } from "./layout.js";
165
177
 
166
178
  let currentGraph = null;
167
179
 
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>Random Layout Test</title>
@@ -142,7 +154,7 @@
142
154
  </div>
143
155
 
144
156
  <script type="module">
145
- import { randomLayout } from '../dist/layout.js';
157
+ import { randomLayout } from "./layout.js";
146
158
 
147
159
  let currentGraph = null;
148
160
 
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>Shell Layout Test</title>
@@ -134,10 +146,12 @@
134
146
  import {
135
147
  shellLayout,
136
148
  scaleFreeGraph,
137
- randomGraph,
149
+ randomGraph
150
+ } from "./layout.js";
151
+ import {
138
152
  groupNodes,
139
153
  findBestRoot
140
- } from '../dist/layout.js';
154
+ } from "./layout-helpers.js";
141
155
 
142
156
  let currentGraph = null;
143
157
 
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>Spectral Layout Test</title>
@@ -135,7 +147,7 @@
135
147
  </div>
136
148
 
137
149
  <script type="module">
138
- import { spectralLayout } from '../dist/layout.js';
150
+ import { spectralLayout } from "./layout.js";
139
151
 
140
152
  let currentGraph = null;
141
153
 
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>Spiral Layout Test</title>
@@ -171,7 +183,7 @@
171
183
  </div>
172
184
 
173
185
  <script type="module">
174
- import { spiralLayout } from '../dist/layout.js';
186
+ import { spiralLayout } from "./layout.js";
175
187
 
176
188
  let currentGraph = null;
177
189
 
@@ -1,6 +1,18 @@
1
1
  <!DOCTYPE html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <!-- Eruda Mobile Console -->
5
+ <script src="https://cdn.jsdelivr.net/npm/eruda@3/eruda.min.js"></script>
6
+ <script>
7
+ // Initialize Eruda console for mobile debugging
8
+ if (typeof eruda !== "undefined") {
9
+ eruda.init();
10
+ // Auto-show on mobile devices
11
+ if (/mobile|android|ios|iphone|ipad|ipod/i.test(navigator.userAgent.toLowerCase())) {
12
+ eruda.show();
13
+ }
14
+ }
15
+ </script>
4
16
  <meta charset="UTF-8">
5
17
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
18
  <title>Spring Layout Test</title>
@@ -141,7 +153,7 @@
141
153
  </div>
142
154
 
143
155
  <script type="module">
144
- import { springLayout } from '../dist/layout.js';
156
+ import { springLayout } from "./layout.js";
145
157
 
146
158
  let currentGraph = null;
147
159
 
@@ -274,17 +286,7 @@
274
286
  if(!currentGraph) return;
275
287
 
276
288
  const iterations = parseInt(document.getElementById('iterations').value);
277
- const autoConfig = document.getElementById('auto-config').checked;
278
-
279
- let k;
280
- if (autoConfig && currentConfig) {
281
- k = currentConfig.k;
282
- // Update slider to show auto-configured value
283
- document.getElementById('k-value').value = k;
284
- document.getElementById('k-value-value').textContent = k.toFixed(2);
285
- } else {
286
- k = parseFloat(document.getElementById('k-value').value);
287
- }
289
+ const k = parseFloat(document.getElementById('k-value').value);
288
290
 
289
291
  const positions = springLayout(currentGraph, k, null, null, iterations, 1, [0, 0], 2);
290
292
 
@@ -308,4 +310,4 @@
308
310
  newGraph();
309
311
  </script>
310
312
  </body>
311
- </html>
313
+ </html>
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@graphty/layout",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "graph layout algorithms based on networkx",
5
- "main": "dist/layout.js",
5
+ "main": "dist/src/index.js",
6
6
  "type": "module",
7
7
  "directories": {
8
8
  "example": "examples"
@@ -14,9 +14,12 @@
14
14
  "test:coverage": "vitest run --coverage",
15
15
  "prepare": "husky",
16
16
  "build": "tsc",
17
+ "build:examples": "npm run build && node build-examples-script.js",
17
18
  "watch": "tsc --watch",
18
19
  "dev": "tsc --watch",
19
- "commit": "cz"
20
+ "commit": "cz",
21
+ "serve": "npm run build:examples && vite",
22
+ "examples": "npm run build:examples && vite"
20
23
  },
21
24
  "repository": {
22
25
  "type": "git",
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Re-export all algorithm utilities
3
+ */
4
+
5
+ export * from './planarity';
6
+ export * from './optimization';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Re-export all optimization algorithms
3
+ */
4
+
5
+ export type { DistanceMap } from './types';
6
+ export {
7
+ _computeShortestPathDistances,
8
+ _kamadaKawaiSolve,
9
+ _kamadaKawaiCostfn
10
+ } from './kamada-kawai-solver';
11
+ export { _lbfgsDirection } from './lbfgs';
12
+ export { _backtrackingLineSearch } from './line-search';
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Kamada-Kawai layout algorithm optimization functions
3
+ */
4
+
5
+ import { Graph, Node, Edge } from '../../types';
6
+ import { DistanceMap } from './types';
7
+ import { _lbfgsDirection } from './lbfgs';
8
+ import { _backtrackingLineSearch } from './line-search';
9
+ import { getNodesFromGraph, getEdgesFromGraph } from '../../utils/graph';
10
+
11
+ /**
12
+ * Compute all-pairs shortest path distances for the graph
13
+ *
14
+ * @param G - NetworkX graph
15
+ * @param weight - Edge attribute for weight
16
+ * @returns Dictionary of dictionaries of shortest path distances
17
+ */
18
+ export function _computeShortestPathDistances(
19
+ G: Graph,
20
+ weight: string
21
+ ): DistanceMap {
22
+ const distances: DistanceMap = {};
23
+ const nodes = getNodesFromGraph(G);
24
+ const edges = getEdgesFromGraph(G);
25
+
26
+ // Initialize distances with direct edges
27
+ for (const node of nodes) {
28
+ distances[node] = {};
29
+ distances[node][node] = 0;
30
+
31
+ for (const other of nodes) {
32
+ if (node !== other) {
33
+ distances[node][other] = Infinity;
34
+ }
35
+ }
36
+ }
37
+
38
+ // Add direct edges
39
+ for (const [source, target] of edges) {
40
+ // In a real implementation, we would get the weight from the graph
41
+ // For now, assume weight = 1 or use weight attribute if available
42
+ let edgeWeight = 1;
43
+ if (G.getEdgeData) {
44
+ edgeWeight = G.getEdgeData(source, target, weight) || 1;
45
+ }
46
+
47
+ distances[source][target] = edgeWeight;
48
+ distances[target][source] = edgeWeight; // Assuming undirected graph
49
+ }
50
+
51
+ // Floyd-Warshall algorithm for all-pairs shortest paths
52
+ for (const k of nodes) {
53
+ for (const i of nodes) {
54
+ for (const j of nodes) {
55
+ if (distances[i][k] + distances[k][j] < distances[i][j]) {
56
+ distances[i][j] = distances[i][k] + distances[k][j];
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ return distances;
63
+ }
64
+
65
+ /**
66
+ * Solve the Kamada-Kawai layout optimization problem
67
+ *
68
+ * @param distMatrix - Matrix of desired distances between nodes
69
+ * @param positions - Initial node positions
70
+ * @param dim - Dimension of layout
71
+ * @returns Optimized node positions
72
+ */
73
+ export function _kamadaKawaiSolve(
74
+ distMatrix: number[][],
75
+ positions: number[][],
76
+ dim: number
77
+ ): number[][] {
78
+ // Implementation of L-BFGS optimization for Kamada-Kawai
79
+ const nNodes = positions.length;
80
+ const meanWeight = 1e-3;
81
+
82
+ // Convert distances to inverse distances (with protection against division by zero)
83
+ const invDistMatrix = distMatrix.map(row =>
84
+ row.map(d => d === 0 ? 0 : 1 / (d + 1e-3))
85
+ );
86
+
87
+ // Flatten positions for optimization
88
+ let posVec = positions.flat();
89
+
90
+ // Optimization parameters
91
+ const maxIter = 500;
92
+ const gtol = 1e-5;
93
+ const m = 10; // L-BFGS memory size
94
+
95
+ // Implement a simplified L-BFGS-B algorithm
96
+ let alpha = 1.0;
97
+ const oldValues: number[][] = [];
98
+ const oldGrads: number[][] = [];
99
+
100
+ for (let iter = 0; iter < maxIter; iter++) {
101
+ // Calculate cost and gradient
102
+ const [cost, grad] = _kamadaKawaiCostfn(posVec, invDistMatrix, meanWeight, dim);
103
+
104
+ // Compute search direction using L-BFGS approximation
105
+ const direction = _lbfgsDirection(grad, oldValues, oldGrads, m);
106
+
107
+ // Simple line search for step size
108
+ alpha = _backtrackingLineSearch(
109
+ posVec, direction, cost, grad,
110
+ (x: number[]) => _kamadaKawaiCostfn(x, invDistMatrix, meanWeight, dim)[0],
111
+ alpha
112
+ );
113
+
114
+ // Save current position and gradient for next iteration
115
+ const oldPos = [...posVec];
116
+
117
+ // Update position
118
+ for (let i = 0; i < posVec.length; i++) {
119
+ posVec[i] += alpha * direction[i];
120
+ }
121
+
122
+ // Calculate new gradient
123
+ const [, newGrad] = _kamadaKawaiCostfn(posVec, invDistMatrix, meanWeight, dim);
124
+
125
+ // Update L-BFGS memory
126
+ oldValues.push(posVec.map((val, i) => val - oldPos[i]));
127
+ oldGrads.push(newGrad.map((val, i) => val - grad[i]));
128
+
129
+ // Keep only m most recent updates
130
+ if (oldValues.length > m) {
131
+ oldValues.shift();
132
+ oldGrads.shift();
133
+ }
134
+
135
+ // Check convergence
136
+ const gradNorm = Math.sqrt(newGrad.reduce((sum, g) => sum + g * g, 0));
137
+ if (gradNorm < gtol) {
138
+ break;
139
+ }
140
+ }
141
+
142
+ // Reshape result back into positions array
143
+ const result: number[][] = [];
144
+ for (let i = 0; i < nNodes; i++) {
145
+ result.push(posVec.slice(i * dim, (i + 1) * dim));
146
+ }
147
+
148
+ return result;
149
+ }
150
+
151
+ /**
152
+ * Cost function and gradient for Kamada-Kawai layout algorithm
153
+ *
154
+ * @param posVec - Flattened position array
155
+ * @param invDist - Inverse distance matrix
156
+ * @param meanWeight - Weight for centering positions
157
+ * @param dim - Dimension of layout
158
+ * @returns Array with [cost, gradient]
159
+ */
160
+ export function _kamadaKawaiCostfn(
161
+ posVec: number[],
162
+ invDist: number[][],
163
+ meanWeight: number,
164
+ dim: number
165
+ ): [number, number[]] {
166
+ const nNodes = invDist.length;
167
+ const positions: number[][] = [];
168
+
169
+ // Reshape flat vector into positions array
170
+ for (let i = 0; i < nNodes; i++) {
171
+ positions.push(posVec.slice(i * dim, (i + 1) * dim));
172
+ }
173
+
174
+ // Calculate cost
175
+ let cost = 0;
176
+
177
+ // Add mean position penalty term
178
+ const sumPos = Array(dim).fill(0);
179
+ for (let i = 0; i < nNodes; i++) {
180
+ for (let d = 0; d < dim; d++) {
181
+ sumPos[d] += positions[i][d];
182
+ }
183
+ }
184
+ cost += 0.5 * meanWeight * sumPos.reduce((sum, val) => sum + val * val, 0);
185
+
186
+ // Add distance penalty terms
187
+ for (let i = 0; i < nNodes; i++) {
188
+ for (let j = i + 1; j < nNodes; j++) {
189
+ // Calculate actual distance
190
+ const diff = positions[i].map((val, d) => val - positions[j][d]);
191
+ const distance = Math.sqrt(diff.reduce((sum, d) => sum + d * d, 0));
192
+
193
+ // Add penalty for difference between actual and ideal distance
194
+ const idealInvDist = invDist[i][j];
195
+ const offset = distance * idealInvDist - 1.0;
196
+ cost += 0.5 * offset * offset;
197
+ }
198
+ }
199
+
200
+ // Calculate gradient
201
+ const grad = new Array(posVec.length).fill(0);
202
+
203
+ // Add gradient of mean position penalty
204
+ for (let i = 0; i < nNodes; i++) {
205
+ for (let d = 0; d < dim; d++) {
206
+ grad[i * dim + d] += meanWeight * sumPos[d];
207
+ }
208
+ }
209
+
210
+ // Add gradient of distance penalties
211
+ for (let i = 0; i < nNodes; i++) {
212
+ for (let j = i + 1; j < nNodes; j++) {
213
+ // Calculate actual distance and direction
214
+ const diff = positions[i].map((val, d) => val - positions[j][d]);
215
+ const distance = Math.sqrt(diff.reduce((sum, d) => sum + d * d, 0)) || 1e-10;
216
+ const direction = diff.map(d => d / distance);
217
+
218
+ // Calculate contribution to gradient
219
+ const idealInvDist = invDist[i][j];
220
+ const offset = distance * idealInvDist - 1.0;
221
+
222
+ for (let d = 0; d < dim; d++) {
223
+ const force = idealInvDist * offset * direction[d];
224
+ grad[i * dim + d] += force;
225
+ grad[j * dim + d] -= force;
226
+ }
227
+ }
228
+ }
229
+
230
+ return [cost, grad];
231
+ }