@graphty/layout 1.0.1 → 1.1.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/.github/workflows/ci.yml +105 -0
- package/.releaserc.json +22 -0
- package/CHANGELOG.md +24 -0
- package/CLAUDE.md +104 -0
- package/CONTRIBUTING.md +1 -0
- package/README.md +893 -34
- package/dist/layout-helpers.d.ts +123 -0
- package/dist/layout-helpers.js +457 -0
- package/dist/layout-helpers.js.map +1 -0
- package/dist/layout.d.ts +275 -0
- package/dist/layout.js +2280 -0
- package/dist/layout.js.map +1 -0
- package/dist/vitest.config.d.ts +2 -0
- package/dist/vitest.config.js +30 -0
- package/dist/vitest.config.js.map +1 -0
- package/examples/arf-layout.html +1 -1
- package/examples/bfs-layout.html +37 -39
- package/examples/bipartite-layout.html +77 -69
- package/examples/circular-layout.html +13 -34
- package/examples/forceatlas2-layout.html +122 -28
- package/examples/kamada-kawai-layout.html +1 -1
- package/examples/multipartite-layout.html +64 -51
- package/examples/planar-layout.html +1 -1
- package/examples/random-layout.html +1 -1
- package/examples/shell-layout.html +53 -34
- package/examples/spectral-layout.html +1 -1
- package/examples/spiral-layout.html +1 -1
- package/examples/spring-layout.html +12 -2
- package/layout-helpers.ts +559 -0
- package/{layout.js → layout.ts} +1261 -771
- package/package.json +22 -6
- package/test/arf-layout.test.ts +443 -0
- package/test/bfs-layout.test.ts +427 -0
- package/test/bipartite-layout.test.ts +344 -0
- package/test/circular-layout.test.ts +300 -0
- package/test/forceatlas2-layout.test.ts +405 -0
- package/test/fruchterman-reingold-layout.test.ts +477 -0
- package/test/graph-generators.test.ts +450 -0
- package/test/kamada-kawai-layout.test.ts +351 -0
- package/test/multipartite-layout.test.ts +404 -0
- package/test/planar-layout.test.ts +266 -0
- package/test/random-layout.test.ts +254 -0
- package/test/rescale-layout.test.ts +373 -0
- package/test/shell-layout.test.ts +347 -0
- package/test/spectral-layout.test.ts +378 -0
- package/test/spiral-layout.test.ts +338 -0
- package/test/spring-layout.test.ts +241 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +30 -0
- package/.husky/commit-msg +0 -1
- package/.husky/prepare-commit-msg +0 -1
|
@@ -0,0 +1,559 @@
|
|
|
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
|
+
numCommunities = new Set(communities.values()).size;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Group nodes by community
|
|
552
|
+
const groups = new Map<number, Node[]>();
|
|
553
|
+
communities.forEach((comm, node) => {
|
|
554
|
+
if (!groups.has(comm)) groups.set(comm, []);
|
|
555
|
+
groups.get(comm)!.push(node);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
return Array.from(groups.values());
|
|
559
|
+
}
|