@shumoku/core 0.2.4 → 0.2.13

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,1358 +1,1366 @@
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()
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 =
863
+ fromNode ||
864
+ (fromSubgraph
865
+ ? {
866
+ position: {
867
+ x: fromSubgraph.bounds.x + fromSubgraph.bounds.width / 2,
868
+ y: fromSubgraph.bounds.y + fromSubgraph.bounds.height / 2,
869
+ },
870
+ size: { width: fromSubgraph.bounds.width, height: fromSubgraph.bounds.height },
871
+ }
872
+ : null)
873
+ const toLayout =
874
+ toNode ||
875
+ (toSubgraph
876
+ ? {
877
+ position: {
878
+ x: toSubgraph.bounds.x + toSubgraph.bounds.width / 2,
879
+ y: toSubgraph.bounds.y + toSubgraph.bounds.height / 2,
880
+ },
881
+ size: { width: toSubgraph.bounds.width, height: toSubgraph.bounds.height },
882
+ }
883
+ : null)
884
+
885
+ if (!fromLayout || !toLayout) continue
886
+
887
+ let points: Position[] = []
888
+
889
+ // Check if this is a subgraph-to-subgraph edge
890
+ const isSubgraphEdge = fromSubgraph || toSubgraph
891
+
892
+ // HA edges inside HA containers: use ELK's edge routing directly
893
+ if (isHAContainer(container.id) && elkEdge.sections && elkEdge.sections.length > 0) {
894
+ const section = elkEdge.sections[0]
895
+ points.push({ x: section.startPoint.x, y: section.startPoint.y })
896
+ if (section.bendPoints) {
897
+ for (const bp of section.bendPoints) {
898
+ points.push({ x: bp.x, y: bp.y })
899
+ }
900
+ }
901
+ points.push({ x: section.endPoint.x, y: section.endPoint.y })
902
+ } else if (isSubgraphEdge) {
903
+ // Subgraph edges: use ELK's coordinates directly
904
+ if (elkEdge.sections && elkEdge.sections.length > 0) {
905
+ const section = elkEdge.sections[0]
906
+ points.push({ x: section.startPoint.x, y: section.startPoint.y })
907
+ if (section.bendPoints) {
908
+ for (const bp of section.bendPoints) {
909
+ points.push({ x: bp.x, y: bp.y })
910
+ }
911
+ }
912
+ points.push({ x: section.endPoint.x, y: section.endPoint.y })
913
+ } else {
914
+ // Fallback: simple line between centers
915
+ points = [
916
+ { x: fromLayout.position.x, y: fromLayout.position.y + fromLayout.size.height / 2 },
917
+ { x: toLayout.position.x, y: toLayout.position.y - toLayout.size.height / 2 },
918
+ ]
919
+ }
920
+ } else if (!isHAContainer(container.id)) {
921
+ // Check if this is a cross-subgraph edge
922
+ const fromParent = graph.nodes.find((n) => n.id === fromEndpoint.node)?.parent
923
+ const toParent = graph.nodes.find((n) => n.id === toEndpoint_.node)?.parent
924
+ const isCrossSubgraph = fromParent !== toParent
925
+
926
+ if (elkEdge.sections && elkEdge.sections.length > 0) {
927
+ const section = elkEdge.sections[0]
928
+
929
+ if (isCrossSubgraph) {
930
+ // Cross-subgraph edges: use ELK's coordinates directly
931
+ points.push({ x: section.startPoint.x, y: section.startPoint.y })
932
+ if (section.bendPoints) {
933
+ for (const bp of section.bendPoints) {
934
+ points.push({ x: bp.x, y: bp.y })
935
+ }
936
+ }
937
+ points.push({ x: section.endPoint.x, y: section.endPoint.y })
938
+ } else {
939
+ // Same-subgraph edges: snap to node boundaries for cleaner look
940
+ const fromBottomY = fromLayout.position.y + fromLayout.size.height / 2
941
+ const toTopY = toLayout.position.y - toLayout.size.height / 2
942
+
943
+ points.push({
944
+ x: section.startPoint.x,
945
+ y: fromBottomY,
946
+ })
947
+
948
+ if (section.bendPoints) {
949
+ for (const bp of section.bendPoints) {
950
+ points.push({ x: bp.x, y: bp.y })
951
+ }
952
+ }
953
+
954
+ points.push({
955
+ x: section.endPoint.x,
956
+ y: toTopY,
957
+ })
958
+ }
959
+ } else {
960
+ const fromBottomY = fromLayout.position.y + fromLayout.size.height / 2
961
+ const toTopY = toLayout.position.y - toLayout.size.height / 2
962
+ points = this.generateOrthogonalPath(
963
+ { x: fromLayout.position.x, y: fromBottomY },
964
+ { x: toLayout.position.x, y: toTopY },
965
+ )
966
+ }
967
+ } else {
968
+ // HA edge fallback: simple horizontal line
969
+ const leftLayout = fromLayout.position.x < toLayout.position.x ? fromLayout : toLayout
970
+ const rightLayout = fromLayout.position.x < toLayout.position.x ? toLayout : fromLayout
971
+ const y = (leftLayout.position.y + rightLayout.position.y) / 2
972
+ points = [
973
+ { x: leftLayout.position.x + leftLayout.size.width / 2, y },
974
+ { x: rightLayout.position.x - rightLayout.size.width / 2, y },
975
+ ]
976
+ }
977
+
978
+ layoutLinks.set(id, {
979
+ id,
980
+ from: fromEndpoint.node,
981
+ to: toEndpoint_.node,
982
+ fromEndpoint,
983
+ toEndpoint: toEndpoint_,
984
+ points,
985
+ link,
986
+ })
987
+ }
988
+ }
989
+
990
+ // Recursively process child containers (subgraphs and HA containers)
991
+ if (container.children) {
992
+ for (const child of container.children) {
993
+ if (subgraphMap.has(child.id) || child.id.startsWith('__ha_container_')) {
994
+ processEdgesInContainer(child)
995
+ }
996
+ }
997
+ }
998
+ }
999
+
1000
+ // Process all edges (coordinates are absolute with edgeCoords=ROOT)
1001
+ processEdgesInContainer(elkGraph)
1002
+
1003
+ // Fallback for any missing links
1004
+ for (const [index, link] of graph.links.entries()) {
1005
+ const id = link.id || `link-${index}`
1006
+ if (layoutLinks.has(id)) continue
1007
+
1008
+ const fromEndpoint = toEndpoint(link.from)
1009
+ const toEndpoint_ = toEndpoint(link.to)
1010
+ const fromNode = layoutNodes.get(fromEndpoint.node)
1011
+ const toNode = layoutNodes.get(toEndpoint_.node)
1012
+ if (!fromNode || !toNode) continue
1013
+
1014
+ const startY = fromNode.position.y + fromNode.size.height / 2
1015
+ const endY = toNode.position.y - toNode.size.height / 2
1016
+ const points = this.generateOrthogonalPath(
1017
+ { x: fromNode.position.x, y: startY },
1018
+ { x: toNode.position.x, y: endY },
1019
+ )
1020
+
1021
+ layoutLinks.set(id, {
1022
+ id,
1023
+ from: fromEndpoint.node,
1024
+ to: toEndpoint_.node,
1025
+ fromEndpoint,
1026
+ toEndpoint: toEndpoint_,
1027
+ points,
1028
+ link,
1029
+ })
1030
+ }
1031
+
1032
+ // Calculate bounds
1033
+ const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs)
1034
+
1035
+ return {
1036
+ nodes: layoutNodes,
1037
+ links: layoutLinks,
1038
+ subgraphs: layoutSubgraphs,
1039
+ bounds,
1040
+ }
1041
+ }
1042
+
1043
+ // Synchronous wrapper
1044
+ layout(graph: NetworkGraph): LayoutResult {
1045
+ const options = this.getEffectiveOptions(graph)
1046
+ const result = this.calculateFallbackLayout(graph, options.direction)
1047
+
1048
+ // Start async layout
1049
+ this.layoutAsync(graph)
1050
+ .then((asyncResult) => {
1051
+ Object.assign(result, asyncResult)
1052
+ })
1053
+ .catch(() => {})
1054
+
1055
+ return result
1056
+ }
1057
+
1058
+ private toElkDirection(direction: LayoutDirection): string {
1059
+ switch (direction) {
1060
+ case 'TB':
1061
+ return 'DOWN'
1062
+ case 'BT':
1063
+ return 'UP'
1064
+ case 'LR':
1065
+ return 'RIGHT'
1066
+ case 'RL':
1067
+ return 'LEFT'
1068
+ default:
1069
+ return 'DOWN'
1070
+ }
1071
+ }
1072
+
1073
+ private calculateNodeHeight(node: Node, portCount = 0): number {
1074
+ const lines = Array.isArray(node.label) ? node.label.length : 1
1075
+ const labelHeight = lines * LABEL_LINE_HEIGHT
1076
+
1077
+ const labels = Array.isArray(node.label) ? node.label : [node.label]
1078
+ const maxLabelLength = Math.max(...labels.map((l) => l.length))
1079
+ const labelWidth = maxLabelLength * ESTIMATED_CHAR_WIDTH
1080
+ const portWidth = portCount > 0 ? (portCount + 1) * MIN_PORT_SPACING : 0
1081
+ const baseContentWidth = Math.max(labelWidth, portWidth)
1082
+ const baseNodeWidth = Math.max(
1083
+ this.options.nodeWidth,
1084
+ baseContentWidth + NODE_HORIZONTAL_PADDING,
1085
+ )
1086
+ const maxIconWidth = Math.round(baseNodeWidth * MAX_ICON_WIDTH_RATIO)
1087
+
1088
+ let iconHeight = 0
1089
+ const iconKey = node.service || node.model
1090
+ if (node.vendor && iconKey) {
1091
+ const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
1092
+ if (iconEntry) {
1093
+ const vendorIcon = iconEntry.default
1094
+ const viewBox = iconEntry.viewBox || '0 0 48 48'
1095
+
1096
+ if (vendorIcon.startsWith('<svg')) {
1097
+ const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
1098
+ if (viewBoxMatch) {
1099
+ const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
1100
+ const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
1101
+ const aspectRatio = vbWidth / vbHeight
1102
+ const iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
1103
+ iconHeight = DEFAULT_ICON_SIZE
1104
+ if (iconWidth > maxIconWidth) {
1105
+ iconHeight = Math.round(maxIconWidth / aspectRatio)
1106
+ }
1107
+ } else {
1108
+ iconHeight = DEFAULT_ICON_SIZE
1109
+ }
1110
+ } else {
1111
+ const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
1112
+ if (vbMatch) {
1113
+ const vbWidth = Number.parseInt(vbMatch[3], 10)
1114
+ const vbHeight = Number.parseInt(vbMatch[4], 10)
1115
+ const aspectRatio = vbWidth / vbHeight
1116
+ const iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
1117
+ iconHeight = DEFAULT_ICON_SIZE
1118
+ if (iconWidth > maxIconWidth) {
1119
+ iconHeight = Math.round(maxIconWidth / aspectRatio)
1120
+ }
1121
+ } else {
1122
+ iconHeight = DEFAULT_ICON_SIZE
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ if (iconHeight === 0 && node.type && getDeviceIcon(node.type)) {
1129
+ iconHeight = DEFAULT_ICON_SIZE
1130
+ }
1131
+
1132
+ const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
1133
+ const contentHeight = iconHeight + gap + labelHeight
1134
+ return Math.max(this.options.nodeHeight, contentHeight + NODE_VERTICAL_PADDING)
1135
+ }
1136
+
1137
+ private calculatePortSpacing(portNames: Set<string> | undefined): number {
1138
+ if (!portNames || portNames.size === 0) return MIN_PORT_SPACING
1139
+
1140
+ let maxLabelLength = 0
1141
+ for (const name of portNames) {
1142
+ maxLabelLength = Math.max(maxLabelLength, name.length)
1143
+ }
1144
+
1145
+ const charWidth = PORT_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO
1146
+ const maxLabelWidth = maxLabelLength * charWidth
1147
+ const spacingFromLabel = maxLabelWidth + PORT_LABEL_PADDING
1148
+ return Math.max(MIN_PORT_SPACING, spacingFromLabel)
1149
+ }
1150
+
1151
+ private calculateNodeWidth(node: Node, portInfo: NodePortInfo | undefined): number {
1152
+ const labels = Array.isArray(node.label) ? node.label : [node.label]
1153
+ const maxLabelLength = Math.max(...labels.map((l) => l.length))
1154
+ const labelWidth = maxLabelLength * ESTIMATED_CHAR_WIDTH
1155
+
1156
+ const topCount = portInfo?.top.size || 0
1157
+ const bottomCount = portInfo?.bottom.size || 0
1158
+ const maxPortsPerSide = Math.max(topCount, bottomCount)
1159
+
1160
+ const portSpacing = this.calculatePortSpacing(portInfo?.all)
1161
+ const edgeMargin = Math.round(MIN_PORT_SPACING / 2)
1162
+ const portWidth = maxPortsPerSide > 0 ? (maxPortsPerSide - 1) * portSpacing + edgeMargin * 2 : 0
1163
+
1164
+ const paddedContentWidth = Math.max(labelWidth, 0) + NODE_HORIZONTAL_PADDING
1165
+ const baseNodeWidth = Math.max(paddedContentWidth, portWidth)
1166
+
1167
+ const maxIconWidth = Math.round(baseNodeWidth * MAX_ICON_WIDTH_RATIO)
1168
+ let iconWidth = DEFAULT_ICON_SIZE
1169
+ const iconKey = node.service || node.model
1170
+ if (node.vendor && iconKey) {
1171
+ const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
1172
+ if (iconEntry) {
1173
+ const vendorIcon = iconEntry.default
1174
+ const viewBox = iconEntry.viewBox || '0 0 48 48'
1175
+
1176
+ if (vendorIcon.startsWith('<svg')) {
1177
+ const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
1178
+ if (viewBoxMatch) {
1179
+ const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
1180
+ const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
1181
+ const aspectRatio = vbWidth / vbHeight
1182
+ iconWidth = Math.min(Math.round(DEFAULT_ICON_SIZE * aspectRatio), maxIconWidth)
1183
+ }
1184
+ } else {
1185
+ const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
1186
+ if (vbMatch) {
1187
+ const vbWidth = Number.parseInt(vbMatch[3], 10)
1188
+ const vbHeight = Number.parseInt(vbMatch[4], 10)
1189
+ const aspectRatio = vbWidth / vbHeight
1190
+ iconWidth = Math.min(Math.round(DEFAULT_ICON_SIZE * aspectRatio), maxIconWidth)
1191
+ }
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ const paddedIconLabelWidth = Math.max(iconWidth, labelWidth) + NODE_HORIZONTAL_PADDING
1197
+ return Math.max(paddedIconLabelWidth, portWidth)
1198
+ }
1199
+
1200
+ private calculateTotalBounds(
1201
+ nodes: Map<string, LayoutNode>,
1202
+ subgraphs: Map<string, LayoutSubgraph>,
1203
+ ): Bounds {
1204
+ let minX = Number.POSITIVE_INFINITY
1205
+ let minY = Number.POSITIVE_INFINITY
1206
+ let maxX = Number.NEGATIVE_INFINITY
1207
+ let maxY = Number.NEGATIVE_INFINITY
1208
+
1209
+ for (const node of nodes.values()) {
1210
+ let left = node.position.x - node.size.width / 2
1211
+ let right = node.position.x + node.size.width / 2
1212
+ let top = node.position.y - node.size.height / 2
1213
+ let bottom = node.position.y + node.size.height / 2
1214
+
1215
+ if (node.ports) {
1216
+ for (const port of node.ports.values()) {
1217
+ const portX = node.position.x + port.position.x
1218
+ const portY = node.position.y + port.position.y
1219
+ left = Math.min(left, portX - port.size.width / 2)
1220
+ right = Math.max(right, portX + port.size.width / 2)
1221
+ top = Math.min(top, portY - port.size.height / 2)
1222
+ bottom = Math.max(bottom, portY + port.size.height / 2)
1223
+ }
1224
+ }
1225
+
1226
+ minX = Math.min(minX, left)
1227
+ minY = Math.min(minY, top)
1228
+ maxX = Math.max(maxX, right)
1229
+ maxY = Math.max(maxY, bottom)
1230
+ }
1231
+
1232
+ for (const sg of subgraphs.values()) {
1233
+ minX = Math.min(minX, sg.bounds.x)
1234
+ minY = Math.min(minY, sg.bounds.y)
1235
+ maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width)
1236
+ maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height)
1237
+ }
1238
+
1239
+ const padding = 50
1240
+
1241
+ if (minX === Number.POSITIVE_INFINITY) {
1242
+ return { x: 0, y: 0, width: 400, height: 300 }
1243
+ }
1244
+
1245
+ return {
1246
+ x: minX - padding,
1247
+ y: minY - padding,
1248
+ width: maxX - minX + padding * 2,
1249
+ height: maxY - minY + padding * 2,
1250
+ }
1251
+ }
1252
+
1253
+ private calculateFallbackLayout(graph: NetworkGraph, _direction: LayoutDirection): LayoutResult {
1254
+ const layoutNodes = new Map<string, LayoutNode>()
1255
+ const layoutSubgraphs = new Map<string, LayoutSubgraph>()
1256
+ const layoutLinks = new Map<string, LayoutLink>()
1257
+
1258
+ // Detect HA pairs for port assignment
1259
+ const haPairs = this.detectHAPairs(graph)
1260
+ const haPairSet = new Set<string>()
1261
+ for (const pair of haPairs) {
1262
+ haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
1263
+ }
1264
+ const nodePorts = collectNodePorts(graph, haPairSet)
1265
+
1266
+ let x = 100
1267
+ let y = 100
1268
+ let col = 0
1269
+ const maxCols = 4
1270
+ const rowHeight = this.options.nodeHeight + this.options.rankSpacing
1271
+
1272
+ for (const node of graph.nodes) {
1273
+ const portInfo = nodePorts.get(node.id)
1274
+ const portCount = portInfo?.all.size || 0
1275
+ const height = this.calculateNodeHeight(node, portCount)
1276
+ const width = this.calculateNodeWidth(node, portInfo)
1277
+ const colWidth = width + this.options.nodeSpacing
1278
+
1279
+ layoutNodes.set(node.id, {
1280
+ id: node.id,
1281
+ position: { x: x + width / 2, y: y + height / 2 },
1282
+ size: { width, height },
1283
+ node,
1284
+ })
1285
+
1286
+ col++
1287
+ if (col >= maxCols) {
1288
+ col = 0
1289
+ x = 100
1290
+ y += rowHeight
1291
+ } else {
1292
+ x += colWidth
1293
+ }
1294
+ }
1295
+
1296
+ for (const [index, link] of graph.links.entries()) {
1297
+ const fromId = getNodeId(link.from)
1298
+ const toId = getNodeId(link.to)
1299
+ const from = layoutNodes.get(fromId)
1300
+ const to = layoutNodes.get(toId)
1301
+ if (from && to) {
1302
+ layoutLinks.set(link.id || `link-${index}`, {
1303
+ id: link.id || `link-${index}`,
1304
+ from: fromId,
1305
+ to: toId,
1306
+ fromEndpoint: toEndpoint(link.from),
1307
+ toEndpoint: toEndpoint(link.to),
1308
+ points: [from.position, to.position],
1309
+ link,
1310
+ })
1311
+ }
1312
+ }
1313
+
1314
+ const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs)
1315
+
1316
+ return {
1317
+ nodes: layoutNodes,
1318
+ links: layoutLinks,
1319
+ subgraphs: layoutSubgraphs,
1320
+ bounds,
1321
+ metadata: { algorithm: 'fallback-grid', duration: 0 },
1322
+ }
1323
+ }
1324
+
1325
+ /** Detect HA pairs from redundancy links */
1326
+ private detectHAPairs(graph: NetworkGraph): { nodeA: string; nodeB: string }[] {
1327
+ const pairs: { nodeA: string; nodeB: string }[] = []
1328
+ const processed = new Set<string>()
1329
+
1330
+ for (const link of graph.links) {
1331
+ if (!link.redundancy) continue
1332
+
1333
+ const fromId = getNodeId(link.from)
1334
+ const toId = getNodeId(link.to)
1335
+ const key = [fromId, toId].sort().join(':')
1336
+ if (processed.has(key)) continue
1337
+
1338
+ pairs.push({ nodeA: fromId, nodeB: toId })
1339
+ processed.add(key)
1340
+ }
1341
+
1342
+ return pairs
1343
+ }
1344
+
1345
+ /** Generate orthogonal path between two points */
1346
+ private generateOrthogonalPath(start: Position, end: Position): Position[] {
1347
+ const dx = end.x - start.x
1348
+ const dy = end.y - start.y
1349
+
1350
+ // If points are nearly aligned, use direct line
1351
+ if (Math.abs(dx) < 5) {
1352
+ return [start, end]
1353
+ }
1354
+ if (Math.abs(dy) < 5) {
1355
+ return [start, end]
1356
+ }
1357
+
1358
+ // Use midpoint for orthogonal routing
1359
+ const midY = start.y + dy / 2
1360
+
1361
+ return [start, { x: start.x, y: midY }, { x: end.x, y: midY }, end]
1362
+ }
1363
+ }
1364
+
1365
+ // Default instance
1366
+ export const hierarchicalLayout = new HierarchicalLayout()