@shumoku/core 0.2.0 → 0.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/dist/icons/build-icons.js +3 -3
- package/dist/icons/build-icons.js.map +1 -1
- package/dist/icons/generated-icons.js +10 -10
- package/dist/icons/generated-icons.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -8
- package/dist/index.js.map +1 -1
- package/dist/layout/hierarchical.d.ts +1 -1
- package/dist/layout/hierarchical.d.ts.map +1 -1
- package/dist/layout/hierarchical.js +82 -66
- package/dist/layout/hierarchical.js.map +1 -1
- package/dist/layout/index.d.ts +1 -1
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js.map +1 -1
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +13 -13
- package/dist/models/types.js.map +1 -1
- package/dist/themes/dark.d.ts.map +1 -1
- package/dist/themes/dark.js +1 -1
- package/dist/themes/dark.js.map +1 -1
- package/dist/themes/index.d.ts +3 -3
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +4 -4
- package/dist/themes/index.js.map +1 -1
- package/dist/themes/modern.d.ts.map +1 -1
- package/dist/themes/modern.js.map +1 -1
- package/dist/themes/types.d.ts.map +1 -1
- package/dist/themes/utils.d.ts +1 -1
- package/dist/themes/utils.d.ts.map +1 -1
- package/dist/themes/utils.js +5 -4
- package/dist/themes/utils.js.map +1 -1
- package/package.json +88 -92
- package/src/constants.ts +35 -35
- package/src/icons/build-icons.ts +12 -6
- package/src/icons/generated-icons.ts +12 -12
- package/src/index.test.ts +66 -0
- package/src/index.ts +6 -13
- package/src/layout/hierarchical.ts +1251 -1221
- package/src/layout/index.ts +1 -1
- package/src/models/types.ts +47 -37
- package/src/themes/dark.ts +15 -15
- package/src/themes/index.ts +7 -7
- package/src/themes/modern.ts +22 -22
- package/src/themes/types.ts +26 -26
- package/src/themes/utils.ts +25 -24
- package/dist/renderer/index.d.ts +0 -6
- package/dist/renderer/index.d.ts.map +0 -1
- package/dist/renderer/index.js +0 -5
- package/dist/renderer/index.js.map +0 -1
- package/dist/renderer/svg.d.ts +0 -131
- package/dist/renderer/svg.d.ts.map +0 -1
- package/dist/renderer/svg.js +0 -1031
- package/dist/renderer/svg.js.map +0 -1
- package/src/renderer/index.ts +0 -6
- package/src/renderer/svg.ts +0 -1277
|
@@ -1,1221 +1,1251 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hierarchical Layout Engine
|
|
3
|
-
* Uses ELK.js for advanced graph layout with proper edge routing
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import ELK, {
|
|
7
|
-
|
|
8
|
-
type
|
|
9
|
-
type
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
info
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
return {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
//
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
//
|
|
697
|
-
if (elkNode.
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
if (
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
const
|
|
1185
|
-
const
|
|
1186
|
-
if (
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Hierarchical Layout Engine
|
|
3
|
+
* Uses ELK.js for advanced graph layout with proper edge routing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import ELK, {
|
|
7
|
+
type ElkExtendedEdge,
|
|
8
|
+
type ElkNode,
|
|
9
|
+
type LayoutOptions,
|
|
10
|
+
} from 'elkjs/lib/elk.bundled.js'
|
|
11
|
+
import {
|
|
12
|
+
CHAR_WIDTH_RATIO,
|
|
13
|
+
DEFAULT_ICON_SIZE,
|
|
14
|
+
ESTIMATED_CHAR_WIDTH,
|
|
15
|
+
ICON_LABEL_GAP,
|
|
16
|
+
LABEL_LINE_HEIGHT,
|
|
17
|
+
MAX_ICON_WIDTH_RATIO,
|
|
18
|
+
MIN_PORT_SPACING,
|
|
19
|
+
NODE_HORIZONTAL_PADDING,
|
|
20
|
+
NODE_VERTICAL_PADDING,
|
|
21
|
+
PORT_LABEL_FONT_SIZE,
|
|
22
|
+
PORT_LABEL_PADDING,
|
|
23
|
+
} from '../constants.js'
|
|
24
|
+
import { getDeviceIcon, getVendorIconEntry } from '../icons/index.js'
|
|
25
|
+
import {
|
|
26
|
+
type Bounds,
|
|
27
|
+
getNodeId,
|
|
28
|
+
type LayoutDirection,
|
|
29
|
+
type LayoutLink,
|
|
30
|
+
type LayoutNode,
|
|
31
|
+
type LayoutResult,
|
|
32
|
+
type LayoutSubgraph,
|
|
33
|
+
type LinkEndpoint,
|
|
34
|
+
type NetworkGraph,
|
|
35
|
+
type Node,
|
|
36
|
+
type Position,
|
|
37
|
+
type Subgraph,
|
|
38
|
+
} from '../models/index.js'
|
|
39
|
+
|
|
40
|
+
// ============================================
|
|
41
|
+
// Types
|
|
42
|
+
// ============================================
|
|
43
|
+
|
|
44
|
+
/** ELK edge section for edge routing */
|
|
45
|
+
interface ElkEdgeSection {
|
|
46
|
+
startPoint: { x: number; y: number }
|
|
47
|
+
endPoint: { x: number; y: number }
|
|
48
|
+
bendPoints?: Array<{ x: number; y: number }>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Extended ELK edge with sections */
|
|
52
|
+
interface ElkEdgeWithSections {
|
|
53
|
+
id: string
|
|
54
|
+
sections?: ElkEdgeSection[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Port info for a node */
|
|
58
|
+
interface NodePortInfo {
|
|
59
|
+
all: Set<string>
|
|
60
|
+
top: Set<string>
|
|
61
|
+
bottom: Set<string>
|
|
62
|
+
left: Set<string>
|
|
63
|
+
right: Set<string>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================
|
|
67
|
+
// Helper Functions
|
|
68
|
+
// ============================================
|
|
69
|
+
|
|
70
|
+
function toEndpoint(endpoint: string | LinkEndpoint): LinkEndpoint {
|
|
71
|
+
if (typeof endpoint === 'string') {
|
|
72
|
+
return { node: endpoint }
|
|
73
|
+
}
|
|
74
|
+
return endpoint
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Collect ports for each node from links */
|
|
78
|
+
function collectNodePorts(graph: NetworkGraph, haPairSet: Set<string>): Map<string, NodePortInfo> {
|
|
79
|
+
const nodePorts = new Map<string, NodePortInfo>()
|
|
80
|
+
|
|
81
|
+
const getOrCreate = (nodeId: string): NodePortInfo => {
|
|
82
|
+
if (!nodePorts.has(nodeId)) {
|
|
83
|
+
nodePorts.set(nodeId, {
|
|
84
|
+
all: new Set(),
|
|
85
|
+
top: new Set(),
|
|
86
|
+
bottom: new Set(),
|
|
87
|
+
left: new Set(),
|
|
88
|
+
right: new Set(),
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
return nodePorts.get(nodeId)!
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check if link is between HA pair nodes
|
|
95
|
+
const isHALink = (fromNode: string, toNode: string): boolean => {
|
|
96
|
+
const key = [fromNode, toNode].sort().join(':')
|
|
97
|
+
return haPairSet.has(key)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const link of graph.links) {
|
|
101
|
+
const from = toEndpoint(link.from)
|
|
102
|
+
const to = toEndpoint(link.to)
|
|
103
|
+
|
|
104
|
+
if (link.redundancy && isHALink(from.node, to.node)) {
|
|
105
|
+
// HA links: create side ports (left/right)
|
|
106
|
+
const fromPortName = from.port || 'ha'
|
|
107
|
+
const toPortName = to.port || 'ha'
|
|
108
|
+
|
|
109
|
+
const fromInfo = getOrCreate(from.node)
|
|
110
|
+
fromInfo.all.add(fromPortName)
|
|
111
|
+
fromInfo.right.add(fromPortName)
|
|
112
|
+
|
|
113
|
+
const toInfo = getOrCreate(to.node)
|
|
114
|
+
toInfo.all.add(toPortName)
|
|
115
|
+
toInfo.left.add(toPortName)
|
|
116
|
+
} else {
|
|
117
|
+
// Normal links: ports on top/bottom
|
|
118
|
+
if (from.port) {
|
|
119
|
+
const info = getOrCreate(from.node)
|
|
120
|
+
info.all.add(from.port)
|
|
121
|
+
info.bottom.add(from.port)
|
|
122
|
+
}
|
|
123
|
+
if (to.port) {
|
|
124
|
+
const info = getOrCreate(to.node)
|
|
125
|
+
info.all.add(to.port)
|
|
126
|
+
info.top.add(to.port)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return nodePorts
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Port size constants */
|
|
135
|
+
const PORT_WIDTH = 8
|
|
136
|
+
const PORT_HEIGHT = 8
|
|
137
|
+
|
|
138
|
+
// ============================================
|
|
139
|
+
// Layout Options
|
|
140
|
+
// ============================================
|
|
141
|
+
|
|
142
|
+
export interface HierarchicalLayoutOptions {
|
|
143
|
+
direction?: LayoutDirection
|
|
144
|
+
nodeWidth?: number
|
|
145
|
+
nodeHeight?: number
|
|
146
|
+
nodeSpacing?: number
|
|
147
|
+
rankSpacing?: number
|
|
148
|
+
subgraphPadding?: number
|
|
149
|
+
subgraphLabelHeight?: number
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const DEFAULT_OPTIONS: Required<HierarchicalLayoutOptions> = {
|
|
153
|
+
direction: 'TB',
|
|
154
|
+
nodeWidth: 180,
|
|
155
|
+
nodeHeight: 60,
|
|
156
|
+
nodeSpacing: 40,
|
|
157
|
+
rankSpacing: 60,
|
|
158
|
+
subgraphPadding: 24,
|
|
159
|
+
subgraphLabelHeight: 24,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================
|
|
163
|
+
// Layout Engine
|
|
164
|
+
// ============================================
|
|
165
|
+
|
|
166
|
+
export class HierarchicalLayout {
|
|
167
|
+
private options: Required<HierarchicalLayoutOptions>
|
|
168
|
+
private elk: InstanceType<typeof ELK>
|
|
169
|
+
|
|
170
|
+
constructor(options?: HierarchicalLayoutOptions) {
|
|
171
|
+
this.options = { ...DEFAULT_OPTIONS, ...options }
|
|
172
|
+
this.elk = new ELK()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Calculate dynamic spacing based on graph complexity
|
|
177
|
+
*/
|
|
178
|
+
private calculateDynamicSpacing(graph: NetworkGraph): {
|
|
179
|
+
nodeSpacing: number
|
|
180
|
+
rankSpacing: number
|
|
181
|
+
subgraphPadding: number
|
|
182
|
+
} {
|
|
183
|
+
const nodeCount = graph.nodes.length
|
|
184
|
+
const linkCount = graph.links.length
|
|
185
|
+
const subgraphCount = graph.subgraphs?.length || 0
|
|
186
|
+
|
|
187
|
+
let portCount = 0
|
|
188
|
+
let maxPortLabelLength = 0
|
|
189
|
+
for (const link of graph.links) {
|
|
190
|
+
if (typeof link.from !== 'string' && link.from.port) {
|
|
191
|
+
portCount++
|
|
192
|
+
maxPortLabelLength = Math.max(maxPortLabelLength, link.from.port.length)
|
|
193
|
+
}
|
|
194
|
+
if (typeof link.to !== 'string' && link.to.port) {
|
|
195
|
+
portCount++
|
|
196
|
+
maxPortLabelLength = Math.max(maxPortLabelLength, link.to.port.length)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const avgPortsPerNode = nodeCount > 0 ? portCount / nodeCount : 0
|
|
201
|
+
const complexity = nodeCount * 1.0 + linkCount * 0.8 + portCount * 0.3 + subgraphCount * 2
|
|
202
|
+
const portDensityFactor = Math.min(1.5, 1 + avgPortsPerNode * 0.1)
|
|
203
|
+
const rawSpacing = Math.max(20, Math.min(60, 80 - complexity * 1.2))
|
|
204
|
+
const baseSpacing = rawSpacing * portDensityFactor
|
|
205
|
+
|
|
206
|
+
const portLabelProtrusion = portCount > 0 ? 28 : 0
|
|
207
|
+
const portLabelWidth = maxPortLabelLength * PORT_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO
|
|
208
|
+
const minRankSpacing = Math.max(portLabelWidth, portLabelProtrusion) + 16
|
|
209
|
+
const minSubgraphPadding = portLabelProtrusion + 8
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
nodeSpacing: Math.round(baseSpacing),
|
|
213
|
+
rankSpacing: Math.round(Math.max(baseSpacing * 1.5, minRankSpacing)),
|
|
214
|
+
subgraphPadding: Math.round(Math.max(baseSpacing * 0.6, minSubgraphPadding)),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private getEffectiveOptions(graph: NetworkGraph): Required<HierarchicalLayoutOptions> {
|
|
219
|
+
const settings = graph.settings
|
|
220
|
+
const dynamicSpacing = this.calculateDynamicSpacing(graph)
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
...this.options,
|
|
224
|
+
direction: settings?.direction || this.options.direction,
|
|
225
|
+
nodeSpacing: settings?.nodeSpacing || dynamicSpacing.nodeSpacing,
|
|
226
|
+
rankSpacing: settings?.rankSpacing || dynamicSpacing.rankSpacing,
|
|
227
|
+
subgraphPadding: settings?.subgraphPadding || dynamicSpacing.subgraphPadding,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async layoutAsync(graph: NetworkGraph): Promise<LayoutResult> {
|
|
232
|
+
const startTime = performance.now()
|
|
233
|
+
const options = this.getEffectiveOptions(graph)
|
|
234
|
+
|
|
235
|
+
// Detect HA pairs first (needed for port assignment)
|
|
236
|
+
const haPairs = this.detectHAPairs(graph)
|
|
237
|
+
const haPairSet = new Set<string>()
|
|
238
|
+
for (const pair of haPairs) {
|
|
239
|
+
haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const nodePorts = collectNodePorts(graph, haPairSet)
|
|
243
|
+
|
|
244
|
+
// Build ELK graph
|
|
245
|
+
const elkGraph = this.buildElkGraph(graph, options, nodePorts, haPairs)
|
|
246
|
+
|
|
247
|
+
// Run ELK layout
|
|
248
|
+
const layoutedGraph = await this.elk.layout(elkGraph)
|
|
249
|
+
|
|
250
|
+
// Extract results using ELK's positions and edge routes
|
|
251
|
+
const result = this.extractLayoutResult(graph, layoutedGraph, nodePorts, options)
|
|
252
|
+
|
|
253
|
+
result.metadata = {
|
|
254
|
+
algorithm: 'elk-layered',
|
|
255
|
+
duration: performance.now() - startTime,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return result
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build ELK graph - uses container nodes for HA pairs
|
|
263
|
+
*/
|
|
264
|
+
private buildElkGraph(
|
|
265
|
+
graph: NetworkGraph,
|
|
266
|
+
options: Required<HierarchicalLayoutOptions>,
|
|
267
|
+
nodePorts: Map<string, NodePortInfo>,
|
|
268
|
+
haPairs: { nodeA: string; nodeB: string }[],
|
|
269
|
+
): ElkNode {
|
|
270
|
+
const elkDirection = this.toElkDirection(options.direction)
|
|
271
|
+
|
|
272
|
+
// Build subgraph map
|
|
273
|
+
const subgraphMap = new Map<string, Subgraph>()
|
|
274
|
+
if (graph.subgraphs) {
|
|
275
|
+
for (const sg of graph.subgraphs) {
|
|
276
|
+
subgraphMap.set(sg.id, sg)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Build HA container map: node ID -> container ID
|
|
281
|
+
const nodeToHAContainer = new Map<string, string>()
|
|
282
|
+
const haPairMap = new Map<string, { nodeA: string; nodeB: string }>()
|
|
283
|
+
for (const [idx, pair] of haPairs.entries()) {
|
|
284
|
+
const containerId = `__ha_container_${idx}`
|
|
285
|
+
nodeToHAContainer.set(pair.nodeA, containerId)
|
|
286
|
+
nodeToHAContainer.set(pair.nodeB, containerId)
|
|
287
|
+
haPairMap.set(containerId, pair)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Create ELK node
|
|
291
|
+
const createElkNode = (node: Node): ElkNode => {
|
|
292
|
+
const portInfo = nodePorts.get(node.id)
|
|
293
|
+
const portCount = portInfo?.all.size || 0
|
|
294
|
+
const height = this.calculateNodeHeight(node, portCount)
|
|
295
|
+
const width = this.calculateNodeWidth(node, portInfo)
|
|
296
|
+
|
|
297
|
+
const elkNode: ElkNode = {
|
|
298
|
+
id: node.id,
|
|
299
|
+
width,
|
|
300
|
+
height,
|
|
301
|
+
labels: [{ text: Array.isArray(node.label) ? node.label.join('\n') : node.label }],
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Add ports
|
|
305
|
+
if (portInfo && portInfo.all.size > 0) {
|
|
306
|
+
elkNode.ports = []
|
|
307
|
+
|
|
308
|
+
// Calculate port spacing based on label width
|
|
309
|
+
const portSpacing = this.calculatePortSpacing(portInfo.all)
|
|
310
|
+
|
|
311
|
+
// Helper to calculate port positions centered in the node
|
|
312
|
+
const calcPortPositions = (count: number, totalWidth: number): number[] => {
|
|
313
|
+
if (count === 0) return []
|
|
314
|
+
if (count === 1) return [totalWidth / 2]
|
|
315
|
+
const totalSpan = (count - 1) * portSpacing
|
|
316
|
+
const startX = (totalWidth - totalSpan) / 2
|
|
317
|
+
return Array.from({ length: count }, (_, i) => startX + i * portSpacing)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Top ports (incoming)
|
|
321
|
+
const topPorts = Array.from(portInfo.top)
|
|
322
|
+
const topPositions = calcPortPositions(topPorts.length, width)
|
|
323
|
+
for (const [i, portName] of topPorts.entries()) {
|
|
324
|
+
elkNode.ports!.push({
|
|
325
|
+
id: `${node.id}:${portName}`,
|
|
326
|
+
width: PORT_WIDTH,
|
|
327
|
+
height: PORT_HEIGHT,
|
|
328
|
+
x: topPositions[i] - PORT_WIDTH / 2,
|
|
329
|
+
y: 0,
|
|
330
|
+
labels: [{ text: portName }],
|
|
331
|
+
layoutOptions: { 'elk.port.side': 'NORTH' },
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Bottom ports (outgoing)
|
|
336
|
+
const bottomPorts = Array.from(portInfo.bottom)
|
|
337
|
+
const bottomPositions = calcPortPositions(bottomPorts.length, width)
|
|
338
|
+
for (const [i, portName] of bottomPorts.entries()) {
|
|
339
|
+
elkNode.ports!.push({
|
|
340
|
+
id: `${node.id}:${portName}`,
|
|
341
|
+
width: PORT_WIDTH,
|
|
342
|
+
height: PORT_HEIGHT,
|
|
343
|
+
x: bottomPositions[i] - PORT_WIDTH / 2,
|
|
344
|
+
y: height - PORT_HEIGHT,
|
|
345
|
+
labels: [{ text: portName }],
|
|
346
|
+
layoutOptions: { 'elk.port.side': 'SOUTH' },
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Left ports (HA)
|
|
351
|
+
const leftPorts = Array.from(portInfo.left)
|
|
352
|
+
const leftPositions = calcPortPositions(leftPorts.length, height)
|
|
353
|
+
for (const [i, portName] of leftPorts.entries()) {
|
|
354
|
+
elkNode.ports!.push({
|
|
355
|
+
id: `${node.id}:${portName}`,
|
|
356
|
+
width: PORT_WIDTH,
|
|
357
|
+
height: PORT_HEIGHT,
|
|
358
|
+
x: 0,
|
|
359
|
+
y: leftPositions[i] - PORT_HEIGHT / 2,
|
|
360
|
+
labels: [{ text: portName }],
|
|
361
|
+
layoutOptions: { 'elk.port.side': 'WEST' },
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Right ports (HA)
|
|
366
|
+
const rightPorts = Array.from(portInfo.right)
|
|
367
|
+
const rightPositions = calcPortPositions(rightPorts.length, height)
|
|
368
|
+
for (const [i, portName] of rightPorts.entries()) {
|
|
369
|
+
elkNode.ports!.push({
|
|
370
|
+
id: `${node.id}:${portName}`,
|
|
371
|
+
width: PORT_WIDTH,
|
|
372
|
+
height: PORT_HEIGHT,
|
|
373
|
+
x: width - PORT_WIDTH,
|
|
374
|
+
y: rightPositions[i] - PORT_HEIGHT / 2,
|
|
375
|
+
labels: [{ text: portName }],
|
|
376
|
+
layoutOptions: { 'elk.port.side': 'EAST' },
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
elkNode.layoutOptions = {
|
|
381
|
+
'elk.portConstraints': 'FIXED_POS',
|
|
382
|
+
'elk.spacing.portPort': String(MIN_PORT_SPACING),
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return elkNode
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Create HA container node
|
|
390
|
+
const createHAContainerNode = (
|
|
391
|
+
containerId: string,
|
|
392
|
+
pair: { nodeA: string; nodeB: string },
|
|
393
|
+
): ElkNode | null => {
|
|
394
|
+
const nodeA = graph.nodes.find((n) => n.id === pair.nodeA)
|
|
395
|
+
const nodeB = graph.nodes.find((n) => n.id === pair.nodeB)
|
|
396
|
+
if (!nodeA || !nodeB) return null
|
|
397
|
+
|
|
398
|
+
const childA = createElkNode(nodeA)
|
|
399
|
+
const childB = createElkNode(nodeB)
|
|
400
|
+
|
|
401
|
+
// Find HA link
|
|
402
|
+
const haLink = graph.links.find((link) => {
|
|
403
|
+
if (!link.redundancy) return false
|
|
404
|
+
const from = toEndpoint(link.from)
|
|
405
|
+
const to = toEndpoint(link.to)
|
|
406
|
+
const key = [from.node, to.node].sort().join(':')
|
|
407
|
+
const pairKey = [pair.nodeA, pair.nodeB].sort().join(':')
|
|
408
|
+
return key === pairKey
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
// Create internal HA edge
|
|
412
|
+
const haEdges: ElkExtendedEdge[] = []
|
|
413
|
+
if (haLink) {
|
|
414
|
+
const from = toEndpoint(haLink.from)
|
|
415
|
+
const to = toEndpoint(haLink.to)
|
|
416
|
+
const fromPortName = from.port || 'ha'
|
|
417
|
+
const toPortName = to.port || 'ha'
|
|
418
|
+
haEdges.push({
|
|
419
|
+
id: haLink.id || `ha-edge-${containerId}`,
|
|
420
|
+
sources: [`${from.node}:${fromPortName}`],
|
|
421
|
+
targets: [`${to.node}:${toPortName}`],
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
id: containerId,
|
|
427
|
+
children: [childA, childB],
|
|
428
|
+
edges: haEdges,
|
|
429
|
+
layoutOptions: {
|
|
430
|
+
'elk.algorithm': 'layered',
|
|
431
|
+
'elk.direction': 'RIGHT',
|
|
432
|
+
'elk.spacing.nodeNode': '40',
|
|
433
|
+
'elk.padding': '[top=0,left=0,bottom=0,right=0]',
|
|
434
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
435
|
+
'elk.edgeRouting': 'POLYLINE',
|
|
436
|
+
'org.eclipse.elk.json.edgeCoords': 'ROOT',
|
|
437
|
+
'org.eclipse.elk.json.shapeCoords': 'ROOT',
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Track added HA containers
|
|
443
|
+
const addedHAContainers = new Set<string>()
|
|
444
|
+
|
|
445
|
+
// Create ELK subgraph node recursively
|
|
446
|
+
const createSubgraphNode = (
|
|
447
|
+
subgraph: Subgraph,
|
|
448
|
+
edgesByContainer: Map<string, ElkExtendedEdge[]>,
|
|
449
|
+
): ElkNode => {
|
|
450
|
+
const childNodes: ElkNode[] = []
|
|
451
|
+
|
|
452
|
+
for (const childSg of subgraphMap.values()) {
|
|
453
|
+
if (childSg.parent === subgraph.id) {
|
|
454
|
+
childNodes.push(createSubgraphNode(childSg, edgesByContainer))
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const node of graph.nodes) {
|
|
459
|
+
if (node.parent === subgraph.id) {
|
|
460
|
+
const containerId = nodeToHAContainer.get(node.id)
|
|
461
|
+
if (containerId) {
|
|
462
|
+
if (!addedHAContainers.has(containerId)) {
|
|
463
|
+
addedHAContainers.add(containerId)
|
|
464
|
+
const pair = haPairMap.get(containerId)
|
|
465
|
+
if (pair) {
|
|
466
|
+
const containerNode = createHAContainerNode(containerId, pair)
|
|
467
|
+
if (containerNode) childNodes.push(containerNode)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
childNodes.push(createElkNode(node))
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const sgPadding = subgraph.style?.padding ?? options.subgraphPadding
|
|
477
|
+
const sgEdges = edgesByContainer.get(subgraph.id) || []
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
id: subgraph.id,
|
|
481
|
+
labels: [{ text: subgraph.label }],
|
|
482
|
+
children: childNodes,
|
|
483
|
+
edges: sgEdges,
|
|
484
|
+
layoutOptions: {
|
|
485
|
+
'elk.padding': `[top=${sgPadding + options.subgraphLabelHeight},left=${sgPadding},bottom=${sgPadding},right=${sgPadding}]`,
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Build root children
|
|
491
|
+
const buildRootChildren = (edgesByContainer: Map<string, ElkExtendedEdge[]>): ElkNode[] => {
|
|
492
|
+
const children: ElkNode[] = []
|
|
493
|
+
|
|
494
|
+
for (const sg of subgraphMap.values()) {
|
|
495
|
+
if (!sg.parent || !subgraphMap.has(sg.parent)) {
|
|
496
|
+
children.push(createSubgraphNode(sg, edgesByContainer))
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
for (const node of graph.nodes) {
|
|
501
|
+
if (!node.parent || !subgraphMap.has(node.parent)) {
|
|
502
|
+
const containerId = nodeToHAContainer.get(node.id)
|
|
503
|
+
if (containerId) {
|
|
504
|
+
if (!addedHAContainers.has(containerId)) {
|
|
505
|
+
addedHAContainers.add(containerId)
|
|
506
|
+
const pair = haPairMap.get(containerId)
|
|
507
|
+
if (pair) {
|
|
508
|
+
const containerNode = createHAContainerNode(containerId, pair)
|
|
509
|
+
if (containerNode) children.push(containerNode)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
children.push(createElkNode(node))
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return children
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Build node to parent map
|
|
522
|
+
const nodeParentMap = new Map<string, string | undefined>()
|
|
523
|
+
for (const node of graph.nodes) {
|
|
524
|
+
nodeParentMap.set(node.id, node.parent)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Find LCA (Lowest Common Ancestor) of two nodes
|
|
528
|
+
const findLCA = (nodeA: string, nodeB: string): string | undefined => {
|
|
529
|
+
const ancestorsA = new Set<string | undefined>()
|
|
530
|
+
let current: string | undefined = nodeA
|
|
531
|
+
while (current) {
|
|
532
|
+
ancestorsA.add(current)
|
|
533
|
+
current = nodeParentMap.get(current)
|
|
534
|
+
}
|
|
535
|
+
ancestorsA.add(undefined) // root
|
|
536
|
+
|
|
537
|
+
current = nodeB
|
|
538
|
+
while (current !== undefined) {
|
|
539
|
+
if (ancestorsA.has(current)) {
|
|
540
|
+
return current
|
|
541
|
+
}
|
|
542
|
+
current = nodeParentMap.get(current)
|
|
543
|
+
}
|
|
544
|
+
return undefined // root
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Build HA pair set for quick lookup
|
|
548
|
+
const haPairSet = new Set<string>()
|
|
549
|
+
for (const pair of haPairs) {
|
|
550
|
+
haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const isHALink = (fromNode: string, toNode: string): boolean => {
|
|
554
|
+
const key = [fromNode, toNode].sort().join(':')
|
|
555
|
+
return haPairSet.has(key)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Group edges by their LCA container (skip HA links - they're in containers)
|
|
559
|
+
const edgesByContainer = new Map<string, ElkExtendedEdge[]>()
|
|
560
|
+
edgesByContainer.set('root', [])
|
|
561
|
+
|
|
562
|
+
for (const sg of subgraphMap.values()) {
|
|
563
|
+
edgesByContainer.set(sg.id, [])
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
for (const [index, link] of graph.links.entries()) {
|
|
567
|
+
const from = toEndpoint(link.from)
|
|
568
|
+
const to = toEndpoint(link.to)
|
|
569
|
+
|
|
570
|
+
// Skip HA links (they're inside HA containers)
|
|
571
|
+
if (link.redundancy && isHALink(from.node, to.node)) {
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const sourceId = from.port ? `${from.node}:${from.port}` : from.node
|
|
576
|
+
const targetId = to.port ? `${to.node}:${to.port}` : to.node
|
|
577
|
+
|
|
578
|
+
const edge: ElkExtendedEdge = {
|
|
579
|
+
id: link.id || `edge-${index}`,
|
|
580
|
+
sources: [sourceId],
|
|
581
|
+
targets: [targetId],
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Add label
|
|
585
|
+
const labelParts: string[] = []
|
|
586
|
+
if (link.label) {
|
|
587
|
+
labelParts.push(Array.isArray(link.label) ? link.label.join(' / ') : link.label)
|
|
588
|
+
}
|
|
589
|
+
if (from.ip) labelParts.push(from.ip)
|
|
590
|
+
if (to.ip) labelParts.push(to.ip)
|
|
591
|
+
|
|
592
|
+
if (labelParts.length > 0) {
|
|
593
|
+
edge.labels = [
|
|
594
|
+
{
|
|
595
|
+
text: labelParts.join('\n'),
|
|
596
|
+
layoutOptions: { 'elk.edgeLabels.placement': 'CENTER' },
|
|
597
|
+
},
|
|
598
|
+
]
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Find LCA and place edge in appropriate container
|
|
602
|
+
const lca = findLCA(from.node, to.node)
|
|
603
|
+
let container = lca
|
|
604
|
+
if (container === from.node || container === to.node) {
|
|
605
|
+
container = nodeParentMap.get(container)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const containerId = container && subgraphMap.has(container) ? container : 'root'
|
|
609
|
+
edgesByContainer.get(containerId)!.push(edge)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Dynamic edge spacing
|
|
613
|
+
const edgeNodeSpacing = Math.max(10, Math.round(options.nodeSpacing * 0.4))
|
|
614
|
+
const edgeEdgeSpacing = Math.max(8, Math.round(options.nodeSpacing * 0.25))
|
|
615
|
+
|
|
616
|
+
// Root layout options
|
|
617
|
+
const rootLayoutOptions: LayoutOptions = {
|
|
618
|
+
'elk.algorithm': 'layered',
|
|
619
|
+
'elk.direction': elkDirection,
|
|
620
|
+
'elk.spacing.nodeNode': String(options.nodeSpacing),
|
|
621
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(options.rankSpacing),
|
|
622
|
+
'elk.spacing.edgeNode': String(edgeNodeSpacing),
|
|
623
|
+
'elk.spacing.edgeEdge': String(edgeEdgeSpacing),
|
|
624
|
+
'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
|
|
625
|
+
'elk.layered.compaction.connectedComponents': 'true',
|
|
626
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
627
|
+
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
628
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
629
|
+
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
|
630
|
+
// Use ROOT coordinate system
|
|
631
|
+
'org.eclipse.elk.json.edgeCoords': 'ROOT',
|
|
632
|
+
'org.eclipse.elk.json.shapeCoords': 'ROOT',
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Build the graph with edges in correct containers
|
|
636
|
+
const rootChildren = buildRootChildren(edgesByContainer)
|
|
637
|
+
const rootEdges = edgesByContainer.get('root') || []
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
id: 'root',
|
|
641
|
+
children: rootChildren,
|
|
642
|
+
edges: rootEdges,
|
|
643
|
+
layoutOptions: rootLayoutOptions,
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Extract layout result from ELK output - uses ELK's edge routing directly
|
|
649
|
+
*/
|
|
650
|
+
private extractLayoutResult(
|
|
651
|
+
graph: NetworkGraph,
|
|
652
|
+
elkGraph: ElkNode,
|
|
653
|
+
nodePorts: Map<string, NodePortInfo>,
|
|
654
|
+
_options: Required<HierarchicalLayoutOptions>,
|
|
655
|
+
): LayoutResult {
|
|
656
|
+
const layoutNodes = new Map<string, LayoutNode>()
|
|
657
|
+
const layoutSubgraphs = new Map<string, LayoutSubgraph>()
|
|
658
|
+
const layoutLinks = new Map<string, LayoutLink>()
|
|
659
|
+
|
|
660
|
+
// Build maps
|
|
661
|
+
const subgraphMap = new Map<string, Subgraph>()
|
|
662
|
+
if (graph.subgraphs) {
|
|
663
|
+
for (const sg of graph.subgraphs) {
|
|
664
|
+
subgraphMap.set(sg.id, sg)
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const nodeMap = new Map<string, Node>()
|
|
669
|
+
for (const node of graph.nodes) {
|
|
670
|
+
nodeMap.set(node.id, node)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Process ELK nodes recursively
|
|
674
|
+
// With shapeCoords=ROOT, all coordinates are absolute (no offset needed)
|
|
675
|
+
const processElkNode = (elkNode: ElkNode) => {
|
|
676
|
+
const x = elkNode.x || 0
|
|
677
|
+
const y = elkNode.y || 0
|
|
678
|
+
const width = elkNode.width || 0
|
|
679
|
+
const height = elkNode.height || 0
|
|
680
|
+
|
|
681
|
+
if (subgraphMap.has(elkNode.id)) {
|
|
682
|
+
// Subgraph
|
|
683
|
+
const sg = subgraphMap.get(elkNode.id)!
|
|
684
|
+
layoutSubgraphs.set(elkNode.id, {
|
|
685
|
+
id: elkNode.id,
|
|
686
|
+
bounds: { x, y, width, height },
|
|
687
|
+
subgraph: sg,
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
if (elkNode.children) {
|
|
691
|
+
for (const child of elkNode.children) {
|
|
692
|
+
processElkNode(child)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
} else if (elkNode.id.startsWith('__ha_container_')) {
|
|
696
|
+
// HA container - process children
|
|
697
|
+
if (elkNode.children) {
|
|
698
|
+
for (const child of elkNode.children) {
|
|
699
|
+
processElkNode(child)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
} else if (nodeMap.has(elkNode.id)) {
|
|
703
|
+
// Regular node
|
|
704
|
+
const node = nodeMap.get(elkNode.id)!
|
|
705
|
+
const portInfo = nodePorts.get(node.id)
|
|
706
|
+
const nodeHeight = this.calculateNodeHeight(node, portInfo?.all.size || 0)
|
|
707
|
+
|
|
708
|
+
const layoutNode: LayoutNode = {
|
|
709
|
+
id: elkNode.id,
|
|
710
|
+
position: { x: x + width / 2, y: y + nodeHeight / 2 },
|
|
711
|
+
size: { width, height: nodeHeight },
|
|
712
|
+
node,
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Extract port positions from ELK
|
|
716
|
+
if (elkNode.ports && elkNode.ports.length > 0) {
|
|
717
|
+
layoutNode.ports = new Map()
|
|
718
|
+
const nodeCenterX = x + width / 2
|
|
719
|
+
const nodeCenterY = y + nodeHeight / 2
|
|
720
|
+
|
|
721
|
+
for (const elkPort of elkNode.ports) {
|
|
722
|
+
const portX = elkPort.x ?? 0
|
|
723
|
+
const portY = elkPort.y ?? 0
|
|
724
|
+
const portW = elkPort.width ?? PORT_WIDTH
|
|
725
|
+
const portH = elkPort.height ?? PORT_HEIGHT
|
|
726
|
+
|
|
727
|
+
const portCenterX = portX + portW / 2
|
|
728
|
+
const portCenterY = portY + portH / 2
|
|
729
|
+
|
|
730
|
+
const relX = portCenterX - nodeCenterX
|
|
731
|
+
const relY = portCenterY - nodeCenterY
|
|
732
|
+
|
|
733
|
+
// Determine side based on which edge the port is closest to
|
|
734
|
+
// Use node boundaries, not relative distance from center
|
|
735
|
+
const distToTop = Math.abs(portCenterY - y)
|
|
736
|
+
const distToBottom = Math.abs(portCenterY - (y + nodeHeight))
|
|
737
|
+
const distToLeft = Math.abs(portCenterX - x)
|
|
738
|
+
const distToRight = Math.abs(portCenterX - (x + width))
|
|
739
|
+
|
|
740
|
+
const minDist = Math.min(distToTop, distToBottom, distToLeft, distToRight)
|
|
741
|
+
let side: 'top' | 'bottom' | 'left' | 'right' = 'bottom'
|
|
742
|
+
if (minDist === distToTop) {
|
|
743
|
+
side = 'top'
|
|
744
|
+
} else if (minDist === distToBottom) {
|
|
745
|
+
side = 'bottom'
|
|
746
|
+
} else if (minDist === distToLeft) {
|
|
747
|
+
side = 'left'
|
|
748
|
+
} else {
|
|
749
|
+
side = 'right'
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const portName = elkPort.id.includes(':')
|
|
753
|
+
? elkPort.id.split(':').slice(1).join(':')
|
|
754
|
+
: elkPort.id
|
|
755
|
+
|
|
756
|
+
layoutNode.ports.set(elkPort.id, {
|
|
757
|
+
id: elkPort.id,
|
|
758
|
+
label: portName,
|
|
759
|
+
position: { x: relX, y: relY },
|
|
760
|
+
size: { width: portW, height: portH },
|
|
761
|
+
side,
|
|
762
|
+
})
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
layoutNodes.set(elkNode.id, layoutNode)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Process root children (coordinates are absolute with shapeCoords=ROOT)
|
|
771
|
+
if (elkGraph.children) {
|
|
772
|
+
for (const child of elkGraph.children) {
|
|
773
|
+
processElkNode(child)
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Build link map for ID matching
|
|
778
|
+
const linkById = new Map<string, { link: (typeof graph.links)[0]; index: number }>()
|
|
779
|
+
for (const [index, link] of graph.links.entries()) {
|
|
780
|
+
linkById.set(link.id || `edge-${index}`, { link, index })
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Track processed edges to prevent duplicates
|
|
784
|
+
const processedEdgeIds = new Set<string>()
|
|
785
|
+
|
|
786
|
+
// Check if container is an HA container
|
|
787
|
+
const isHAContainer = (id: string) => id.startsWith('__ha_container_')
|
|
788
|
+
|
|
789
|
+
// Process edges from a container
|
|
790
|
+
// With edgeCoords=ROOT, all edge coordinates are absolute (no offset needed)
|
|
791
|
+
const processEdgesInContainer = (container: ElkNode) => {
|
|
792
|
+
const elkEdges = container.edges as ElkEdgeWithSections[] | undefined
|
|
793
|
+
if (elkEdges) {
|
|
794
|
+
for (const elkEdge of elkEdges) {
|
|
795
|
+
// Skip if already processed
|
|
796
|
+
if (processedEdgeIds.has(elkEdge.id)) continue
|
|
797
|
+
processedEdgeIds.add(elkEdge.id)
|
|
798
|
+
|
|
799
|
+
const entry = linkById.get(elkEdge.id)
|
|
800
|
+
if (!entry) continue
|
|
801
|
+
|
|
802
|
+
const { link, index } = entry
|
|
803
|
+
const id = link.id || `link-${index}`
|
|
804
|
+
const fromEndpoint = toEndpoint(link.from)
|
|
805
|
+
const toEndpoint_ = toEndpoint(link.to)
|
|
806
|
+
|
|
807
|
+
const fromNode = layoutNodes.get(fromEndpoint.node)
|
|
808
|
+
const toNode = layoutNodes.get(toEndpoint_.node)
|
|
809
|
+
if (!fromNode || !toNode) continue
|
|
810
|
+
|
|
811
|
+
let points: Position[] = []
|
|
812
|
+
|
|
813
|
+
// HA edges inside HA containers: use ELK's edge routing directly
|
|
814
|
+
if (isHAContainer(container.id) && elkEdge.sections && elkEdge.sections.length > 0) {
|
|
815
|
+
const section = elkEdge.sections[0]
|
|
816
|
+
points.push({ x: section.startPoint.x, y: section.startPoint.y })
|
|
817
|
+
if (section.bendPoints) {
|
|
818
|
+
for (const bp of section.bendPoints) {
|
|
819
|
+
points.push({ x: bp.x, y: bp.y })
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
points.push({ x: section.endPoint.x, y: section.endPoint.y })
|
|
823
|
+
} else if (!isHAContainer(container.id)) {
|
|
824
|
+
// Normal vertical edges
|
|
825
|
+
const fromBottomY = fromNode.position.y + fromNode.size.height / 2
|
|
826
|
+
const toTopY = toNode.position.y - toNode.size.height / 2
|
|
827
|
+
|
|
828
|
+
if (elkEdge.sections && elkEdge.sections.length > 0) {
|
|
829
|
+
const section = elkEdge.sections[0]
|
|
830
|
+
|
|
831
|
+
points.push({
|
|
832
|
+
x: section.startPoint.x,
|
|
833
|
+
y: fromBottomY,
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
if (section.bendPoints) {
|
|
837
|
+
for (const bp of section.bendPoints) {
|
|
838
|
+
points.push({ x: bp.x, y: bp.y })
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
points.push({
|
|
843
|
+
x: section.endPoint.x,
|
|
844
|
+
y: toTopY,
|
|
845
|
+
})
|
|
846
|
+
} else {
|
|
847
|
+
points = this.generateOrthogonalPath(
|
|
848
|
+
{ x: fromNode.position.x, y: fromBottomY },
|
|
849
|
+
{ x: toNode.position.x, y: toTopY },
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
} else {
|
|
853
|
+
// HA edge fallback: simple horizontal line
|
|
854
|
+
const leftNode = fromNode.position.x < toNode.position.x ? fromNode : toNode
|
|
855
|
+
const rightNode = fromNode.position.x < toNode.position.x ? toNode : fromNode
|
|
856
|
+
const y = (leftNode.position.y + rightNode.position.y) / 2
|
|
857
|
+
points = [
|
|
858
|
+
{ x: leftNode.position.x + leftNode.size.width / 2, y },
|
|
859
|
+
{ x: rightNode.position.x - rightNode.size.width / 2, y },
|
|
860
|
+
]
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
layoutLinks.set(id, {
|
|
864
|
+
id,
|
|
865
|
+
from: fromEndpoint.node,
|
|
866
|
+
to: toEndpoint_.node,
|
|
867
|
+
fromEndpoint,
|
|
868
|
+
toEndpoint: toEndpoint_,
|
|
869
|
+
points,
|
|
870
|
+
link,
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Recursively process child containers (subgraphs and HA containers)
|
|
876
|
+
if (container.children) {
|
|
877
|
+
for (const child of container.children) {
|
|
878
|
+
if (subgraphMap.has(child.id) || child.id.startsWith('__ha_container_')) {
|
|
879
|
+
processEdgesInContainer(child)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Process all edges (coordinates are absolute with edgeCoords=ROOT)
|
|
886
|
+
processEdgesInContainer(elkGraph)
|
|
887
|
+
|
|
888
|
+
// Fallback for any missing links
|
|
889
|
+
for (const [index, link] of graph.links.entries()) {
|
|
890
|
+
const id = link.id || `link-${index}`
|
|
891
|
+
if (layoutLinks.has(id)) continue
|
|
892
|
+
|
|
893
|
+
const fromEndpoint = toEndpoint(link.from)
|
|
894
|
+
const toEndpoint_ = toEndpoint(link.to)
|
|
895
|
+
const fromNode = layoutNodes.get(fromEndpoint.node)
|
|
896
|
+
const toNode = layoutNodes.get(toEndpoint_.node)
|
|
897
|
+
if (!fromNode || !toNode) continue
|
|
898
|
+
|
|
899
|
+
const startY = fromNode.position.y + fromNode.size.height / 2
|
|
900
|
+
const endY = toNode.position.y - toNode.size.height / 2
|
|
901
|
+
const points = this.generateOrthogonalPath(
|
|
902
|
+
{ x: fromNode.position.x, y: startY },
|
|
903
|
+
{ x: toNode.position.x, y: endY },
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
layoutLinks.set(id, {
|
|
907
|
+
id,
|
|
908
|
+
from: fromEndpoint.node,
|
|
909
|
+
to: toEndpoint_.node,
|
|
910
|
+
fromEndpoint,
|
|
911
|
+
toEndpoint: toEndpoint_,
|
|
912
|
+
points,
|
|
913
|
+
link,
|
|
914
|
+
})
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Calculate bounds
|
|
918
|
+
const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs)
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
nodes: layoutNodes,
|
|
922
|
+
links: layoutLinks,
|
|
923
|
+
subgraphs: layoutSubgraphs,
|
|
924
|
+
bounds,
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Synchronous wrapper
|
|
929
|
+
layout(graph: NetworkGraph): LayoutResult {
|
|
930
|
+
const options = this.getEffectiveOptions(graph)
|
|
931
|
+
const result = this.calculateFallbackLayout(graph, options.direction)
|
|
932
|
+
|
|
933
|
+
// Start async layout
|
|
934
|
+
this.layoutAsync(graph)
|
|
935
|
+
.then((asyncResult) => {
|
|
936
|
+
Object.assign(result, asyncResult)
|
|
937
|
+
})
|
|
938
|
+
.catch(() => {})
|
|
939
|
+
|
|
940
|
+
return result
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private toElkDirection(direction: LayoutDirection): string {
|
|
944
|
+
switch (direction) {
|
|
945
|
+
case 'TB':
|
|
946
|
+
return 'DOWN'
|
|
947
|
+
case 'BT':
|
|
948
|
+
return 'UP'
|
|
949
|
+
case 'LR':
|
|
950
|
+
return 'RIGHT'
|
|
951
|
+
case 'RL':
|
|
952
|
+
return 'LEFT'
|
|
953
|
+
default:
|
|
954
|
+
return 'DOWN'
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private calculateNodeHeight(node: Node, portCount = 0): number {
|
|
959
|
+
const lines = Array.isArray(node.label) ? node.label.length : 1
|
|
960
|
+
const labelHeight = lines * LABEL_LINE_HEIGHT
|
|
961
|
+
|
|
962
|
+
const labels = Array.isArray(node.label) ? node.label : [node.label]
|
|
963
|
+
const maxLabelLength = Math.max(...labels.map((l) => l.length))
|
|
964
|
+
const labelWidth = maxLabelLength * ESTIMATED_CHAR_WIDTH
|
|
965
|
+
const portWidth = portCount > 0 ? (portCount + 1) * MIN_PORT_SPACING : 0
|
|
966
|
+
const baseContentWidth = Math.max(labelWidth, portWidth)
|
|
967
|
+
const baseNodeWidth = Math.max(
|
|
968
|
+
this.options.nodeWidth,
|
|
969
|
+
baseContentWidth + NODE_HORIZONTAL_PADDING,
|
|
970
|
+
)
|
|
971
|
+
const maxIconWidth = Math.round(baseNodeWidth * MAX_ICON_WIDTH_RATIO)
|
|
972
|
+
|
|
973
|
+
let iconHeight = 0
|
|
974
|
+
const iconKey = node.service || node.model
|
|
975
|
+
if (node.vendor && iconKey) {
|
|
976
|
+
const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
|
|
977
|
+
if (iconEntry) {
|
|
978
|
+
const vendorIcon = iconEntry.default
|
|
979
|
+
const viewBox = iconEntry.viewBox || '0 0 48 48'
|
|
980
|
+
|
|
981
|
+
if (vendorIcon.startsWith('<svg')) {
|
|
982
|
+
const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
|
|
983
|
+
if (viewBoxMatch) {
|
|
984
|
+
const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
|
|
985
|
+
const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
|
|
986
|
+
const aspectRatio = vbWidth / vbHeight
|
|
987
|
+
const iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
|
|
988
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
989
|
+
if (iconWidth > maxIconWidth) {
|
|
990
|
+
iconHeight = Math.round(maxIconWidth / aspectRatio)
|
|
991
|
+
}
|
|
992
|
+
} else {
|
|
993
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
994
|
+
}
|
|
995
|
+
} else {
|
|
996
|
+
const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
|
|
997
|
+
if (vbMatch) {
|
|
998
|
+
const vbWidth = Number.parseInt(vbMatch[3], 10)
|
|
999
|
+
const vbHeight = Number.parseInt(vbMatch[4], 10)
|
|
1000
|
+
const aspectRatio = vbWidth / vbHeight
|
|
1001
|
+
const iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
|
|
1002
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
1003
|
+
if (iconWidth > maxIconWidth) {
|
|
1004
|
+
iconHeight = Math.round(maxIconWidth / aspectRatio)
|
|
1005
|
+
}
|
|
1006
|
+
} else {
|
|
1007
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (iconHeight === 0 && node.type && getDeviceIcon(node.type)) {
|
|
1014
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
|
|
1018
|
+
const contentHeight = iconHeight + gap + labelHeight
|
|
1019
|
+
return Math.max(this.options.nodeHeight, contentHeight + NODE_VERTICAL_PADDING)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
private calculatePortSpacing(portNames: Set<string> | undefined): number {
|
|
1023
|
+
if (!portNames || portNames.size === 0) return MIN_PORT_SPACING
|
|
1024
|
+
|
|
1025
|
+
let maxLabelLength = 0
|
|
1026
|
+
for (const name of portNames) {
|
|
1027
|
+
maxLabelLength = Math.max(maxLabelLength, name.length)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const charWidth = PORT_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO
|
|
1031
|
+
const maxLabelWidth = maxLabelLength * charWidth
|
|
1032
|
+
const spacingFromLabel = maxLabelWidth + PORT_LABEL_PADDING
|
|
1033
|
+
return Math.max(MIN_PORT_SPACING, spacingFromLabel)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private calculateNodeWidth(node: Node, portInfo: NodePortInfo | undefined): number {
|
|
1037
|
+
const labels = Array.isArray(node.label) ? node.label : [node.label]
|
|
1038
|
+
const maxLabelLength = Math.max(...labels.map((l) => l.length))
|
|
1039
|
+
const labelWidth = maxLabelLength * ESTIMATED_CHAR_WIDTH
|
|
1040
|
+
|
|
1041
|
+
const topCount = portInfo?.top.size || 0
|
|
1042
|
+
const bottomCount = portInfo?.bottom.size || 0
|
|
1043
|
+
const maxPortsPerSide = Math.max(topCount, bottomCount)
|
|
1044
|
+
|
|
1045
|
+
const portSpacing = this.calculatePortSpacing(portInfo?.all)
|
|
1046
|
+
const edgeMargin = Math.round(MIN_PORT_SPACING / 2)
|
|
1047
|
+
const portWidth = maxPortsPerSide > 0 ? (maxPortsPerSide - 1) * portSpacing + edgeMargin * 2 : 0
|
|
1048
|
+
|
|
1049
|
+
const paddedContentWidth = Math.max(labelWidth, 0) + NODE_HORIZONTAL_PADDING
|
|
1050
|
+
const baseNodeWidth = Math.max(paddedContentWidth, portWidth)
|
|
1051
|
+
|
|
1052
|
+
const maxIconWidth = Math.round(baseNodeWidth * MAX_ICON_WIDTH_RATIO)
|
|
1053
|
+
let iconWidth = DEFAULT_ICON_SIZE
|
|
1054
|
+
const iconKey = node.service || node.model
|
|
1055
|
+
if (node.vendor && iconKey) {
|
|
1056
|
+
const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
|
|
1057
|
+
if (iconEntry) {
|
|
1058
|
+
const vendorIcon = iconEntry.default
|
|
1059
|
+
const viewBox = iconEntry.viewBox || '0 0 48 48'
|
|
1060
|
+
|
|
1061
|
+
if (vendorIcon.startsWith('<svg')) {
|
|
1062
|
+
const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
|
|
1063
|
+
if (viewBoxMatch) {
|
|
1064
|
+
const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
|
|
1065
|
+
const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
|
|
1066
|
+
const aspectRatio = vbWidth / vbHeight
|
|
1067
|
+
iconWidth = Math.min(Math.round(DEFAULT_ICON_SIZE * aspectRatio), maxIconWidth)
|
|
1068
|
+
}
|
|
1069
|
+
} else {
|
|
1070
|
+
const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
|
|
1071
|
+
if (vbMatch) {
|
|
1072
|
+
const vbWidth = Number.parseInt(vbMatch[3], 10)
|
|
1073
|
+
const vbHeight = Number.parseInt(vbMatch[4], 10)
|
|
1074
|
+
const aspectRatio = vbWidth / vbHeight
|
|
1075
|
+
iconWidth = Math.min(Math.round(DEFAULT_ICON_SIZE * aspectRatio), maxIconWidth)
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const paddedIconLabelWidth = Math.max(iconWidth, labelWidth) + NODE_HORIZONTAL_PADDING
|
|
1082
|
+
return Math.max(paddedIconLabelWidth, portWidth)
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private calculateTotalBounds(
|
|
1086
|
+
nodes: Map<string, LayoutNode>,
|
|
1087
|
+
subgraphs: Map<string, LayoutSubgraph>,
|
|
1088
|
+
): Bounds {
|
|
1089
|
+
let minX = Number.POSITIVE_INFINITY
|
|
1090
|
+
let minY = Number.POSITIVE_INFINITY
|
|
1091
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
1092
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
1093
|
+
|
|
1094
|
+
for (const node of nodes.values()) {
|
|
1095
|
+
let left = node.position.x - node.size.width / 2
|
|
1096
|
+
let right = node.position.x + node.size.width / 2
|
|
1097
|
+
let top = node.position.y - node.size.height / 2
|
|
1098
|
+
let bottom = node.position.y + node.size.height / 2
|
|
1099
|
+
|
|
1100
|
+
if (node.ports) {
|
|
1101
|
+
for (const port of node.ports.values()) {
|
|
1102
|
+
const portX = node.position.x + port.position.x
|
|
1103
|
+
const portY = node.position.y + port.position.y
|
|
1104
|
+
left = Math.min(left, portX - port.size.width / 2)
|
|
1105
|
+
right = Math.max(right, portX + port.size.width / 2)
|
|
1106
|
+
top = Math.min(top, portY - port.size.height / 2)
|
|
1107
|
+
bottom = Math.max(bottom, portY + port.size.height / 2)
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
minX = Math.min(minX, left)
|
|
1112
|
+
minY = Math.min(minY, top)
|
|
1113
|
+
maxX = Math.max(maxX, right)
|
|
1114
|
+
maxY = Math.max(maxY, bottom)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
for (const sg of subgraphs.values()) {
|
|
1118
|
+
minX = Math.min(minX, sg.bounds.x)
|
|
1119
|
+
minY = Math.min(minY, sg.bounds.y)
|
|
1120
|
+
maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width)
|
|
1121
|
+
maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const padding = 50
|
|
1125
|
+
|
|
1126
|
+
if (minX === Number.POSITIVE_INFINITY) {
|
|
1127
|
+
return { x: 0, y: 0, width: 400, height: 300 }
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return {
|
|
1131
|
+
x: minX - padding,
|
|
1132
|
+
y: minY - padding,
|
|
1133
|
+
width: maxX - minX + padding * 2,
|
|
1134
|
+
height: maxY - minY + padding * 2,
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private calculateFallbackLayout(graph: NetworkGraph, _direction: LayoutDirection): LayoutResult {
|
|
1139
|
+
const layoutNodes = new Map<string, LayoutNode>()
|
|
1140
|
+
const layoutSubgraphs = new Map<string, LayoutSubgraph>()
|
|
1141
|
+
const layoutLinks = new Map<string, LayoutLink>()
|
|
1142
|
+
|
|
1143
|
+
// Detect HA pairs for port assignment
|
|
1144
|
+
const haPairs = this.detectHAPairs(graph)
|
|
1145
|
+
const haPairSet = new Set<string>()
|
|
1146
|
+
for (const pair of haPairs) {
|
|
1147
|
+
haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
|
|
1148
|
+
}
|
|
1149
|
+
const nodePorts = collectNodePorts(graph, haPairSet)
|
|
1150
|
+
|
|
1151
|
+
let x = 100
|
|
1152
|
+
let y = 100
|
|
1153
|
+
let col = 0
|
|
1154
|
+
const maxCols = 4
|
|
1155
|
+
const rowHeight = this.options.nodeHeight + this.options.rankSpacing
|
|
1156
|
+
|
|
1157
|
+
for (const node of graph.nodes) {
|
|
1158
|
+
const portInfo = nodePorts.get(node.id)
|
|
1159
|
+
const portCount = portInfo?.all.size || 0
|
|
1160
|
+
const height = this.calculateNodeHeight(node, portCount)
|
|
1161
|
+
const width = this.calculateNodeWidth(node, portInfo)
|
|
1162
|
+
const colWidth = width + this.options.nodeSpacing
|
|
1163
|
+
|
|
1164
|
+
layoutNodes.set(node.id, {
|
|
1165
|
+
id: node.id,
|
|
1166
|
+
position: { x: x + width / 2, y: y + height / 2 },
|
|
1167
|
+
size: { width, height },
|
|
1168
|
+
node,
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
col++
|
|
1172
|
+
if (col >= maxCols) {
|
|
1173
|
+
col = 0
|
|
1174
|
+
x = 100
|
|
1175
|
+
y += rowHeight
|
|
1176
|
+
} else {
|
|
1177
|
+
x += colWidth
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
for (const [index, link] of graph.links.entries()) {
|
|
1182
|
+
const fromId = getNodeId(link.from)
|
|
1183
|
+
const toId = getNodeId(link.to)
|
|
1184
|
+
const from = layoutNodes.get(fromId)
|
|
1185
|
+
const to = layoutNodes.get(toId)
|
|
1186
|
+
if (from && to) {
|
|
1187
|
+
layoutLinks.set(link.id || `link-${index}`, {
|
|
1188
|
+
id: link.id || `link-${index}`,
|
|
1189
|
+
from: fromId,
|
|
1190
|
+
to: toId,
|
|
1191
|
+
fromEndpoint: toEndpoint(link.from),
|
|
1192
|
+
toEndpoint: toEndpoint(link.to),
|
|
1193
|
+
points: [from.position, to.position],
|
|
1194
|
+
link,
|
|
1195
|
+
})
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs)
|
|
1200
|
+
|
|
1201
|
+
return {
|
|
1202
|
+
nodes: layoutNodes,
|
|
1203
|
+
links: layoutLinks,
|
|
1204
|
+
subgraphs: layoutSubgraphs,
|
|
1205
|
+
bounds,
|
|
1206
|
+
metadata: { algorithm: 'fallback-grid', duration: 0 },
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** Detect HA pairs from redundancy links */
|
|
1211
|
+
private detectHAPairs(graph: NetworkGraph): { nodeA: string; nodeB: string }[] {
|
|
1212
|
+
const pairs: { nodeA: string; nodeB: string }[] = []
|
|
1213
|
+
const processed = new Set<string>()
|
|
1214
|
+
|
|
1215
|
+
for (const link of graph.links) {
|
|
1216
|
+
if (!link.redundancy) continue
|
|
1217
|
+
|
|
1218
|
+
const fromId = getNodeId(link.from)
|
|
1219
|
+
const toId = getNodeId(link.to)
|
|
1220
|
+
const key = [fromId, toId].sort().join(':')
|
|
1221
|
+
if (processed.has(key)) continue
|
|
1222
|
+
|
|
1223
|
+
pairs.push({ nodeA: fromId, nodeB: toId })
|
|
1224
|
+
processed.add(key)
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
return pairs
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** Generate orthogonal path between two points */
|
|
1231
|
+
private generateOrthogonalPath(start: Position, end: Position): Position[] {
|
|
1232
|
+
const dx = end.x - start.x
|
|
1233
|
+
const dy = end.y - start.y
|
|
1234
|
+
|
|
1235
|
+
// If points are nearly aligned, use direct line
|
|
1236
|
+
if (Math.abs(dx) < 5) {
|
|
1237
|
+
return [start, end]
|
|
1238
|
+
}
|
|
1239
|
+
if (Math.abs(dy) < 5) {
|
|
1240
|
+
return [start, end]
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Use midpoint for orthogonal routing
|
|
1244
|
+
const midY = start.y + dy / 2
|
|
1245
|
+
|
|
1246
|
+
return [start, { x: start.x, y: midY }, { x: end.x, y: midY }, end]
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Default instance
|
|
1251
|
+
export const hierarchicalLayout = new HierarchicalLayout()
|