@graphty/layout 1.2.0 → 1.2.2

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 (92) hide show
  1. package/.github/workflows/ci.yml +3 -7
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +1 -1
  4. package/dist/vitest.config.js +2 -2
  5. package/dist/vitest.config.js.map +1 -1
  6. package/examples/3d-kamada-kawai.html +4 -19
  7. package/examples/bfs-layout.html +26 -32
  8. package/examples/bipartite-layout.html +3 -3
  9. package/examples/forceatlas2-layout.html +106 -151
  10. package/{dist → examples}/layout-helpers.js +58 -30
  11. package/examples/multipartite-layout.html +32 -14
  12. package/examples/shell-layout.html +4 -2
  13. package/examples/spring-layout.html +1 -11
  14. package/package.json +3 -3
  15. package/src/algorithms/index.ts +6 -0
  16. package/src/algorithms/optimization/index.ts +12 -0
  17. package/src/algorithms/optimization/kamada-kawai-solver.ts +231 -0
  18. package/src/algorithms/optimization/lbfgs.ts +68 -0
  19. package/src/algorithms/optimization/line-search.ts +50 -0
  20. package/src/algorithms/optimization/types.ts +8 -0
  21. package/src/algorithms/planarity/check.ts +38 -0
  22. package/src/algorithms/planarity/embedding.ts +216 -0
  23. package/src/algorithms/planarity/index.ts +12 -0
  24. package/src/algorithms/planarity/lr-test.ts +70 -0
  25. package/src/algorithms/planarity/special-graphs.ts +126 -0
  26. package/src/generators/basic.ts +93 -0
  27. package/src/generators/bipartite.ts +45 -0
  28. package/src/generators/grid.ts +42 -0
  29. package/src/generators/index.ts +15 -0
  30. package/src/generators/random.ts +39 -0
  31. package/src/generators/scale-free.ts +72 -0
  32. package/src/index.ts +18 -0
  33. package/src/layouts/basic/index.ts +5 -0
  34. package/src/layouts/basic/random.ts +32 -0
  35. package/src/layouts/force-directed/arf.ts +130 -0
  36. package/src/layouts/force-directed/forceatlas2.ts +407 -0
  37. package/src/layouts/force-directed/fruchterman-reingold.ts +164 -0
  38. package/src/layouts/force-directed/index.ts +9 -0
  39. package/src/layouts/force-directed/kamada-kawai.ts +112 -0
  40. package/src/layouts/force-directed/spring.ts +35 -0
  41. package/src/layouts/geometric/circular.ts +77 -0
  42. package/src/layouts/geometric/index.ts +7 -0
  43. package/src/layouts/geometric/shell.ts +80 -0
  44. package/src/layouts/geometric/spiral.ts +93 -0
  45. package/src/layouts/hierarchical/bfs.ts +81 -0
  46. package/src/layouts/hierarchical/bipartite.ts +94 -0
  47. package/src/layouts/hierarchical/index.ts +7 -0
  48. package/src/layouts/hierarchical/multipartite.ts +88 -0
  49. package/src/layouts/index.ts +9 -0
  50. package/src/layouts/specialized/index.ts +6 -0
  51. package/src/layouts/specialized/planar.ts +65 -0
  52. package/src/layouts/specialized/spectral.ts +128 -0
  53. package/src/types/embedding.ts +11 -0
  54. package/src/types/graph.ts +13 -0
  55. package/src/types/index.ts +7 -0
  56. package/src/types/layout.ts +9 -0
  57. package/src/utils/graph.ts +77 -0
  58. package/src/utils/index.ts +9 -0
  59. package/src/utils/numpy.ts +111 -0
  60. package/src/utils/params.ts +26 -0
  61. package/src/utils/random.ts +53 -0
  62. package/src/utils/rescale.ts +137 -0
  63. package/test/arf-layout.test.ts +1 -1
  64. package/test/bfs-layout.test.ts +1 -1
  65. package/test/bipartite-layout.test.ts +1 -1
  66. package/test/circular-layout.test.ts +1 -1
  67. package/test/forceatlas2-layout.test.ts +1 -1
  68. package/test/fruchterman-reingold-layout.test.ts +1 -1
  69. package/test/graph-generators.test.ts +1 -1
  70. package/test/kamada-kawai-layout.test.ts +1 -1
  71. package/test/multipartite-layout.test.ts +1 -1
  72. package/test/planar-layout.test.ts +1 -1
  73. package/test/random-layout.test.ts +1 -1
  74. package/test/rescale-layout.test.ts +1 -1
  75. package/test/shell-layout.test.ts +1 -1
  76. package/test/spectral-layout.test.ts +1 -1
  77. package/test/spiral-layout.test.ts +1 -1
  78. package/test/spring-layout.test.ts +1 -1
  79. package/test/test-utils.ts +34 -0
  80. package/test/utils-graph.test.ts +155 -0
  81. package/test/utils-index.test.ts +77 -0
  82. package/test/utils-numpy.test.ts +272 -0
  83. package/test/utils-params.test.ts +99 -0
  84. package/test/utils-random.test.ts +229 -0
  85. package/vitest.config.ts +2 -2
  86. package/dist/layout-helpers.d.ts +0 -123
  87. package/dist/layout-helpers.js.map +0 -1
  88. package/dist/layout.d.ts +0 -275
  89. package/dist/layout.js +0 -2304
  90. package/dist/layout.js.map +0 -1
  91. package/layout-helpers.ts +0 -560
  92. package/layout.ts +0 -2893
