@shumoku/core 0.2.3 → 0.2.4

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