@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.
- package/.env.example +11 -0
- package/.github/workflows/ci.yml +3 -7
- package/CHANGELOG.md +14 -0
- package/DEPLOYMENT.md +59 -0
- package/README.md +54 -1
- package/dist/vitest.config.js +2 -2
- package/dist/vitest.config.js.map +1 -1
- package/examples/3d-force-directed.html +611 -0
- package/examples/3d-kamada-kawai.html +379 -0
- package/examples/3d-layout-comparison.html +448 -0
- package/examples/3d-spherical-layout.html +319 -0
- package/examples/arf-layout.html +13 -1
- package/examples/bfs-layout.html +38 -32
- package/examples/bipartite-layout.html +16 -4
- package/examples/circular-layout.html +14 -2
- package/examples/forceatlas2-layout.html +118 -151
- package/examples/index.html +75 -0
- package/examples/kamada-kawai-layout.html +13 -1
- package/{dist → examples}/layout-helpers.js +58 -29
- package/examples/multipartite-layout.html +44 -54
- package/examples/planar-layout.html +13 -1
- package/examples/random-layout.html +13 -1
- package/examples/shell-layout.html +16 -2
- package/examples/spectral-layout.html +13 -1
- package/examples/spiral-layout.html +13 -1
- package/examples/spring-layout.html +15 -13
- package/package.json +6 -3
- package/src/algorithms/index.ts +6 -0
- package/src/algorithms/optimization/index.ts +12 -0
- package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
- package/src/algorithms/optimization/lbfgs.ts +68 -0
- package/src/algorithms/optimization/line-search.ts +50 -0
- package/src/algorithms/optimization/types.ts +8 -0
- package/src/algorithms/planarity/check.ts +38 -0
- package/src/algorithms/planarity/embedding.ts +216 -0
- package/src/algorithms/planarity/index.ts +12 -0
- package/src/algorithms/planarity/lr-test.ts +70 -0
- package/src/algorithms/planarity/special-graphs.ts +126 -0
- package/src/generators/basic.ts +93 -0
- package/src/generators/bipartite.ts +45 -0
- package/src/generators/grid.ts +42 -0
- package/src/generators/index.ts +15 -0
- package/src/generators/random.ts +39 -0
- package/src/generators/scale-free.ts +72 -0
- package/src/index.ts +18 -0
- package/src/layouts/basic/index.ts +5 -0
- package/src/layouts/basic/random.ts +32 -0
- package/src/layouts/force-directed/arf.ts +130 -0
- package/src/layouts/force-directed/forceatlas2.ts +407 -0
- package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
- package/src/layouts/force-directed/index.ts +9 -0
- package/src/layouts/force-directed/kamada-kawai.ts +112 -0
- package/src/layouts/force-directed/spring.ts +35 -0
- package/src/layouts/geometric/circular.ts +77 -0
- package/src/layouts/geometric/index.ts +7 -0
- package/src/layouts/geometric/shell.ts +80 -0
- package/src/layouts/geometric/spiral.ts +93 -0
- package/src/layouts/hierarchical/bfs.ts +81 -0
- package/src/layouts/hierarchical/bipartite.ts +94 -0
- package/src/layouts/hierarchical/index.ts +7 -0
- package/src/layouts/hierarchical/multipartite.ts +88 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/specialized/index.ts +6 -0
- package/src/layouts/specialized/planar.ts +65 -0
- package/src/layouts/specialized/spectral.ts +128 -0
- package/src/types/embedding.ts +11 -0
- package/src/types/graph.ts +13 -0
- package/src/types/index.ts +7 -0
- package/src/types/layout.ts +9 -0
- package/src/utils/graph.ts +77 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/numpy.ts +108 -0
- package/src/utils/params.ts +26 -0
- package/src/utils/random.ts +53 -0
- package/src/utils/rescale.ts +137 -0
- package/test/arf-layout.test.ts +1 -1
- package/test/bfs-layout.test.ts +1 -1
- package/test/bipartite-layout.test.ts +1 -1
- package/test/circular-layout.test.ts +145 -3
- package/test/forceatlas2-layout.test.ts +1 -1
- package/test/fruchterman-reingold-layout.test.ts +1 -1
- package/test/graph-generators.test.ts +1 -1
- package/test/kamada-kawai-layout.test.ts +273 -1
- package/test/multipartite-layout.test.ts +1 -1
- package/test/planar-layout.test.ts +1 -1
- package/test/random-layout.test.ts +1 -1
- package/test/rescale-layout.test.ts +1 -1
- package/test/shell-layout.test.ts +1 -1
- package/test/spectral-layout.test.ts +1 -1
- package/test/spiral-layout.test.ts +1 -1
- package/test/spring-layout.test.ts +1 -1
- package/vite.config.js +36 -0
- package/vitest.config.ts +2 -2
- package/dist/layout-helpers.d.ts +0 -123
- package/dist/layout-helpers.js.map +0 -1
- package/dist/layout.d.ts +0 -275
- package/dist/layout.js +0 -2280
- package/dist/layout.js.map +0 -1
- package/layout-helpers.ts +0 -559
- 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
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
344
|
+
const graphType = document.getElementById('graph-type').value;
|
|
368
345
|
|
|
369
|
-
currentGraph =
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "graph layout algorithms based on networkx",
|
|
5
|
-
"main": "dist/
|
|
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,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
|
+
}
|