package/layout-helpers.ts DELETED
@@ -1,560 +0,0 @@
1
- /**
2
- * Layout Helper Functions
3
- * =======================
4
- *
5
- * Essential utilities for graph layouts
6
- */
7
-
8
- import { Node, Edge, Graph } from './layout';
9
-
10
- // Define PositionMap type locally since it's not exported
11
- type PositionMap = Record<Node, number[]>;
12
-
13
- /**
14
- * Universal Node Grouping
15
- * =======================
16
- * Groups nodes for shell, multipartite, or custom layouts
17
- */
18
-
19
- /**
20
- * Group nodes by various centrality measures.
21
- * Works for shell layout, multipartite layout, or any grouping need.
22
- *
23
- * @param graph - Input graph
24
- * @param method - Grouping method
25
- * @param numGroups - Number of groups to create
26
- * @returns Array of node arrays (groups)
27
- *
28
- * @example
29
- * // For shell layout
30
- * const shells = groupNodes(graph, 'degree', 3);
31
- * const positions = shellLayout(graph, shells);
32
- *
33
- * // For multipartite layout
34
- * const layers = groupNodes(graph, 'bfs', 0); // 0 = auto
35
- * const positions = multipartiteLayout(graph, layers);
36
- */
37
- export function groupNodes(
38
- graph: Graph,
39
- method: 'degree' | 'bfs' | 'k-core' | 'community' = 'degree',
40
- numGroups: number = 3,
41
- options: { root?: Node } = {}
42
- ): Node[][] {
43
- const nodes = graph.nodes?.() || [];
44
- const edges = graph.edges?.() || [];
45
-
46
- switch (method) {
47
- case 'degree':
48
- return groupByDegree(nodes, edges, numGroups);
49
-
50
- case 'bfs':
51
- return groupByDistance(nodes, edges, options.root);
52
-
53
- case 'k-core':
54
- return groupByKCore(nodes, edges);
55
-
56
- case 'community':
57
- return groupByCommunity(nodes, edges, numGroups);
58
-
59
- default:
60
- return [nodes]; // Single group fallback
61
- }
62
- }
63
-
64
- /**
65
- * Bipartite Detection & Handling
66
- * ==============================
67
- */
68
-
69
- /**
70
- * Detect if graph is bipartite and find the two sets.
71
- *
72
- * @param graph - Input graph
73
- * @returns Bipartite sets or null if not bipartite
74
- *
75
- * @example
76
- * const result = detectBipartite(graph);
77
- * if (result) {
78
- * const positions = bipartiteLayout(graph, result.setA);
79
- * }
80
- */
81
- export function detectBipartite(graph: Graph): { setA: Node[], setB: Node[] } | null {
82
- const nodes = graph.nodes?.() || [];
83
- const edges = graph.edges?.() || [];
84
-
85
- if (nodes.length === 0) return { setA: [], setB: [] };
86
-
87
- // Build adjacency list
88
- const adj = new Map<Node, Node[]>();
89
- nodes.forEach(node => adj.set(node, []));
90
- edges.forEach(([u, v]) => {
91
- adj.get(u)!.push(v);
92
- adj.get(v)!.push(u);
93
- });
94
-
95
- // Try to 2-color the graph
96
- const colors = new Map<Node, number>();
97
-
98
- for (const start of nodes) {
99
- if (colors.has(start)) continue;
100
-
101
- const queue = [start];
102
- colors.set(start, 0);
103
-
104
- while (queue.length > 0) {
105
- const node = queue.shift()!;
106
- const nodeColor = colors.get(node)!;
107
-
108
- for (const neighbor of adj.get(node)!) {
109
- if (!colors.has(neighbor)) {
110
- colors.set(neighbor, 1 - nodeColor);
111
- queue.push(neighbor);
112
- } else if (colors.get(neighbor) === nodeColor) {
113
- return null; // Not bipartite
114
- }
115
- }
116
- }
117
- }
118
-
119
- // Separate into sets
120
- const setA: Node[] = [];
121
- const setB: Node[] = [];
122
-
123
- nodes.forEach(node => {
124
- if (colors.get(node) === 0) setA.push(node);
125
- else setB.push(node);
126
- });
127
-
128
- return { setA, setB };
129
- }
130
-
131
- /**
132
- * Layout Analysis & Optimization
133
- * ==============================
134
- */
135
-
136
- /**
137
- * Find the best root node for tree-like layouts (BFS, hierarchical).
138
- *
139
- * @param graph - Input graph
140
- * @returns Best root node
141
- */
142
- export function findBestRoot(graph: Graph): Node | null {
143
- const nodes = graph.nodes?.() || [];
144
- const edges = graph.edges?.() || [];
145
-
146
- if (nodes.length === 0) return null;
147
-
148
- // Build adjacency list
149
- const adj = new Map<Node, Set<Node>>();
150
- nodes.forEach(node => adj.set(node, new Set()));
151
- edges.forEach(([u, v]) => {
152
- adj.get(u)!.add(v);
153
- adj.get(v)!.add(u);
154
- });
155
-
156
- // Find node with minimum eccentricity (center of graph)
157
- let bestNode = nodes[0];
158
- let bestEccentricity = Infinity;
159
-
160
- for (const start of nodes) {
161
- // BFS to find max distance
162
- const distances = new Map<Node, number>();
163
- const queue = [start];
164
- distances.set(start, 0);
165
- let maxDist = 0;
166
-
167
- while (queue.length > 0) {
168
- const node = queue.shift()!;
169
- const dist = distances.get(node)!;
170
-
171
- adj.get(node)!.forEach(neighbor => {
172
- if (!distances.has(neighbor)) {
173
- distances.set(neighbor, dist + 1);
174
- maxDist = Math.max(maxDist, dist + 1);
175
- queue.push(neighbor);
176
- }
177
- });
178
- }
179
-
180
- if (maxDist < bestEccentricity) {
181
- bestEccentricity = maxDist;
182
- bestNode = start;
183
- }
184
- }
185
-
186
- return bestNode;
187
- }
188
-
189
- /**
190
- * Check if a graph is planar (can be drawn without edge crossings).
191
- *
192
- * @param graph - Input graph
193
- * @returns true if planar
194
- */
195
- export function isPlanar(graph: Graph): boolean {
196
- const n = graph.nodes?.()?.length || 0;
197
- const m = graph.edges?.()?.length || 0;
198
-
199
- // Quick check: Euler's formula
200
- if (n >= 3 && m > 3 * n - 6) return false;
201
-
202
- // For accurate check, would need to try planarLayout
203
- // This is a simplified heuristic
204
- return true;
205
- }
206
-
207
- /**
208
- * Auto-configure force-directed layout parameters.
209
- *
210
- * @param graph - Input graph
211
- * @returns Recommended parameters
212
- */
213
- export function autoConfigureForce(graph: Graph): {
214
- k: number; // Optimal spring length
215
- iterations: number; // Number of iterations
216
- gravity: number; // ForceAtlas2 gravity
217
- scalingRatio: number; // ForceAtlas2 scaling
218
- } {
219
- const n = graph.nodes?.()?.length || 0;
220
- const m = graph.edges?.()?.length || 0;
221
- const density = n > 1 ? (2 * m) / (n * (n - 1)) : 0;
222
-
223
- return {
224
- k: Math.sqrt(1 / n), // Fruchterman-Reingold optimal distance
225
- iterations: n < 50 ? 500 : n < 200 ? 300 : 200,
226
- gravity: density < 0.1 ? 0.5 : density < 0.3 ? 1.0 : 2.0,
227
- scalingRatio: n < 50 ? 2.0 : n < 200 ? 5.0 : 10.0
228
- };
229
- }
230
-
231
- /**
232
- * Layout Quality Metrics
233
- * ======================
234
- */
235
-
236
- /**
237
- * Calculate simple layout quality metrics.
238
- *
239
- * @param graph - Input graph
240
- * @param positions - Layout positions
241
- * @returns Quality metrics
242
- */
243
- export function layoutQuality(graph: Graph, positions: PositionMap): {
244
- avgEdgeLength: number;
245
- edgeLengthStdDev: number;
246
- minNodeDistance: number;
247
- aspectRatio: number;
248
- } {
249
- const nodes = graph.nodes?.() || [];
250
- const edges = graph.edges?.() || [];
251
-
252
- // Edge lengths
253
- const edgeLengths = edges.map(([u, v]) => {
254
- const p1 = positions[u];
255
- const p2 = positions[v];
256
- return Math.sqrt(p1.reduce((sum: number, val: number, i: number) => sum + (val - p2[i]) ** 2, 0));
257
- });
258
-
259
- const avgEdgeLength = edgeLengths.reduce((a, b) => a + b, 0) / edgeLengths.length || 0;
260
- const variance = edgeLengths.reduce((sum, len) => sum + (len - avgEdgeLength) ** 2, 0) / edgeLengths.length || 0;
261
- const edgeLengthStdDev = Math.sqrt(variance);
262
-
263
- // Minimum node distance
264
- let minNodeDistance = Infinity;
265
- for (let i = 0; i < nodes.length; i++) {
266
- for (let j = i + 1; j < nodes.length; j++) {
267
- const p1 = positions[nodes[i]];
268
- const p2 = positions[nodes[j]];
269
- const dist = Math.sqrt(p1.reduce((sum: number, val: number, k: number) => sum + (val - p2[k]) ** 2, 0));
270
- minNodeDistance = Math.min(minNodeDistance, dist);
271
- }
272
- }
273
-
274
- // Bounding box and aspect ratio
275
- let minX = Infinity, maxX = -Infinity;
276
- let minY = Infinity, maxY = -Infinity;
277
-
278
- nodes.forEach(node => {
279
- const [x, y] = positions[node];
280
- minX = Math.min(minX, x);
281
- maxX = Math.max(maxX, x);
282
- minY = Math.min(minY, y);
283
- maxY = Math.max(maxY, y);
284
- });
285
-
286
- const width = maxX - minX || 1;
287
- const height = maxY - minY || 1;
288
- const aspectRatio = Math.max(width, height) / Math.min(width, height);
289
-
290
- return {
291
- avgEdgeLength,
292
- edgeLengthStdDev,
293
- minNodeDistance: minNodeDistance === Infinity ? 0 : minNodeDistance,
294
- aspectRatio
295
- };
296
- }
297
-
298
- /**
299
- * Layout Utilities
300
- * ================
301
- */
302
-
303
- /**
304
- * Combine multiple layouts with weights.
305
- *
306
- * @param layouts - Array of position maps
307
- * @param weights - Weight for each layout (default: equal)
308
- * @returns Combined positions
309
- */
310
- export function combineLayouts(layouts: PositionMap[], weights?: number[]): PositionMap {
311
- if (layouts.length === 0) return {};
312
-
313
- const w = weights || new Array(layouts.length).fill(1 / layouts.length);
314
- const result: PositionMap = {};
315
- const nodes = Object.keys(layouts[0]);
316
-
317
- nodes.forEach(node => {
318
- const dim = layouts[0][node].length;
319
- result[node] = new Array(dim).fill(0);
320
-
321
- layouts.forEach((layout, i) => {
322
- if (node in layout) {
323
- for (let d = 0; d < dim; d++) {
324
- result[node][d] += layout[node][d] * w[i];
325
- }
326
- }
327
- });
328
- });
329
-
330
- return result;
331
- }
332
-
333
- /**
334
- * Create smooth animation frames between layouts.
335
- *
336
- * @param from - Starting positions
337
- * @param to - Ending positions
338
- * @param steps - Number of frames
339
- * @returns Array of position maps
340
- */
341
- export function interpolateLayouts(
342
- from: PositionMap,
343
- to: PositionMap,
344
- steps: number = 10
345
- ): PositionMap[] {
346
- const frames: PositionMap[] = [];
347
- const nodes = Object.keys(from);
348
-
349
- for (let i = 0; i <= steps; i++) {
350
- const t = i / steps;
351
- const frame: PositionMap = {};
352
-
353
- nodes.forEach(node => {
354
- if (node in to) {
355
- frame[node] = from[node].map((val: number, d: number) =>
356
- val + t * (to[node][d] - val)
357
- );
358
- }
359
- });
360
-
361
- frames.push(frame);
362
- }
363
-
364
- return frames;
365
- }
366
-
367
- /**
368
- * Internal Helper Functions
369
- * =========================
370
- */
371
-
372
- function groupByDegree(nodes: Node[], edges: Edge[], numGroups: number): Node[][] {
373
- // Calculate degrees
374
- const degrees = new Map<Node, number>();
375
- nodes.forEach(node => degrees.set(node, 0));
376
- edges.forEach(([u, v]) => {
377
- degrees.set(u, degrees.get(u)! + 1);
378
- degrees.set(v, degrees.get(v)! + 1);
379
- });
380
-
381
- // Sort by degree
382
- const sorted = nodes.slice().sort((a, b) => degrees.get(b)! - degrees.get(a)!);
383
-
384
- // Distribute into groups
385
- const groups: Node[][] = new Array(numGroups).fill(null).map(() => []);
386
- const nodesPerGroup = Math.ceil(nodes.length / numGroups);
387
-
388
- sorted.forEach((node, i) => {
389
- const groupIdx = Math.floor(i / nodesPerGroup);
390
- if (groupIdx < numGroups) {
391
- groups[groupIdx].push(node);
392
- }
393
- });
394
-
395
- return groups.filter(g => g.length > 0);
396
- }
397
-
398
- function groupByDistance(nodes: Node[], edges: Edge[], root?: Node): Node[][] {
399
- if (nodes.length === 0) return [];
400
-
401
- const start = root || nodes[0];
402
- const adj = new Map<Node, Node[]>();
403
-
404
- nodes.forEach(node => adj.set(node, []));
405
- edges.forEach(([u, v]) => {
406
- adj.get(u)!.push(v);
407
- adj.get(v)!.push(u);
408
- });
409
-
410
- // BFS layers
411
- const visited = new Set<Node>();
412
- const layers: Node[][] = [];
413
- let currentLayer = [start];
414
- visited.add(start);
415
-
416
- while (currentLayer.length > 0) {
417
- layers.push(currentLayer.slice());
418
- const nextLayer: Node[] = [];
419
-
420
- currentLayer.forEach(node => {
421
- adj.get(node)!.forEach(neighbor => {
422
- if (!visited.has(neighbor)) {
423
- visited.add(neighbor);
424
- nextLayer.push(neighbor);
425
- }
426
- });
427
- });
428
-
429
- currentLayer = nextLayer;
430
- }
431
-
432
- // Add disconnected nodes
433
- const unvisited = nodes.filter(n => !visited.has(n));
434
- if (unvisited.length > 0) {
435
- layers.push(unvisited);
436
- }
437
-
438
- return layers;
439
- }
440
-
441
- function groupByKCore(nodes: Node[], edges: Edge[]): Node[][] {
442
- // Build adjacency
443
- const adj = new Map<Node, Set<Node>>();
444
- const degree = new Map<Node, number>();
445
-
446
- nodes.forEach(node => {
447
- adj.set(node, new Set());
448
- degree.set(node, 0);
449
- });
450
-
451
- edges.forEach(([u, v]) => {
452
- adj.get(u)!.add(v);
453
- adj.get(v)!.add(u);
454
- degree.set(u, degree.get(u)! + 1);
455
- degree.set(v, degree.get(v)! + 1);
456
- });
457
-
458
- // k-core decomposition
459
- const coreNumber = new Map<Node, number>();
460
- const remaining = new Set(nodes);
461
-
462
- while (remaining.size > 0) {
463
- // Find minimum degree
464
- let minDegree = Infinity;
465
- remaining.forEach(node => {
466
- minDegree = Math.min(minDegree, degree.get(node)!);
467
- });
468
-
469
- // Remove nodes with degree <= minDegree
470
- const toRemove: Node[] = [];
471
- remaining.forEach(node => {
472
- if (degree.get(node)! <= minDegree) {
473
- toRemove.push(node);
474
- }
475
- });
476
-
477
- while (toRemove.length > 0) {
478
- const node = toRemove.shift()!;
479
- coreNumber.set(node, minDegree);
480
- remaining.delete(node);
481
-
482
- adj.get(node)!.forEach(neighbor => {
483
- if (remaining.has(neighbor)) {
484
- degree.set(neighbor, degree.get(neighbor)! - 1);
485
- if (degree.get(neighbor)! <= minDegree && !toRemove.includes(neighbor)) {
486
- toRemove.push(neighbor);
487
- }
488
- }
489
- });
490
- }
491
- }
492
-
493
- // Group by core number
494
- const groups = new Map<number, Node[]>();
495
- nodes.forEach(node => {
496
- const core = coreNumber.get(node)!;
497
- if (!groups.has(core)) groups.set(core, []);
498
- groups.get(core)!.push(node);
499
- });
500
-
501
- // Return sorted by core number (highest first)
502
- return Array.from(groups.entries())
503
- .sort((a, b) => b[0] - a[0])
504
- .map(([, nodeList]) => nodeList);
505
- }
506
-
507
- function groupByCommunity(nodes: Node[], edges: Edge[], targetGroups: number): Node[][] {
508
- // Simple community detection using modularity
509
- const communities = new Map<Node, number>();
510
- nodes.forEach((node, i) => communities.set(node, i));
511
-
512
- // Build adjacency
513
- const adj = new Map<Node, Set<Node>>();
514
- nodes.forEach(node => adj.set(node, new Set()));
515
- edges.forEach(([u, v]) => {
516
- adj.get(u)!.add(v);
517
- adj.get(v)!.add(u);
518
- });
519
-
520
- // Iteratively merge communities
521
- let numCommunities = nodes.length;
522
- while (numCommunities > targetGroups) {
523
- let improved = false;
524
-
525
- for (const node of nodes) {
526
- const currentComm = communities.get(node)!;
527
- const neighborComms = new Set<number>();
528
-
529
- adj.get(node)!.forEach(neighbor => {
530
- neighborComms.add(communities.get(neighbor)!);
531
- });
532
-
533
- for (const targetComm of Array.from(neighborComms)) {
534
- if (targetComm !== currentComm) {
535
- // Simple decision: join most common neighbor community
536
- communities.set(node, targetComm);
537
- improved = true;
538
- break;
539
- }
540
- }
541
-
542
- if (improved) break;
543
- }
544
-
545
- if (!improved) break;
546
-
547
- // Count communities
548
- const currentNumCommunities = new Set(communities.values()).size;
549
- numCommunities = currentNumCommunities;
550
- }
551
-
552
- // Group nodes by community
553
- const groups = new Map<number, Node[]>();
554
- communities.forEach((comm, node) => {
555
- if (!groups.has(comm)) groups.set(comm, []);
556
- groups.get(comm)!.push(node);
557
- });
558
-
559
- return Array.from(groups.values());
560
- }