@falkordb/canvas 0.0.44 → 0.0.49

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/src/layouts.ts ADDED
@@ -0,0 +1,993 @@
1
+ import * as d3 from "d3";
2
+ import {
3
+ ArcLayoutOptions,
4
+ ComponentsInnerLayout,
5
+ ComponentsLayoutOptions,
6
+ ComponentsSortMode,
7
+ ConcentricLayoutOptions,
8
+ ConcentricMetric,
9
+ FlowLayoutOptions,
10
+ GraphData,
11
+ GraphLink,
12
+ GraphNode,
13
+ LayoutDirection,
14
+ LayoutMode,
15
+ LayoutOptions,
16
+ RadialTreeLayoutOptions,
17
+ RingSortMode,
18
+ TreeLayoutOptions,
19
+ } from "./canvas-types.js";
20
+
21
+ type LayoutPoint = { x: number; y: number };
22
+ type TreeNode = { id: number; children: TreeNode[] };
23
+ type ComponentInfo = { nodeIds: number[]; edgeCount: number };
24
+
25
+ const DEFAULT_LAYOUT_MODE: LayoutMode = "force";
26
+ const LINK_CURVE_MULTIPLIER = 0.4;
27
+
28
+ const DEFAULT_TREE_OPTIONS: Required<Omit<TreeLayoutOptions, "rootNodeId">> = {
29
+ direction: "TB",
30
+ levelSpacing: 130,
31
+ nodeSpacing: 110,
32
+ componentSpacing: 180,
33
+ };
34
+ const DEFAULT_FLOW_OPTIONS: Required<FlowLayoutOptions> = {
35
+ direction: "LR",
36
+ layerSpacing: 180,
37
+ nodeSpacing: 110,
38
+ componentSpacing: 220,
39
+ };
40
+ const DEFAULT_RADIAL_TREE_OPTIONS: Required<Omit<RadialTreeLayoutOptions, "rootNodeId">> = {
41
+ direction: "TB",
42
+ startAngle: -Math.PI / 2,
43
+ endAngle: (3 * Math.PI) / 2,
44
+ radiusStep: 130,
45
+ componentSpacing: 250,
46
+ };
47
+ const DEFAULT_CONCENTRIC_OPTIONS: Required<Omit<ConcentricLayoutOptions, "rootNodeId">> = {
48
+ metric: "degree",
49
+ ringSpacing: 130,
50
+ minRingNodeSpacing: 80,
51
+ sortWithinRing: "id",
52
+ };
53
+ const DEFAULT_COMPONENTS_OPTIONS: Required<ComponentsLayoutOptions> = {
54
+ innerLayout: "concentric",
55
+ componentGap: 260,
56
+ maxColumns: 3,
57
+ sortComponentsBy: "size",
58
+ };
59
+ const DEFAULT_ARC_OPTIONS: Required<ArcLayoutOptions> = {
60
+ orderBy: "id",
61
+ direction: "LR",
62
+ nodeSpacing: 120,
63
+ curveScale: 0.22,
64
+ };
65
+
66
+ function orientPoint(x: number, y: number, direction: LayoutDirection): LayoutPoint {
67
+ switch (direction) {
68
+ case "BT":
69
+ return { x, y: -y };
70
+ case "LR":
71
+ return { x: y, y: x };
72
+ case "RL":
73
+ return { x: -y, y: x };
74
+ case "TB":
75
+ default:
76
+ return { x, y };
77
+ }
78
+ }
79
+
80
+ function getDirectionRotation(direction: LayoutDirection): number {
81
+ switch (direction) {
82
+ case "LR":
83
+ return 0;
84
+ case "RL":
85
+ return Math.PI;
86
+ case "BT":
87
+ return -Math.PI / 2;
88
+ case "TB":
89
+ default:
90
+ return Math.PI / 2;
91
+ }
92
+ }
93
+
94
+ function calculateLinkCurve(index: number, isSelfLoop: boolean): number {
95
+ const even = index % 2 === 0;
96
+
97
+ if (isSelfLoop) {
98
+ if (even) {
99
+ return (Math.floor(-(index / 2)) - 3) * LINK_CURVE_MULTIPLIER;
100
+ }
101
+ return (Math.floor((index + 1) / 2) + 2) * LINK_CURVE_MULTIPLIER;
102
+ }
103
+
104
+ if (even) {
105
+ return Math.floor(-(index / 2)) * LINK_CURVE_MULTIPLIER;
106
+ }
107
+ return Math.floor((index + 1) / 2) * LINK_CURVE_MULTIPLIER;
108
+ }
109
+
110
+ function resetDefaultLinkCurves(links: GraphLink[]) {
111
+ const linksByPairCount = new Map<number, Map<number, number>>();
112
+
113
+ for (const link of links) {
114
+ const sourceId = link.source.id;
115
+ const targetId = link.target.id;
116
+ const minId = Math.min(sourceId, targetId);
117
+ const maxId = Math.max(sourceId, targetId);
118
+
119
+ let pairMap = linksByPairCount.get(minId);
120
+ if (!pairMap) {
121
+ pairMap = new Map<number, number>();
122
+ linksByPairCount.set(minId, pairMap);
123
+ }
124
+
125
+ const duplicateIndex = pairMap.get(maxId) ?? 0;
126
+ pairMap.set(maxId, duplicateIndex + 1);
127
+ link.curve = calculateLinkCurve(duplicateIndex, sourceId === targetId);
128
+ }
129
+ }
130
+
131
+ function centerNodePositions(nodes: GraphNode[]) {
132
+ if (nodes.length === 0) return;
133
+
134
+ let minX = Number.POSITIVE_INFINITY;
135
+ let maxX = Number.NEGATIVE_INFINITY;
136
+ let minY = Number.POSITIVE_INFINITY;
137
+ let maxY = Number.NEGATIVE_INFINITY;
138
+
139
+ for (const node of nodes) {
140
+ const x = node.x ?? 0;
141
+ const y = node.y ?? 0;
142
+ minX = Math.min(minX, x);
143
+ maxX = Math.max(maxX, x);
144
+ minY = Math.min(minY, y);
145
+ maxY = Math.max(maxY, y);
146
+ }
147
+
148
+ const centerX = (minX + maxX) / 2;
149
+ const centerY = (minY + maxY) / 2;
150
+
151
+ for (const node of nodes) {
152
+ node.x = (node.x ?? 0) - centerX;
153
+ node.y = (node.y ?? 0) - centerY;
154
+ }
155
+ }
156
+
157
+ function pinAllNodes(nodes: GraphNode[]) {
158
+ for (const node of nodes) {
159
+ const x = node.x ?? 0;
160
+ const y = node.y ?? 0;
161
+ node.x = x;
162
+ node.y = y;
163
+ node.fx = x;
164
+ node.fy = y;
165
+ node.vx = 0;
166
+ node.vy = 0;
167
+ node.initialPositionCalculated = true;
168
+ }
169
+ }
170
+
171
+ function unpinAllNodes(nodes: GraphNode[]) {
172
+ for (const node of nodes) {
173
+ node.fx = undefined;
174
+ node.fy = undefined;
175
+ }
176
+ }
177
+
178
+ function createAdjacency(graphData: GraphData) {
179
+ const nodeIds = graphData.nodes.map((node) => node.id);
180
+ const nodeIdSet = new Set<number>(nodeIds);
181
+ const outgoing = new Map<number, number[]>();
182
+ const incoming = new Map<number, number[]>();
183
+ const inDegree = new Map<number, number>();
184
+
185
+ for (const id of nodeIds) {
186
+ outgoing.set(id, []);
187
+ incoming.set(id, []);
188
+ inDegree.set(id, 0);
189
+ }
190
+
191
+ for (const link of graphData.links) {
192
+ const sourceId = link.source.id;
193
+ const targetId = link.target.id;
194
+
195
+ if (!nodeIdSet.has(sourceId) || !nodeIdSet.has(targetId)) continue;
196
+ if (sourceId === targetId) continue;
197
+
198
+ const sourceOutgoing = outgoing.get(sourceId);
199
+ const targetIncoming = incoming.get(targetId);
200
+ if (!sourceOutgoing || !targetIncoming) continue;
201
+ if (sourceOutgoing.includes(targetId)) continue;
202
+
203
+ sourceOutgoing.push(targetId);
204
+ targetIncoming.push(sourceId);
205
+ inDegree.set(targetId, (inDegree.get(targetId) ?? 0) + 1);
206
+ }
207
+
208
+ for (const ids of outgoing.values()) ids.sort((a, b) => a - b);
209
+ for (const ids of incoming.values()) ids.sort((a, b) => a - b);
210
+
211
+ return { nodeIds, outgoing, incoming, inDegree };
212
+ }
213
+
214
+ function getNodeMap(nodes: GraphNode[]) {
215
+ const nodeMap = new Map<number, GraphNode>();
216
+ for (const node of nodes) {
217
+ nodeMap.set(node.id, node);
218
+ }
219
+ return nodeMap;
220
+ }
221
+
222
+ function getNodeLabel(node: GraphNode): string {
223
+ const label = node.labels[0] ?? "";
224
+ return label || String(node.id);
225
+ }
226
+
227
+ function computeDegreeMaps(nodeIds: number[], outgoing: Map<number, number[]>, incoming: Map<number, number[]>) {
228
+ const inDegree = new Map<number, number>();
229
+ const outDegree = new Map<number, number>();
230
+ const degree = new Map<number, number>();
231
+
232
+ for (const id of nodeIds) {
233
+ const inCount = incoming.get(id)?.length ?? 0;
234
+ const outCount = outgoing.get(id)?.length ?? 0;
235
+ inDegree.set(id, inCount);
236
+ outDegree.set(id, outCount);
237
+ degree.set(id, inCount + outCount);
238
+ }
239
+
240
+ return { inDegree, outDegree, degree };
241
+ }
242
+
243
+ function buildUndirectedAdjacency(nodeIds: number[], outgoing: Map<number, number[]>, incoming: Map<number, number[]>) {
244
+ const undirected = new Map<number, number[]>();
245
+
246
+ for (const id of nodeIds) {
247
+ const neighbors = new Set<number>();
248
+ for (const target of outgoing.get(id) ?? []) neighbors.add(target);
249
+ for (const source of incoming.get(id) ?? []) neighbors.add(source);
250
+ undirected.set(id, [...neighbors].sort((a, b) => a - b));
251
+ }
252
+
253
+ return undirected;
254
+ }
255
+
256
+ function getDefaultRootId(
257
+ nodeIds: number[],
258
+ degreeMap: Map<number, number>,
259
+ preferredRootId?: number
260
+ ) {
261
+ if (preferredRootId !== undefined && nodeIds.includes(preferredRootId)) {
262
+ return preferredRootId;
263
+ }
264
+
265
+ let bestId = nodeIds[0];
266
+ let bestDegree = Number.NEGATIVE_INFINITY;
267
+
268
+ for (const nodeId of nodeIds) {
269
+ const nodeDegree = degreeMap.get(nodeId) ?? 0;
270
+ if (nodeDegree > bestDegree || (nodeDegree === bestDegree && nodeId < bestId)) {
271
+ bestDegree = nodeDegree;
272
+ bestId = nodeId;
273
+ }
274
+ }
275
+
276
+ return bestId;
277
+ }
278
+
279
+ function computeBfsDepths(
280
+ nodeIds: number[],
281
+ outgoing: Map<number, number[]>,
282
+ incoming: Map<number, number[]>,
283
+ rootId?: number
284
+ ) {
285
+ const undirected = buildUndirectedAdjacency(nodeIds, outgoing, incoming);
286
+ const degreeMap = computeDegreeMaps(nodeIds, outgoing, incoming).degree;
287
+ const sourceRoot = getDefaultRootId(nodeIds, degreeMap, rootId);
288
+ const depths = new Map<number, number>();
289
+ const queue: number[] = [sourceRoot];
290
+
291
+ depths.set(sourceRoot, 0);
292
+
293
+ while (queue.length > 0) {
294
+ const current = queue.shift();
295
+ if (current === undefined) continue;
296
+
297
+ const currentDepth = depths.get(current) ?? 0;
298
+ for (const neighbor of undirected.get(current) ?? []) {
299
+ if (depths.has(neighbor)) continue;
300
+ depths.set(neighbor, currentDepth + 1);
301
+ queue.push(neighbor);
302
+ }
303
+ }
304
+
305
+ let maxDepth = 0;
306
+ for (const depth of depths.values()) {
307
+ maxDepth = Math.max(maxDepth, depth);
308
+ }
309
+
310
+ for (const nodeId of nodeIds) {
311
+ if (!depths.has(nodeId)) depths.set(nodeId, maxDepth + 1);
312
+ }
313
+
314
+ return depths;
315
+ }
316
+
317
+ function sortNodeIdsByMode(
318
+ nodeIds: number[],
319
+ mode: RingSortMode,
320
+ nodeMap: Map<number, GraphNode>,
321
+ degreeMap: Map<number, number>
322
+ ) {
323
+ const sorted = [...nodeIds];
324
+ sorted.sort((a, b) => {
325
+ if (mode === "degree") {
326
+ const diff = (degreeMap.get(b) ?? 0) - (degreeMap.get(a) ?? 0);
327
+ if (diff !== 0) return diff;
328
+ } else if (mode === "label") {
329
+ const aNode = nodeMap.get(a);
330
+ const bNode = nodeMap.get(b);
331
+ const aLabel = aNode ? getNodeLabel(aNode) : "";
332
+ const bLabel = bNode ? getNodeLabel(bNode) : "";
333
+ const labelDiff = aLabel.localeCompare(bLabel);
334
+ if (labelDiff !== 0) return labelDiff;
335
+ }
336
+ return a - b;
337
+ });
338
+ return sorted;
339
+ }
340
+
341
+ function buildForest(
342
+ nodeIds: number[],
343
+ outgoing: Map<number, number[]>,
344
+ inDegree: Map<number, number>,
345
+ rootNodeId?: number
346
+ ) {
347
+ const nodeIdSet = new Set<number>(nodeIds);
348
+ const preferredRoots: number[] = [];
349
+ const visited = new Set<number>();
350
+ const forest: TreeNode[] = [];
351
+
352
+ if (rootNodeId !== undefined && nodeIdSet.has(rootNodeId)) {
353
+ preferredRoots.push(rootNodeId);
354
+ }
355
+
356
+ for (const rootId of nodeIds) {
357
+ if ((inDegree.get(rootId) ?? 0) === 0 && !preferredRoots.includes(rootId)) {
358
+ preferredRoots.push(rootId);
359
+ }
360
+ }
361
+
362
+ if (preferredRoots.length === 0 && nodeIds.length > 0) {
363
+ preferredRoots.push(nodeIds[0]);
364
+ }
365
+
366
+ const addTreeRoot = (rootId: number) => {
367
+ if (visited.has(rootId)) return;
368
+
369
+ const root: TreeNode = { id: rootId, children: [] };
370
+ const queue: Array<{ node: TreeNode; id: number }> = [{ node: root, id: rootId }];
371
+ visited.add(rootId);
372
+
373
+ while (queue.length > 0) {
374
+ const current = queue.shift();
375
+ if (!current) continue;
376
+
377
+ const children = outgoing.get(current.id) ?? [];
378
+ for (const childId of children) {
379
+ if (visited.has(childId)) continue;
380
+
381
+ visited.add(childId);
382
+ const childNode: TreeNode = { id: childId, children: [] };
383
+ current.node.children.push(childNode);
384
+ queue.push({ node: childNode, id: childId });
385
+ }
386
+ }
387
+
388
+ forest.push(root);
389
+ };
390
+
391
+ for (const rootId of preferredRoots) addTreeRoot(rootId);
392
+ for (const nodeId of nodeIds) addTreeRoot(nodeId);
393
+
394
+ return forest;
395
+ }
396
+
397
+ function collectWeaklyConnectedComponents(
398
+ nodeIds: number[],
399
+ outgoing: Map<number, number[]>,
400
+ incoming: Map<number, number[]>
401
+ ) {
402
+ const visited = new Set<number>();
403
+ const components: number[][] = [];
404
+
405
+ for (const nodeId of nodeIds) {
406
+ if (visited.has(nodeId)) continue;
407
+
408
+ const queue: number[] = [nodeId];
409
+ const component: number[] = [];
410
+ visited.add(nodeId);
411
+
412
+ while (queue.length > 0) {
413
+ const current = queue.shift();
414
+ if (current === undefined) continue;
415
+ component.push(current);
416
+
417
+ const neighbors = [
418
+ ...(outgoing.get(current) ?? []),
419
+ ...(incoming.get(current) ?? []),
420
+ ];
421
+
422
+ for (const next of neighbors) {
423
+ if (visited.has(next)) continue;
424
+ visited.add(next);
425
+ queue.push(next);
426
+ }
427
+ }
428
+
429
+ component.sort((a, b) => a - b);
430
+ components.push(component);
431
+ }
432
+
433
+ return components;
434
+ }
435
+
436
+ function getGraphBounds(nodes: GraphNode[]) {
437
+ if (nodes.length === 0) {
438
+ return { minX: 0, maxX: 0, minY: 0, maxY: 0, width: 0, height: 0 };
439
+ }
440
+
441
+ let minX = Number.POSITIVE_INFINITY;
442
+ let maxX = Number.NEGATIVE_INFINITY;
443
+ let minY = Number.POSITIVE_INFINITY;
444
+ let maxY = Number.NEGATIVE_INFINITY;
445
+
446
+ for (const node of nodes) {
447
+ const x = node.x ?? 0;
448
+ const y = node.y ?? 0;
449
+ minX = Math.min(minX, x);
450
+ maxX = Math.max(maxX, x);
451
+ minY = Math.min(minY, y);
452
+ maxY = Math.max(maxY, y);
453
+ }
454
+
455
+ return {
456
+ minX,
457
+ maxX,
458
+ minY,
459
+ maxY,
460
+ width: maxX - minX,
461
+ height: maxY - minY,
462
+ };
463
+ }
464
+
465
+ function assignFlowLayers(
466
+ componentNodeIds: number[],
467
+ outgoing: Map<number, number[]>,
468
+ incoming: Map<number, number[]>
469
+ ) {
470
+ const componentSet = new Set(componentNodeIds);
471
+ const inDegree = new Map<number, number>();
472
+ const layers = new Map<number, number>();
473
+ const processed = new Set<number>();
474
+ const queue: number[] = [];
475
+
476
+ for (const nodeId of componentNodeIds) {
477
+ const degree = (incoming.get(nodeId) ?? []).filter((id) => componentSet.has(id)).length;
478
+ inDegree.set(nodeId, degree);
479
+ if (degree === 0) queue.push(nodeId);
480
+ }
481
+
482
+ while (queue.length > 0) {
483
+ const current = queue.shift();
484
+ if (current === undefined) continue;
485
+ processed.add(current);
486
+
487
+ const currentLayer = layers.get(current) ?? 0;
488
+ const children = outgoing.get(current) ?? [];
489
+
490
+ for (const childId of children) {
491
+ if (!componentSet.has(childId)) continue;
492
+ if (childId === current) continue;
493
+
494
+ const nextLayer = Math.max(layers.get(childId) ?? 0, currentLayer + 1);
495
+ layers.set(childId, nextLayer);
496
+
497
+ const nextInDegree = (inDegree.get(childId) ?? 0) - 1;
498
+ inDegree.set(childId, nextInDegree);
499
+ if (nextInDegree === 0) queue.push(childId);
500
+ }
501
+ }
502
+
503
+ let maxAssignedLayer = 0;
504
+ for (const layer of layers.values()) {
505
+ maxAssignedLayer = Math.max(maxAssignedLayer, layer);
506
+ }
507
+
508
+ for (const nodeId of componentNodeIds) {
509
+ if (processed.has(nodeId)) continue;
510
+ const parentLayers = (incoming.get(nodeId) ?? [])
511
+ .filter((parentId) => componentSet.has(parentId))
512
+ .map((parentId) => layers.get(parentId))
513
+ .filter((layer): layer is number => layer !== undefined);
514
+
515
+ const fallbackLayer = maxAssignedLayer + 1;
516
+ const layer = parentLayers.length > 0 ? Math.max(...parentLayers) + 1 : fallbackLayer;
517
+ layers.set(nodeId, layer);
518
+ maxAssignedLayer = Math.max(maxAssignedLayer, layer);
519
+ }
520
+
521
+ for (const nodeId of componentNodeIds) {
522
+ if (!layers.has(nodeId)) layers.set(nodeId, 0);
523
+ }
524
+
525
+ return layers;
526
+ }
527
+
528
+ function applyTreeLayout(graphData: GraphData, treeOptions?: TreeLayoutOptions) {
529
+ const options = { ...DEFAULT_TREE_OPTIONS, ...treeOptions };
530
+ const { nodeIds, outgoing, inDegree } = createAdjacency(graphData);
531
+ const forest = buildForest(nodeIds, outgoing, inDegree, treeOptions?.rootNodeId);
532
+ const positionByNodeId = new Map<number, LayoutPoint>();
533
+ let breadthOffset = 0;
534
+
535
+ for (const treeRoot of forest) {
536
+ const rootHierarchy = d3.hierarchy(treeRoot, (node) => node.children);
537
+ const treeLayout = d3
538
+ .tree<TreeNode>()
539
+ .nodeSize([options.nodeSpacing, options.levelSpacing]);
540
+
541
+ treeLayout(rootHierarchy);
542
+
543
+ let minBreadth = Number.POSITIVE_INFINITY;
544
+ let maxBreadth = Number.NEGATIVE_INFINITY;
545
+ rootHierarchy.each((node) => {
546
+ const breadth = node.x ?? 0;
547
+ minBreadth = Math.min(minBreadth, breadth);
548
+ maxBreadth = Math.max(maxBreadth, breadth);
549
+ });
550
+
551
+ rootHierarchy.each((node) => {
552
+ const localBreadth = (node.x ?? 0) - minBreadth + breadthOffset;
553
+ const localDepth = node.y ?? 0;
554
+ positionByNodeId.set(
555
+ node.data.id,
556
+ orientPoint(localBreadth, localDepth, options.direction)
557
+ );
558
+ });
559
+
560
+ const componentBreadth = maxBreadth - minBreadth;
561
+ breadthOffset += componentBreadth + options.componentSpacing;
562
+ }
563
+
564
+ for (const node of graphData.nodes) {
565
+ const position = positionByNodeId.get(node.id);
566
+ if (!position) continue;
567
+ node.x = position.x;
568
+ node.y = position.y;
569
+ }
570
+
571
+ centerNodePositions(graphData.nodes);
572
+ }
573
+
574
+ function applyRadialTreeLayout(graphData: GraphData, radialOptions?: RadialTreeLayoutOptions) {
575
+ const options = { ...DEFAULT_RADIAL_TREE_OPTIONS, ...radialOptions };
576
+ const { nodeIds, outgoing, inDegree } = createAdjacency(graphData);
577
+ const forest = buildForest(nodeIds, outgoing, inDegree, radialOptions?.rootNodeId);
578
+ const angleSpan = Math.max(0.001, options.endAngle - options.startAngle);
579
+ const rotation = getDirectionRotation(options.direction);
580
+ const nodeMap = getNodeMap(graphData.nodes);
581
+ let offsetX = 0;
582
+
583
+ for (const treeRoot of forest) {
584
+ const rootHierarchy = d3.hierarchy(treeRoot, (node) => node.children);
585
+ const treeLayout = d3.tree<TreeNode>().size([angleSpan, options.radiusStep * rootHierarchy.height]);
586
+ treeLayout(rootHierarchy);
587
+
588
+ const points: Array<{ id: number; x: number; y: number }> = [];
589
+ rootHierarchy.each((node) => {
590
+ const angle = options.startAngle + (node.x ?? 0) + rotation;
591
+ const radius = node.y ?? 0;
592
+ points.push({
593
+ id: node.data.id,
594
+ x: Math.cos(angle) * radius,
595
+ y: Math.sin(angle) * radius,
596
+ });
597
+ });
598
+
599
+ let minX = Number.POSITIVE_INFINITY;
600
+ let maxX = Number.NEGATIVE_INFINITY;
601
+ for (const point of points) {
602
+ minX = Math.min(minX, point.x);
603
+ maxX = Math.max(maxX, point.x);
604
+ }
605
+
606
+ for (const point of points) {
607
+ const node = nodeMap.get(point.id);
608
+ if (!node) continue;
609
+ node.x = point.x - minX + offsetX;
610
+ node.y = point.y;
611
+ }
612
+
613
+ offsetX += (maxX - minX) + options.componentSpacing;
614
+ }
615
+
616
+ centerNodePositions(graphData.nodes);
617
+ }
618
+
619
+ function applyFlowLayout(graphData: GraphData, flowOptions?: FlowLayoutOptions) {
620
+ const options = { ...DEFAULT_FLOW_OPTIONS, ...flowOptions };
621
+ const { nodeIds, outgoing, incoming } = createAdjacency(graphData);
622
+ const components = collectWeaklyConnectedComponents(nodeIds, outgoing, incoming);
623
+ const positionByNodeId = new Map<number, LayoutPoint>();
624
+ let breadthOffset = 0;
625
+
626
+ for (const componentNodeIds of components) {
627
+ const componentSet = new Set(componentNodeIds);
628
+ const layersByNode = assignFlowLayers(componentNodeIds, outgoing, incoming);
629
+ const nodesByLayer = new Map<number, number[]>();
630
+ const priorLayerOrder = new Map<number, number>();
631
+
632
+ for (const nodeId of componentNodeIds) {
633
+ const layer = layersByNode.get(nodeId) ?? 0;
634
+ const layerNodes = nodesByLayer.get(layer) ?? [];
635
+ layerNodes.push(nodeId);
636
+ nodesByLayer.set(layer, layerNodes);
637
+ }
638
+
639
+ const sortedLayers = [...nodesByLayer.keys()].sort((a, b) => a - b);
640
+
641
+ for (const layer of sortedLayers) {
642
+ const layerNodes = nodesByLayer.get(layer);
643
+ if (!layerNodes) continue;
644
+
645
+ layerNodes.sort((a, b) => {
646
+ const aParents = (incoming.get(a) ?? [])
647
+ .filter((id) => componentSet.has(id))
648
+ .map((id) => priorLayerOrder.get(id))
649
+ .filter((index): index is number => index !== undefined);
650
+ const bParents = (incoming.get(b) ?? [])
651
+ .filter((id) => componentSet.has(id))
652
+ .map((id) => priorLayerOrder.get(id))
653
+ .filter((index): index is number => index !== undefined);
654
+
655
+ const aScore = aParents.length > 0
656
+ ? aParents.reduce((sum, index) => sum + index, 0) / aParents.length
657
+ : Number.POSITIVE_INFINITY;
658
+ const bScore = bParents.length > 0
659
+ ? bParents.reduce((sum, index) => sum + index, 0) / bParents.length
660
+ : Number.POSITIVE_INFINITY;
661
+
662
+ if (aScore === bScore) return a - b;
663
+ return aScore - bScore;
664
+ });
665
+
666
+ layerNodes.forEach((nodeId, index) => {
667
+ priorLayerOrder.set(nodeId, index);
668
+ });
669
+ }
670
+
671
+ let minBreadth = Number.POSITIVE_INFINITY;
672
+ let maxBreadth = Number.NEGATIVE_INFINITY;
673
+
674
+ for (const layer of sortedLayers) {
675
+ const layerNodes = nodesByLayer.get(layer);
676
+ if (!layerNodes) continue;
677
+
678
+ const centerIndex = (layerNodes.length - 1) / 2;
679
+ layerNodes.forEach((nodeId, index) => {
680
+ const breadth = (index - centerIndex) * options.nodeSpacing;
681
+ const depth = layer * options.layerSpacing;
682
+ minBreadth = Math.min(minBreadth, breadth);
683
+ maxBreadth = Math.max(maxBreadth, breadth);
684
+ positionByNodeId.set(nodeId, { x: breadth, y: depth });
685
+ });
686
+ }
687
+
688
+ for (const nodeId of componentNodeIds) {
689
+ const basePoint = positionByNodeId.get(nodeId);
690
+ if (!basePoint) continue;
691
+ const normalizedBreadth = basePoint.x - minBreadth + breadthOffset;
692
+ positionByNodeId.set(nodeId, { x: normalizedBreadth, y: basePoint.y });
693
+ }
694
+
695
+ const componentBreadth = maxBreadth - minBreadth;
696
+ breadthOffset += componentBreadth + options.componentSpacing;
697
+ }
698
+
699
+ for (const node of graphData.nodes) {
700
+ const basePoint = positionByNodeId.get(node.id);
701
+ if (!basePoint) continue;
702
+ const orientedPoint = orientPoint(basePoint.x, basePoint.y, options.direction);
703
+ node.x = orientedPoint.x;
704
+ node.y = orientedPoint.y;
705
+ }
706
+
707
+ centerNodePositions(graphData.nodes);
708
+ }
709
+
710
+ function getMetricValues(
711
+ metric: ConcentricMetric,
712
+ nodeIds: number[],
713
+ outgoing: Map<number, number[]>,
714
+ incoming: Map<number, number[]>,
715
+ rootNodeId?: number
716
+ ) {
717
+ const { degree, inDegree, outDegree } = computeDegreeMaps(nodeIds, outgoing, incoming);
718
+
719
+ if (metric === "inDegree") return inDegree;
720
+ if (metric === "outDegree") return outDegree;
721
+ if (metric === "bfsDepth") return computeBfsDepths(nodeIds, outgoing, incoming, rootNodeId);
722
+ return degree;
723
+ }
724
+
725
+ function applyConcentricLayout(graphData: GraphData, concentricOptions?: ConcentricLayoutOptions) {
726
+ const options = { ...DEFAULT_CONCENTRIC_OPTIONS, ...concentricOptions };
727
+ const { nodeIds, outgoing, incoming } = createAdjacency(graphData);
728
+ const nodeMap = getNodeMap(graphData.nodes);
729
+ const degreeMap = computeDegreeMaps(nodeIds, outgoing, incoming).degree;
730
+ const metricValues = getMetricValues(options.metric, nodeIds, outgoing, incoming, options.rootNodeId);
731
+ const ringByNode = new Map<number, number>();
732
+ const nodesByRing = new Map<number, number[]>();
733
+
734
+ if (options.metric === "bfsDepth") {
735
+ for (const nodeId of nodeIds) {
736
+ ringByNode.set(nodeId, metricValues.get(nodeId) ?? 0);
737
+ }
738
+ } else {
739
+ const uniqueValues = [...new Set(nodeIds.map((nodeId) => metricValues.get(nodeId) ?? 0))]
740
+ .sort((a, b) => b - a);
741
+ const ringByMetric = new Map<number, number>();
742
+ uniqueValues.forEach((value, index) => {
743
+ ringByMetric.set(value, index);
744
+ });
745
+ for (const nodeId of nodeIds) {
746
+ const value = metricValues.get(nodeId) ?? 0;
747
+ ringByNode.set(nodeId, ringByMetric.get(value) ?? 0);
748
+ }
749
+ }
750
+
751
+ for (const nodeId of nodeIds) {
752
+ const ring = ringByNode.get(nodeId) ?? 0;
753
+ const ringNodes = nodesByRing.get(ring) ?? [];
754
+ ringNodes.push(nodeId);
755
+ nodesByRing.set(ring, ringNodes);
756
+ }
757
+
758
+ const rings = [...nodesByRing.keys()].sort((a, b) => a - b);
759
+ const baseAngle = -Math.PI / 2;
760
+
761
+ for (const ring of rings) {
762
+ const ringNodeIds = nodesByRing.get(ring);
763
+ if (!ringNodeIds) continue;
764
+ const sortedRingNodes = sortNodeIdsByMode(ringNodeIds, options.sortWithinRing, nodeMap, degreeMap);
765
+ const nodeCount = sortedRingNodes.length;
766
+
767
+ let radius = ring === 0 ? 0 : ring * options.ringSpacing;
768
+ if (nodeCount > 1) {
769
+ const minRadiusForSpacing = (nodeCount * options.minRingNodeSpacing) / (2 * Math.PI);
770
+ radius = Math.max(radius || options.ringSpacing, minRadiusForSpacing);
771
+ }
772
+
773
+ sortedRingNodes.forEach((nodeId, index) => {
774
+ const node = nodeMap.get(nodeId);
775
+ if (!node) return;
776
+
777
+ if (nodeCount === 1 && ring === 0) {
778
+ node.x = 0;
779
+ node.y = 0;
780
+ return;
781
+ }
782
+
783
+ const angle = baseAngle + ((2 * Math.PI * index) / nodeCount);
784
+ node.x = Math.cos(angle) * radius;
785
+ node.y = Math.sin(angle) * radius;
786
+ });
787
+ }
788
+
789
+ centerNodePositions(graphData.nodes);
790
+ }
791
+
792
+ function applyArcLayout(graphData: GraphData, arcOptions?: ArcLayoutOptions) {
793
+ const options = { ...DEFAULT_ARC_OPTIONS, ...arcOptions };
794
+ const { nodeIds, outgoing, incoming } = createAdjacency(graphData);
795
+ const nodeMap = getNodeMap(graphData.nodes);
796
+ const degreeMap = computeDegreeMaps(nodeIds, outgoing, incoming).degree;
797
+ const orderedIds = sortNodeIdsByMode(nodeIds, options.orderBy, nodeMap, degreeMap);
798
+
799
+ if (options.direction === "RL") orderedIds.reverse();
800
+
801
+ const nodeIndex = new Map<number, number>();
802
+ orderedIds.forEach((nodeId, index) => {
803
+ nodeIndex.set(nodeId, index);
804
+ const node = nodeMap.get(nodeId);
805
+ if (!node) return;
806
+ node.x = index * options.nodeSpacing;
807
+ node.y = 0;
808
+ });
809
+ const linksByPairCount = new Map<number, Map<number, number>>();
810
+
811
+ for (const link of graphData.links) {
812
+ const sourceIndex = nodeIndex.get(link.source.id);
813
+ const targetIndex = nodeIndex.get(link.target.id);
814
+ if (sourceIndex === undefined || targetIndex === undefined) continue;
815
+
816
+ const sourceId = link.source.id;
817
+ const targetId = link.target.id;
818
+ const minId = Math.min(sourceId, targetId);
819
+ const maxId = Math.max(sourceId, targetId);
820
+ let pairMap = linksByPairCount.get(minId);
821
+ if (!pairMap) {
822
+ pairMap = new Map<number, number>();
823
+ linksByPairCount.set(minId, pairMap);
824
+ }
825
+ const duplicateIndex = pairMap.get(maxId) ?? 0;
826
+ pairMap.set(maxId, duplicateIndex + 1);
827
+
828
+ if (sourceIndex === targetIndex) {
829
+ link.curve = calculateLinkCurve(duplicateIndex, true);
830
+ continue;
831
+ }
832
+
833
+ const distance = Math.max(1, Math.abs(sourceIndex - targetIndex));
834
+ const magnitude = Math.max(0.25, distance * options.curveScale);
835
+ const baseCurve = calculateLinkCurve(duplicateIndex, false);
836
+ link.curve = (sourceIndex < targetIndex ? -magnitude : magnitude) + baseCurve;
837
+ }
838
+
839
+ centerNodePositions(graphData.nodes);
840
+ }
841
+
842
+ function createComponentSubGraph(graphData: GraphData, nodeIds: number[]) {
843
+ const nodeIdSet = new Set<number>(nodeIds);
844
+ const nodes = graphData.nodes
845
+ .filter((node) => nodeIdSet.has(node.id))
846
+ .map((node) => ({ ...node }));
847
+ const nodeMap = new Map<number, GraphNode>();
848
+ for (const node of nodes) nodeMap.set(node.id, node);
849
+
850
+ const links = graphData.links
851
+ .filter((link) => nodeIdSet.has(link.source.id) && nodeIdSet.has(link.target.id))
852
+ .map((link) => ({
853
+ ...link,
854
+ source: nodeMap.get(link.source.id)!,
855
+ target: nodeMap.get(link.target.id)!,
856
+ }));
857
+
858
+ return { nodes, links } as GraphData;
859
+ }
860
+
861
+ function applyInnerComponentsLayout(
862
+ subGraph: GraphData,
863
+ innerLayout: ComponentsInnerLayout,
864
+ layoutOptions?: LayoutOptions
865
+ ) {
866
+ if (innerLayout === "flow") {
867
+ applyFlowLayout(subGraph, layoutOptions?.flow);
868
+ return;
869
+ }
870
+ if (innerLayout === "tree") {
871
+ applyTreeLayout(subGraph, layoutOptions?.tree);
872
+ return;
873
+ }
874
+ if (innerLayout === "radial-tree") {
875
+ applyRadialTreeLayout(subGraph, layoutOptions?.radialTree);
876
+ return;
877
+ }
878
+ applyConcentricLayout(subGraph, layoutOptions?.concentric);
879
+ }
880
+
881
+ function getComponentsInfo(
882
+ graphData: GraphData,
883
+ sortBy: ComponentsSortMode
884
+ ) {
885
+ const { nodeIds, outgoing, incoming } = createAdjacency(graphData);
886
+ const components = collectWeaklyConnectedComponents(nodeIds, outgoing, incoming);
887
+ const info: ComponentInfo[] = components.map((componentNodeIds) => {
888
+ const nodeSet = new Set(componentNodeIds);
889
+ let edgeCount = 0;
890
+ for (const link of graphData.links) {
891
+ if (nodeSet.has(link.source.id) && nodeSet.has(link.target.id)) edgeCount += 1;
892
+ }
893
+ return { nodeIds: componentNodeIds, edgeCount };
894
+ });
895
+
896
+ info.sort((a, b) => {
897
+ if (sortBy === "edgeCount") {
898
+ const edgeDiff = b.edgeCount - a.edgeCount;
899
+ if (edgeDiff !== 0) return edgeDiff;
900
+ }
901
+ const sizeDiff = b.nodeIds.length - a.nodeIds.length;
902
+ if (sizeDiff !== 0) return sizeDiff;
903
+ return a.nodeIds[0] - b.nodeIds[0];
904
+ });
905
+
906
+ return info;
907
+ }
908
+
909
+ function applyComponentsLayout(
910
+ graphData: GraphData,
911
+ componentsOptions?: ComponentsLayoutOptions,
912
+ layoutOptions?: LayoutOptions
913
+ ) {
914
+ const options = { ...DEFAULT_COMPONENTS_OPTIONS, ...componentsOptions };
915
+ const components = getComponentsInfo(graphData, options.sortComponentsBy);
916
+ const maxColumns = Math.max(1, options.maxColumns);
917
+ const globalNodeMap = getNodeMap(graphData.nodes);
918
+
919
+ let currentColumn = 0;
920
+ let currentX = 0;
921
+ let currentY = 0;
922
+ let rowHeight = 0;
923
+
924
+ for (const component of components) {
925
+ if (currentColumn >= maxColumns) {
926
+ currentColumn = 0;
927
+ currentX = 0;
928
+ currentY += rowHeight + options.componentGap;
929
+ rowHeight = 0;
930
+ }
931
+
932
+ const subGraph = createComponentSubGraph(graphData, component.nodeIds);
933
+ applyInnerComponentsLayout(subGraph, options.innerLayout, layoutOptions);
934
+
935
+ const bounds = getGraphBounds(subGraph.nodes);
936
+ const width = Math.max(1, bounds.width);
937
+ const height = Math.max(1, bounds.height);
938
+
939
+ const shiftX = currentX - bounds.minX;
940
+ const shiftY = currentY - bounds.minY;
941
+
942
+ for (const node of subGraph.nodes) {
943
+ const globalNode = globalNodeMap.get(node.id);
944
+ if (!globalNode) continue;
945
+ globalNode.x = (node.x ?? 0) + shiftX;
946
+ globalNode.y = (node.y ?? 0) + shiftY;
947
+ }
948
+
949
+ currentX += width + options.componentGap;
950
+ rowHeight = Math.max(rowHeight, height);
951
+ currentColumn += 1;
952
+ }
953
+
954
+ centerNodePositions(graphData.nodes);
955
+ }
956
+
957
+ export function isForceLayout(layoutMode?: LayoutMode): boolean {
958
+ return (layoutMode ?? DEFAULT_LAYOUT_MODE) === "force";
959
+ }
960
+
961
+ export function applyGraphLayout(
962
+ graphData: GraphData,
963
+ layoutMode?: LayoutMode,
964
+ layoutOptions?: LayoutOptions
965
+ ) {
966
+ const mode = layoutMode ?? DEFAULT_LAYOUT_MODE;
967
+
968
+ if (mode !== "arc") {
969
+ resetDefaultLinkCurves(graphData.links);
970
+ }
971
+
972
+ if (mode === "force") {
973
+ unpinAllNodes(graphData.nodes);
974
+ return graphData;
975
+ }
976
+
977
+ if (mode === "tree") {
978
+ applyTreeLayout(graphData, layoutOptions?.tree);
979
+ } else if (mode === "flow") {
980
+ applyFlowLayout(graphData, layoutOptions?.flow);
981
+ } else if (mode === "radial-tree") {
982
+ applyRadialTreeLayout(graphData, layoutOptions?.radialTree);
983
+ } else if (mode === "concentric") {
984
+ applyConcentricLayout(graphData, layoutOptions?.concentric);
985
+ } else if (mode === "components") {
986
+ applyComponentsLayout(graphData, layoutOptions?.components, layoutOptions);
987
+ } else if (mode === "arc") {
988
+ applyArcLayout(graphData, layoutOptions?.arc);
989
+ }
990
+
991
+ pinAllNodes(graphData.nodes);
992
+ return graphData;
993
+ }