@shumoku/core 0.1.0 → 0.2.0

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.
Files changed (158) hide show
  1. package/README.md +56 -0
  2. package/dist/constants.d.ts +23 -0
  3. package/dist/constants.d.ts.map +1 -0
  4. package/dist/constants.js +25 -0
  5. package/dist/constants.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/layout/hierarchical.d.ts +12 -39
  11. package/dist/layout/hierarchical.d.ts.map +1 -1
  12. package/dist/layout/hierarchical.js +697 -1015
  13. package/dist/layout/hierarchical.js.map +1 -1
  14. package/dist/models/types.d.ts +30 -0
  15. package/dist/models/types.d.ts.map +1 -1
  16. package/dist/models/types.js.map +1 -1
  17. package/dist/renderer/svg.d.ts +31 -5
  18. package/dist/renderer/svg.d.ts.map +1 -1
  19. package/dist/renderer/svg.js +312 -85
  20. package/dist/renderer/svg.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/constants.ts +35 -0
  23. package/src/index.ts +3 -0
  24. package/src/layout/hierarchical.ts +805 -1127
  25. package/src/models/types.ts +37 -0
  26. package/src/renderer/svg.ts +368 -88
  27. package/dist/renderer/components/index.d.ts +0 -8
  28. package/dist/renderer/components/index.d.ts.map +0 -1
  29. package/dist/renderer/components/index.js +0 -8
  30. package/dist/renderer/components/index.js.map +0 -1
  31. package/dist/renderer/components/link-renderer.d.ts +0 -11
  32. package/dist/renderer/components/link-renderer.d.ts.map +0 -1
  33. package/dist/renderer/components/link-renderer.js +0 -340
  34. package/dist/renderer/components/link-renderer.js.map +0 -1
  35. package/dist/renderer/components/node-renderer.d.ts +0 -14
  36. package/dist/renderer/components/node-renderer.d.ts.map +0 -1
  37. package/dist/renderer/components/node-renderer.js +0 -242
  38. package/dist/renderer/components/node-renderer.js.map +0 -1
  39. package/dist/renderer/components/port-renderer.d.ts +0 -8
  40. package/dist/renderer/components/port-renderer.d.ts.map +0 -1
  41. package/dist/renderer/components/port-renderer.js +0 -85
  42. package/dist/renderer/components/port-renderer.js.map +0 -1
  43. package/dist/renderer/components/subgraph-renderer.d.ts +0 -13
  44. package/dist/renderer/components/subgraph-renderer.d.ts.map +0 -1
  45. package/dist/renderer/components/subgraph-renderer.js +0 -85
  46. package/dist/renderer/components/subgraph-renderer.js.map +0 -1
  47. package/dist/renderer/icon-registry/index.d.ts +0 -6
  48. package/dist/renderer/icon-registry/index.d.ts.map +0 -1
  49. package/dist/renderer/icon-registry/index.js +0 -5
  50. package/dist/renderer/icon-registry/index.js.map +0 -1
  51. package/dist/renderer/icon-registry/registry.d.ts +0 -25
  52. package/dist/renderer/icon-registry/registry.d.ts.map +0 -1
  53. package/dist/renderer/icon-registry/registry.js +0 -85
  54. package/dist/renderer/icon-registry/registry.js.map +0 -1
  55. package/dist/renderer/icon-registry/types.d.ts +0 -44
  56. package/dist/renderer/icon-registry/types.d.ts.map +0 -1
  57. package/dist/renderer/icon-registry/types.js +0 -5
  58. package/dist/renderer/icon-registry/types.js.map +0 -1
  59. package/dist/renderer/render-model/builder.d.ts +0 -43
  60. package/dist/renderer/render-model/builder.d.ts.map +0 -1
  61. package/dist/renderer/render-model/builder.js +0 -646
  62. package/dist/renderer/render-model/builder.js.map +0 -1
  63. package/dist/renderer/render-model/index.d.ts +0 -6
  64. package/dist/renderer/render-model/index.d.ts.map +0 -1
  65. package/dist/renderer/render-model/index.js +0 -5
  66. package/dist/renderer/render-model/index.js.map +0 -1
  67. package/dist/renderer/render-model/types.d.ts +0 -216
  68. package/dist/renderer/render-model/types.d.ts.map +0 -1
  69. package/dist/renderer/render-model/types.js +0 -6
  70. package/dist/renderer/render-model/types.js.map +0 -1
  71. package/dist/renderer/renderer-types.d.ts +0 -55
  72. package/dist/renderer/renderer-types.d.ts.map +0 -1
  73. package/dist/renderer/renderer-types.js +0 -5
  74. package/dist/renderer/renderer-types.js.map +0 -1
  75. package/dist/renderer/svg-builder.d.ts +0 -152
  76. package/dist/renderer/svg-builder.d.ts.map +0 -1
  77. package/dist/renderer/svg-builder.js +0 -176
  78. package/dist/renderer/svg-builder.js.map +0 -1
  79. package/dist/renderer/svg-dom/builders/defs.d.ts +0 -10
  80. package/dist/renderer/svg-dom/builders/defs.d.ts.map +0 -1
  81. package/dist/renderer/svg-dom/builders/defs.js +0 -82
  82. package/dist/renderer/svg-dom/builders/defs.js.map +0 -1
  83. package/dist/renderer/svg-dom/builders/index.d.ts +0 -9
  84. package/dist/renderer/svg-dom/builders/index.d.ts.map +0 -1
  85. package/dist/renderer/svg-dom/builders/index.js +0 -9
  86. package/dist/renderer/svg-dom/builders/index.js.map +0 -1
  87. package/dist/renderer/svg-dom/builders/link.d.ts +0 -18
  88. package/dist/renderer/svg-dom/builders/link.d.ts.map +0 -1
  89. package/dist/renderer/svg-dom/builders/link.js +0 -188
  90. package/dist/renderer/svg-dom/builders/link.js.map +0 -1
  91. package/dist/renderer/svg-dom/builders/node.d.ts +0 -15
  92. package/dist/renderer/svg-dom/builders/node.d.ts.map +0 -1
  93. package/dist/renderer/svg-dom/builders/node.js +0 -262
  94. package/dist/renderer/svg-dom/builders/node.js.map +0 -1
  95. package/dist/renderer/svg-dom/builders/subgraph.d.ts +0 -14
  96. package/dist/renderer/svg-dom/builders/subgraph.d.ts.map +0 -1
  97. package/dist/renderer/svg-dom/builders/subgraph.js +0 -63
  98. package/dist/renderer/svg-dom/builders/subgraph.js.map +0 -1
  99. package/dist/renderer/svg-dom/builders/utils.d.ts +0 -40
  100. package/dist/renderer/svg-dom/builders/utils.d.ts.map +0 -1
  101. package/dist/renderer/svg-dom/builders/utils.js +0 -79
  102. package/dist/renderer/svg-dom/builders/utils.js.map +0 -1
  103. package/dist/renderer/svg-dom/index.d.ts +0 -9
  104. package/dist/renderer/svg-dom/index.d.ts.map +0 -1
  105. package/dist/renderer/svg-dom/index.js +0 -7
  106. package/dist/renderer/svg-dom/index.js.map +0 -1
  107. package/dist/renderer/svg-dom/interaction.d.ts +0 -69
  108. package/dist/renderer/svg-dom/interaction.d.ts.map +0 -1
  109. package/dist/renderer/svg-dom/interaction.js +0 -296
  110. package/dist/renderer/svg-dom/interaction.js.map +0 -1
  111. package/dist/renderer/svg-dom/renderer.d.ts +0 -47
  112. package/dist/renderer/svg-dom/renderer.d.ts.map +0 -1
  113. package/dist/renderer/svg-dom/renderer.js +0 -188
  114. package/dist/renderer/svg-dom/renderer.js.map +0 -1
  115. package/dist/renderer/svg-string/builders/defs.d.ts +0 -10
  116. package/dist/renderer/svg-string/builders/defs.d.ts.map +0 -1
  117. package/dist/renderer/svg-string/builders/defs.js +0 -43
  118. package/dist/renderer/svg-string/builders/defs.js.map +0 -1
  119. package/dist/renderer/svg-string/builders/link.d.ts +0 -10
  120. package/dist/renderer/svg-string/builders/link.d.ts.map +0 -1
  121. package/dist/renderer/svg-string/builders/link.js +0 -149
  122. package/dist/renderer/svg-string/builders/link.js.map +0 -1
  123. package/dist/renderer/svg-string/builders/node.d.ts +0 -10
  124. package/dist/renderer/svg-string/builders/node.d.ts.map +0 -1
  125. package/dist/renderer/svg-string/builders/node.js +0 -134
  126. package/dist/renderer/svg-string/builders/node.js.map +0 -1
  127. package/dist/renderer/svg-string/builders/subgraph.d.ts +0 -10
  128. package/dist/renderer/svg-string/builders/subgraph.d.ts.map +0 -1
  129. package/dist/renderer/svg-string/builders/subgraph.js +0 -59
  130. package/dist/renderer/svg-string/builders/subgraph.js.map +0 -1
  131. package/dist/renderer/svg-string/index.d.ts +0 -5
  132. package/dist/renderer/svg-string/index.d.ts.map +0 -1
  133. package/dist/renderer/svg-string/index.js +0 -5
  134. package/dist/renderer/svg-string/index.js.map +0 -1
  135. package/dist/renderer/svg-string/renderer.d.ts +0 -17
  136. package/dist/renderer/svg-string/renderer.d.ts.map +0 -1
  137. package/dist/renderer/svg-string/renderer.js +0 -53
  138. package/dist/renderer/svg-string/renderer.js.map +0 -1
  139. package/dist/renderer/text-measurer/browser-measurer.d.ts +0 -25
  140. package/dist/renderer/text-measurer/browser-measurer.d.ts.map +0 -1
  141. package/dist/renderer/text-measurer/browser-measurer.js +0 -85
  142. package/dist/renderer/text-measurer/browser-measurer.js.map +0 -1
  143. package/dist/renderer/text-measurer/fallback-measurer.d.ts +0 -22
  144. package/dist/renderer/text-measurer/fallback-measurer.d.ts.map +0 -1
  145. package/dist/renderer/text-measurer/fallback-measurer.js +0 -113
  146. package/dist/renderer/text-measurer/fallback-measurer.js.map +0 -1
  147. package/dist/renderer/text-measurer/index.d.ts +0 -13
  148. package/dist/renderer/text-measurer/index.d.ts.map +0 -1
  149. package/dist/renderer/text-measurer/index.js +0 -35
  150. package/dist/renderer/text-measurer/index.js.map +0 -1
  151. package/dist/renderer/text-measurer/types.d.ts +0 -30
  152. package/dist/renderer/text-measurer/types.d.ts.map +0 -1
  153. package/dist/renderer/text-measurer/types.js +0 -5
  154. package/dist/renderer/text-measurer/types.js.map +0 -1
  155. package/dist/renderer/theme.d.ts +0 -29
  156. package/dist/renderer/theme.d.ts.map +0 -1
  157. package/dist/renderer/theme.js +0 -80
  158. package/dist/renderer/theme.js.map +0 -1
@@ -18,15 +18,51 @@ import {
18
18
  type LinkEndpoint,
19
19
  getNodeId,
20
20
  } from '../models/index.js'
21
- import { getVendorIconEntry } from '../icons/index.js'
21
+ import { getVendorIconEntry, getDeviceIcon } from '../icons/index.js'
22
+ import {
23
+ DEFAULT_ICON_SIZE,
24
+ ICON_LABEL_GAP,
25
+ LABEL_LINE_HEIGHT,
26
+ NODE_VERTICAL_PADDING,
27
+ NODE_HORIZONTAL_PADDING,
28
+ MIN_PORT_SPACING,
29
+ PORT_LABEL_FONT_SIZE,
30
+ CHAR_WIDTH_RATIO,
31
+ PORT_LABEL_PADDING,
32
+ ESTIMATED_CHAR_WIDTH,
33
+ MAX_ICON_WIDTH_RATIO,
34
+ } from '../constants.js'
35
+
36
+ // ============================================
37
+ // Types
38
+ // ============================================
39
+
40
+ /** ELK edge section for edge routing */
41
+ interface ElkEdgeSection {
42
+ startPoint: { x: number; y: number }
43
+ endPoint: { x: number; y: number }
44
+ bendPoints?: Array<{ x: number; y: number }>
45
+ }
46
+
47
+ /** Extended ELK edge with sections */
48
+ interface ElkEdgeWithSections {
49
+ id: string
50
+ sections?: ElkEdgeSection[]
51
+ }
52
+
53
+ /** Port info for a node */
54
+ interface NodePortInfo {
55
+ all: Set<string>
56
+ top: Set<string>
57
+ bottom: Set<string>
58
+ left: Set<string>
59
+ right: Set<string>
60
+ }
22
61
 
23
62
  // ============================================
24
63
  // Helper Functions
25
64
  // ============================================
26
65
 
27
- /**
28
- * Convert endpoint to full LinkEndpoint object
29
- */
30
66
  function toEndpoint(endpoint: string | LinkEndpoint): LinkEndpoint {
31
67
  if (typeof endpoint === 'string') {
32
68
  return { node: endpoint }
@@ -34,38 +70,54 @@ function toEndpoint(endpoint: string | LinkEndpoint): LinkEndpoint {
34
70
  return endpoint
35
71
  }
36
72
 
37
- /**
38
- * Collect all ports for each node from links
39
- * @param haNodePairs - Set of "nodeA:nodeB" strings for HA pairs to exclude their ports
40
- */
73
+ /** Collect ports for each node from links */
41
74
  function collectNodePorts(
42
75
  graph: NetworkGraph,
43
- haNodePairs?: Set<string>
44
- ): Map<string, Set<string>> {
45
- const nodePorts = new Map<string, Set<string>>()
76
+ haPairSet: Set<string>
77
+ ): Map<string, NodePortInfo> {
78
+ const nodePorts = new Map<string, NodePortInfo>()
79
+
80
+ const getOrCreate = (nodeId: string): NodePortInfo => {
81
+ if (!nodePorts.has(nodeId)) {
82
+ nodePorts.set(nodeId, { all: new Set(), top: new Set(), bottom: new Set(), left: new Set(), right: new Set() })
83
+ }
84
+ return nodePorts.get(nodeId)!
85
+ }
86
+
87
+ // Check if link is between HA pair nodes
88
+ const isHALink = (fromNode: string, toNode: string): boolean => {
89
+ const key = [fromNode, toNode].sort().join(':')
90
+ return haPairSet.has(key)
91
+ }
46
92
 
47
93
  for (const link of graph.links) {
48
- const fromEndpoint = toEndpoint(link.from)
49
- const toEndpoint_ = toEndpoint(link.to)
94
+ const from = toEndpoint(link.from)
95
+ const to = toEndpoint(link.to)
50
96
 
51
- // Skip ports for HA links - they will be handled separately
52
- const pairKey = `${fromEndpoint.node}:${toEndpoint_.node}`
53
- if (haNodePairs?.has(pairKey)) {
54
- continue
55
- }
97
+ if (link.redundancy && isHALink(from.node, to.node)) {
98
+ // HA links: create side ports (left/right)
99
+ const fromPortName = from.port || 'ha'
100
+ const toPortName = to.port || 'ha'
56
101
 
57
- if (fromEndpoint.port) {
58
- if (!nodePorts.has(fromEndpoint.node)) {
59
- nodePorts.set(fromEndpoint.node, new Set())
60
- }
61
- nodePorts.get(fromEndpoint.node)!.add(fromEndpoint.port)
62
- }
102
+ const fromInfo = getOrCreate(from.node)
103
+ fromInfo.all.add(fromPortName)
104
+ fromInfo.right.add(fromPortName)
63
105
 
64
- if (toEndpoint_.port) {
65
- if (!nodePorts.has(toEndpoint_.node)) {
66
- nodePorts.set(toEndpoint_.node, new Set())
106
+ const toInfo = getOrCreate(to.node)
107
+ toInfo.all.add(toPortName)
108
+ toInfo.left.add(toPortName)
109
+ } else {
110
+ // Normal links: ports on top/bottom
111
+ if (from.port) {
112
+ const info = getOrCreate(from.node)
113
+ info.all.add(from.port)
114
+ info.bottom.add(from.port)
115
+ }
116
+ if (to.port) {
117
+ const info = getOrCreate(to.node)
118
+ info.all.add(to.port)
119
+ info.top.add(to.port)
67
120
  }
68
- nodePorts.get(toEndpoint_.node)!.add(toEndpoint_.port)
69
121
  }
70
122
  }
71
123
 
@@ -94,10 +146,10 @@ const DEFAULT_OPTIONS: Required<HierarchicalLayoutOptions> = {
94
146
  direction: 'TB',
95
147
  nodeWidth: 180,
96
148
  nodeHeight: 60,
97
- nodeSpacing: 120, // Horizontal spacing between nodes
98
- rankSpacing: 180, // Vertical spacing between layers
99
- subgraphPadding: 60, // Padding inside subgraphs
100
- subgraphLabelHeight: 28,
149
+ nodeSpacing: 40,
150
+ rankSpacing: 60,
151
+ subgraphPadding: 24,
152
+ subgraphLabelHeight: 24,
101
153
  }
102
154
 
103
155
  // ============================================
@@ -114,51 +166,81 @@ export class HierarchicalLayout {
114
166
  }
115
167
 
116
168
  /**
117
- * Get effective options by merging graph settings with defaults
169
+ * Calculate dynamic spacing based on graph complexity
118
170
  */
171
+ private calculateDynamicSpacing(graph: NetworkGraph): { nodeSpacing: number; rankSpacing: number; subgraphPadding: number } {
172
+ const nodeCount = graph.nodes.length
173
+ const linkCount = graph.links.length
174
+ const subgraphCount = graph.subgraphs?.length || 0
175
+
176
+ let portCount = 0
177
+ let maxPortLabelLength = 0
178
+ for (const link of graph.links) {
179
+ if (typeof link.from !== 'string' && link.from.port) {
180
+ portCount++
181
+ maxPortLabelLength = Math.max(maxPortLabelLength, link.from.port.length)
182
+ }
183
+ if (typeof link.to !== 'string' && link.to.port) {
184
+ portCount++
185
+ maxPortLabelLength = Math.max(maxPortLabelLength, link.to.port.length)
186
+ }
187
+ }
188
+
189
+ const avgPortsPerNode = nodeCount > 0 ? portCount / nodeCount : 0
190
+ const complexity = nodeCount * 1.0 + linkCount * 0.8 + portCount * 0.3 + subgraphCount * 2
191
+ const portDensityFactor = Math.min(1.5, 1 + avgPortsPerNode * 0.1)
192
+ const rawSpacing = Math.max(20, Math.min(60, 80 - complexity * 1.2))
193
+ const baseSpacing = rawSpacing * portDensityFactor
194
+
195
+ const portLabelProtrusion = portCount > 0 ? 28 : 0
196
+ const portLabelWidth = maxPortLabelLength * PORT_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO
197
+ const minRankSpacing = Math.max(portLabelWidth, portLabelProtrusion) + 16
198
+ const minSubgraphPadding = portLabelProtrusion + 8
199
+
200
+ return {
201
+ nodeSpacing: Math.round(baseSpacing),
202
+ rankSpacing: Math.round(Math.max(baseSpacing * 1.5, minRankSpacing)),
203
+ subgraphPadding: Math.round(Math.max(baseSpacing * 0.6, minSubgraphPadding)),
204
+ }
205
+ }
206
+
119
207
  private getEffectiveOptions(graph: NetworkGraph): Required<HierarchicalLayoutOptions> {
120
208
  const settings = graph.settings
209
+ const dynamicSpacing = this.calculateDynamicSpacing(graph)
121
210
 
122
211
  return {
123
212
  ...this.options,
124
213
  direction: settings?.direction || this.options.direction,
125
- nodeSpacing: settings?.nodeSpacing || this.options.nodeSpacing,
126
- rankSpacing: settings?.rankSpacing || this.options.rankSpacing,
127
- subgraphPadding: settings?.subgraphPadding || this.options.subgraphPadding,
214
+ nodeSpacing: settings?.nodeSpacing || dynamicSpacing.nodeSpacing,
215
+ rankSpacing: settings?.rankSpacing || dynamicSpacing.rankSpacing,
216
+ subgraphPadding: settings?.subgraphPadding || dynamicSpacing.subgraphPadding,
128
217
  }
129
218
  }
130
219
 
131
220
  async layoutAsync(graph: NetworkGraph): Promise<LayoutResult> {
132
221
  const startTime = performance.now()
222
+ const options = this.getEffectiveOptions(graph)
133
223
 
134
- // Merge graph settings with default options
135
- const effectiveOptions = this.getEffectiveOptions(graph)
136
- const direction = effectiveOptions.direction
137
-
138
- // Detect HA pairs first (before building ELK graph)
224
+ // Detect HA pairs first (needed for port assignment)
139
225
  const haPairs = this.detectHAPairs(graph)
226
+ const haPairSet = new Set<string>()
227
+ for (const pair of haPairs) {
228
+ haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
229
+ }
230
+
231
+ const nodePorts = collectNodePorts(graph, haPairSet)
140
232
 
141
- // Build ELK graph with HA pairs merged into virtual nodes
142
- const { elkGraph, haVirtualNodes } = this.buildElkGraphWithHAMerge(
143
- graph, direction, effectiveOptions, haPairs
144
- )
233
+ // Build ELK graph
234
+ const elkGraph = this.buildElkGraph(graph, options, nodePorts, haPairs)
145
235
 
146
236
  // Run ELK layout
147
237
  const layoutedGraph = await this.elk.layout(elkGraph)
148
238
 
149
- // Extract results and expand HA virtual nodes back to original pairs
150
- const result = this.extractLayoutResultWithHAExpand(
151
- graph, layoutedGraph, haVirtualNodes, effectiveOptions
152
- )
153
-
154
- // Adjust node distances based on link minLength (for non-HA links)
155
- this.adjustLinkDistances(result, graph, direction)
156
-
157
- // Recalculate subgraph bounds after node adjustments
158
- this.recalculateSubgraphBounds(result, graph)
239
+ // Extract results using ELK's positions and edge routes
240
+ const result = this.extractLayoutResult(graph, layoutedGraph, nodePorts, options)
159
241
 
160
242
  result.metadata = {
161
- algorithm: 'elk-layered-ha-merge',
243
+ algorithm: 'elk-layered',
162
244
  duration: performance.now() - startTime,
163
245
  }
164
246
 
@@ -166,64 +248,15 @@ export class HierarchicalLayout {
166
248
  }
167
249
 
168
250
  /**
169
- * Build ELK graph with HA pairs merged into single virtual nodes
251
+ * Build ELK graph - uses container nodes for HA pairs
170
252
  */
171
- private buildElkGraphWithHAMerge(
253
+ private buildElkGraph(
172
254
  graph: NetworkGraph,
173
- direction: LayoutDirection,
174
255
  options: Required<HierarchicalLayoutOptions>,
175
- haPairs: { nodeA: string; nodeB: string; minLength?: number }[]
176
- ): { elkGraph: ElkNode; haVirtualNodes: Map<string, { virtualId: string; nodeA: Node; nodeB: Node; gap: number; widthA: number; widthB: number; height: number }> } {
177
- const elkDirection = this.toElkDirection(direction)
178
- const haVirtualNodes = new Map<string, { virtualId: string; nodeA: Node; nodeB: Node; gap: number; widthA: number; widthB: number; height: number }>()
179
-
180
- // Build set of nodes in HA pairs
181
- const nodesInHAPairs = new Set<string>()
182
- for (const pair of haPairs) {
183
- nodesInHAPairs.add(pair.nodeA)
184
- nodesInHAPairs.add(pair.nodeB)
185
- }
186
-
187
- // Build node map for quick lookup
188
- const nodeMap = new Map<string, Node>()
189
- for (const node of graph.nodes) {
190
- nodeMap.set(node.id, node)
191
- }
192
-
193
- // Create virtual nodes for HA pairs
194
- const gap = 40 // Gap between HA pair nodes
195
- for (let i = 0; i < haPairs.length; i++) {
196
- const pair = haPairs[i]
197
- const nodeA = nodeMap.get(pair.nodeA)
198
- const nodeB = nodeMap.get(pair.nodeB)
199
- if (!nodeA || !nodeB) continue
200
-
201
- const virtualId = `__ha_virtual_${i}`
202
- const widthA = options.nodeWidth
203
- const widthB = options.nodeWidth
204
- const heightA = this.calculateNodeHeight(nodeA)
205
- const heightB = this.calculateNodeHeight(nodeB)
206
-
207
- haVirtualNodes.set(virtualId, {
208
- virtualId,
209
- nodeA,
210
- nodeB,
211
- gap: pair.minLength ?? gap,
212
- widthA,
213
- widthB,
214
- height: Math.max(heightA, heightB),
215
- })
216
- }
217
-
218
- // Build edge redirect map: original node -> virtual node + side (A or B)
219
- const edgeRedirect = new Map<string, { virtualId: string; side: 'A' | 'B' }>()
220
- for (const [virtualId, info] of haVirtualNodes) {
221
- edgeRedirect.set(info.nodeA.id, { virtualId, side: 'A' })
222
- edgeRedirect.set(info.nodeB.id, { virtualId, side: 'B' })
223
- }
224
-
225
- // Collect all ports for each node from links
226
- const nodePorts = collectNodePorts(graph)
256
+ nodePorts: Map<string, NodePortInfo>,
257
+ haPairs: { nodeA: string; nodeB: string }[]
258
+ ): ElkNode {
259
+ const elkDirection = this.toElkDirection(options.direction)
227
260
 
228
261
  // Build subgraph map
229
262
  const subgraphMap = new Map<string, Subgraph>()
@@ -233,289 +266,379 @@ export class HierarchicalLayout {
233
266
  }
234
267
  }
235
268
 
236
- // Build node to parent map
237
- const nodeParent = new Map<string, string>()
238
- for (const node of graph.nodes) {
239
- if (node.parent) {
240
- nodeParent.set(node.id, node.parent)
241
- }
242
- }
269
+ // Build HA container map: node ID -> container ID
270
+ const nodeToHAContainer = new Map<string, string>()
271
+ const haPairMap = new Map<string, { nodeA: string; nodeB: string }>()
272
+ haPairs.forEach((pair, idx) => {
273
+ const containerId = `__ha_container_${idx}`
274
+ nodeToHAContainer.set(pair.nodeA, containerId)
275
+ nodeToHAContainer.set(pair.nodeB, containerId)
276
+ haPairMap.set(containerId, pair)
277
+ })
243
278
 
244
- // Build subgraph children map
245
- const subgraphChildren = new Map<string, string[]>()
246
- for (const sg of subgraphMap.values()) {
247
- subgraphChildren.set(sg.id, [])
248
- }
249
- for (const sg of subgraphMap.values()) {
250
- if (sg.parent && subgraphMap.has(sg.parent)) {
251
- subgraphChildren.get(sg.parent)!.push(sg.id)
252
- }
253
- }
279
+ // Create ELK node
280
+ const createElkNode = (node: Node): ElkNode => {
281
+ const portInfo = nodePorts.get(node.id)
282
+ const portCount = portInfo?.all.size || 0
283
+ const height = this.calculateNodeHeight(node, portCount)
284
+ const width = this.calculateNodeWidth(node, portInfo)
254
285
 
255
- // Create ELK node for regular nodes
256
- const createElkNode_ = (node: Node): ElkNode => {
257
- const height = this.calculateNodeHeight(node)
258
286
  const elkNode: ElkNode = {
259
287
  id: node.id,
260
- width: options.nodeWidth,
288
+ width,
261
289
  height,
262
290
  labels: [{ text: Array.isArray(node.label) ? node.label.join('\n') : node.label }],
263
291
  }
264
292
 
265
- // Add ports if this node has any
266
- const ports = nodePorts.get(node.id)
267
- if (ports && ports.size > 0) {
268
- elkNode.ports = Array.from(ports).map(portName => ({
269
- id: `${node.id}:${portName}`,
270
- width: PORT_WIDTH,
271
- height: PORT_HEIGHT,
272
- labels: [{ text: portName }],
273
- }))
274
- elkNode.layoutOptions = { 'elk.portConstraints': 'FREE' }
275
- }
293
+ // Add ports
294
+ if (portInfo && portInfo.all.size > 0) {
295
+ elkNode.ports = []
276
296
 
277
- return elkNode
278
- }
297
+ // Calculate port spacing based on label width
298
+ const portSpacing = this.calculatePortSpacing(portInfo.all)
299
+
300
+ // Helper to calculate port positions centered in the node
301
+ const calcPortPositions = (count: number, totalWidth: number): number[] => {
302
+ if (count === 0) return []
303
+ if (count === 1) return [totalWidth / 2]
304
+ const totalSpan = (count - 1) * portSpacing
305
+ const startX = (totalWidth - totalSpan) / 2
306
+ return Array.from({ length: count }, (_, i) => startX + i * portSpacing)
307
+ }
279
308
 
280
- // Create ELK node for HA virtual node with all ports from both nodes
281
- const createHAVirtualElkNode = (info: { virtualId: string; nodeA: Node; nodeB: Node; gap: number; widthA: number; widthB: number; height: number }): ElkNode => {
282
- const totalWidth = info.widthA + info.gap + info.widthB
283
- const ports: { id: string; width: number; height: number; labels?: { text: string }[] }[] = []
284
-
285
- // Add all ports from nodeA (with prefix to identify them)
286
- const portsA = nodePorts.get(info.nodeA.id)
287
- if (portsA) {
288
- for (const portName of portsA) {
289
- ports.push({
290
- id: `${info.virtualId}:A:${portName}`,
309
+ // Top ports (incoming)
310
+ const topPorts = Array.from(portInfo.top)
311
+ const topPositions = calcPortPositions(topPorts.length, width)
312
+ topPorts.forEach((portName, i) => {
313
+ elkNode.ports!.push({
314
+ id: `${node.id}:${portName}`,
291
315
  width: PORT_WIDTH,
292
316
  height: PORT_HEIGHT,
317
+ x: topPositions[i] - PORT_WIDTH / 2,
318
+ y: 0,
293
319
  labels: [{ text: portName }],
320
+ layoutOptions: { 'elk.port.side': 'NORTH' },
294
321
  })
295
- }
296
- }
322
+ })
323
+
324
+ // Bottom ports (outgoing)
325
+ const bottomPorts = Array.from(portInfo.bottom)
326
+ const bottomPositions = calcPortPositions(bottomPorts.length, width)
327
+ bottomPorts.forEach((portName, i) => {
328
+ elkNode.ports!.push({
329
+ id: `${node.id}:${portName}`,
330
+ width: PORT_WIDTH,
331
+ height: PORT_HEIGHT,
332
+ x: bottomPositions[i] - PORT_WIDTH / 2,
333
+ y: height - PORT_HEIGHT,
334
+ labels: [{ text: portName }],
335
+ layoutOptions: { 'elk.port.side': 'SOUTH' },
336
+ })
337
+ })
297
338
 
298
- // Add all ports from nodeB (with prefix to identify them)
299
- const portsB = nodePorts.get(info.nodeB.id)
300
- if (portsB) {
301
- for (const portName of portsB) {
302
- ports.push({
303
- id: `${info.virtualId}:B:${portName}`,
339
+ // Left ports (HA)
340
+ const leftPorts = Array.from(portInfo.left)
341
+ const leftPositions = calcPortPositions(leftPorts.length, height)
342
+ leftPorts.forEach((portName, i) => {
343
+ elkNode.ports!.push({
344
+ id: `${node.id}:${portName}`,
304
345
  width: PORT_WIDTH,
305
346
  height: PORT_HEIGHT,
347
+ x: 0,
348
+ y: leftPositions[i] - PORT_HEIGHT / 2,
306
349
  labels: [{ text: portName }],
350
+ layoutOptions: { 'elk.port.side': 'WEST' },
307
351
  })
352
+ })
353
+
354
+ // Right ports (HA)
355
+ const rightPorts = Array.from(portInfo.right)
356
+ const rightPositions = calcPortPositions(rightPorts.length, height)
357
+ rightPorts.forEach((portName, i) => {
358
+ elkNode.ports!.push({
359
+ id: `${node.id}:${portName}`,
360
+ width: PORT_WIDTH,
361
+ height: PORT_HEIGHT,
362
+ x: width - PORT_WIDTH,
363
+ y: rightPositions[i] - PORT_HEIGHT / 2,
364
+ labels: [{ text: portName }],
365
+ layoutOptions: { 'elk.port.side': 'EAST' },
366
+ })
367
+ })
368
+
369
+ elkNode.layoutOptions = {
370
+ 'elk.portConstraints': 'FIXED_POS',
371
+ 'elk.spacing.portPort': String(MIN_PORT_SPACING),
308
372
  }
309
373
  }
310
374
 
311
- // Add fallback ports if no specific ports
312
- if (ports.length === 0) {
313
- ports.push(
314
- { id: `${info.virtualId}:A`, width: PORT_WIDTH, height: PORT_HEIGHT },
315
- { id: `${info.virtualId}:B`, width: PORT_WIDTH, height: PORT_HEIGHT },
316
- )
375
+ return elkNode
376
+ }
377
+
378
+ // Create HA container node
379
+ const createHAContainerNode = (containerId: string, pair: { nodeA: string; nodeB: string }): ElkNode | null => {
380
+ const nodeA = graph.nodes.find(n => n.id === pair.nodeA)
381
+ const nodeB = graph.nodes.find(n => n.id === pair.nodeB)
382
+ if (!nodeA || !nodeB) return null
383
+
384
+ const childA = createElkNode(nodeA)
385
+ const childB = createElkNode(nodeB)
386
+
387
+ // Find HA link
388
+ const haLink = graph.links.find(link => {
389
+ if (!link.redundancy) return false
390
+ const from = toEndpoint(link.from)
391
+ const to = toEndpoint(link.to)
392
+ const key = [from.node, to.node].sort().join(':')
393
+ const pairKey = [pair.nodeA, pair.nodeB].sort().join(':')
394
+ return key === pairKey
395
+ })
396
+
397
+ // Create internal HA edge
398
+ const haEdges: ElkExtendedEdge[] = []
399
+ if (haLink) {
400
+ const from = toEndpoint(haLink.from)
401
+ const to = toEndpoint(haLink.to)
402
+ const fromPortName = from.port || 'ha'
403
+ const toPortName = to.port || 'ha'
404
+ haEdges.push({
405
+ id: haLink.id || `ha-edge-${containerId}`,
406
+ sources: [`${from.node}:${fromPortName}`],
407
+ targets: [`${to.node}:${toPortName}`],
408
+ })
317
409
  }
318
410
 
319
- const elkNode: ElkNode = {
320
- id: info.virtualId,
321
- width: totalWidth,
322
- height: info.height,
323
- labels: [{ text: `${info.nodeA.id} + ${info.nodeB.id}` }],
324
- ports,
325
- layoutOptions: { 'elk.portConstraints': 'FREE' },
411
+ return {
412
+ id: containerId,
413
+ children: [childA, childB],
414
+ edges: haEdges,
415
+ layoutOptions: {
416
+ 'elk.algorithm': 'layered',
417
+ 'elk.direction': 'RIGHT',
418
+ 'elk.spacing.nodeNode': '40',
419
+ 'elk.padding': '[top=0,left=0,bottom=0,right=0]',
420
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
421
+ 'elk.edgeRouting': 'POLYLINE',
422
+ 'org.eclipse.elk.json.edgeCoords': 'ROOT',
423
+ 'org.eclipse.elk.json.shapeCoords': 'ROOT',
424
+ },
326
425
  }
327
- return elkNode
328
426
  }
329
427
 
330
- // Determine which subgraph each HA virtual node belongs to
331
- const virtualNodeParent = new Map<string, string | undefined>()
332
- for (const [virtualId, info] of haVirtualNodes) {
333
- // Use nodeA's parent (both should be in same subgraph for HA to make sense)
334
- virtualNodeParent.set(virtualId, info.nodeA.parent)
335
- }
428
+ // Track added HA containers
429
+ const addedHAContainers = new Set<string>()
336
430
 
337
- // Create ELK nodes recursively for subgraphs
338
- const createSubgraphNode = (subgraph: Subgraph): ElkNode => {
431
+ // Create ELK subgraph node recursively
432
+ const createSubgraphNode = (subgraph: Subgraph, edgesByContainer: Map<string, ElkExtendedEdge[]>): ElkNode => {
339
433
  const childNodes: ElkNode[] = []
340
434
 
341
- // Add child subgraphs
342
- const childSgIds = subgraphChildren.get(subgraph.id) || []
343
- for (const childId of childSgIds) {
344
- const childSg = subgraphMap.get(childId)
345
- if (childSg) {
346
- childNodes.push(createSubgraphNode(childSg))
435
+ for (const childSg of subgraphMap.values()) {
436
+ if (childSg.parent === subgraph.id) {
437
+ childNodes.push(createSubgraphNode(childSg, edgesByContainer))
347
438
  }
348
439
  }
349
440
 
350
- // Add HA virtual nodes that belong to this subgraph
351
- for (const [virtualId, info] of haVirtualNodes) {
352
- if (virtualNodeParent.get(virtualId) === subgraph.id) {
353
- childNodes.push(createHAVirtualElkNode(info))
354
- }
355
- }
356
-
357
- // Add regular nodes in this subgraph (skip nodes in HA pairs)
358
441
  for (const node of graph.nodes) {
359
- if (node.parent === subgraph.id && !nodesInHAPairs.has(node.id)) {
360
- childNodes.push(createElkNode_(node))
442
+ if (node.parent === subgraph.id) {
443
+ const containerId = nodeToHAContainer.get(node.id)
444
+ if (containerId) {
445
+ if (!addedHAContainers.has(containerId)) {
446
+ addedHAContainers.add(containerId)
447
+ const pair = haPairMap.get(containerId)
448
+ if (pair) {
449
+ const containerNode = createHAContainerNode(containerId, pair)
450
+ if (containerNode) childNodes.push(containerNode)
451
+ }
452
+ }
453
+ } else {
454
+ childNodes.push(createElkNode(node))
455
+ }
361
456
  }
362
457
  }
363
458
 
364
459
  const sgPadding = subgraph.style?.padding ?? options.subgraphPadding
365
- const sgNodeSpacing = subgraph.style?.nodeSpacing ?? options.nodeSpacing
366
- const sgRankSpacing = subgraph.style?.rankSpacing ?? options.rankSpacing
460
+ const sgEdges = edgesByContainer.get(subgraph.id) || []
367
461
 
368
462
  return {
369
463
  id: subgraph.id,
370
464
  labels: [{ text: subgraph.label }],
371
465
  children: childNodes,
466
+ edges: sgEdges,
372
467
  layoutOptions: {
373
468
  'elk.padding': `[top=${sgPadding + options.subgraphLabelHeight},left=${sgPadding},bottom=${sgPadding},right=${sgPadding}]`,
374
- 'elk.spacing.nodeNode': String(sgNodeSpacing),
375
- 'elk.layered.spacing.nodeNodeBetweenLayers': String(sgRankSpacing),
376
469
  },
377
470
  }
378
471
  }
379
472
 
380
473
  // Build root children
381
- const rootChildren: ElkNode[] = []
474
+ const buildRootChildren = (edgesByContainer: Map<string, ElkExtendedEdge[]>): ElkNode[] => {
475
+ const children: ElkNode[] = []
382
476
 
383
- // Add root-level subgraphs
384
- for (const sg of subgraphMap.values()) {
385
- if (!sg.parent || !subgraphMap.has(sg.parent)) {
386
- rootChildren.push(createSubgraphNode(sg))
477
+ for (const sg of subgraphMap.values()) {
478
+ if (!sg.parent || !subgraphMap.has(sg.parent)) {
479
+ children.push(createSubgraphNode(sg, edgesByContainer))
480
+ }
387
481
  }
388
- }
389
482
 
390
- // Add root-level HA virtual nodes
391
- for (const [virtualId, info] of haVirtualNodes) {
392
- if (!virtualNodeParent.get(virtualId) || !subgraphMap.has(virtualNodeParent.get(virtualId)!)) {
393
- rootChildren.push(createHAVirtualElkNode(info))
483
+ for (const node of graph.nodes) {
484
+ if (!node.parent || !subgraphMap.has(node.parent)) {
485
+ const containerId = nodeToHAContainer.get(node.id)
486
+ if (containerId) {
487
+ if (!addedHAContainers.has(containerId)) {
488
+ addedHAContainers.add(containerId)
489
+ const pair = haPairMap.get(containerId)
490
+ if (pair) {
491
+ const containerNode = createHAContainerNode(containerId, pair)
492
+ if (containerNode) children.push(containerNode)
493
+ }
494
+ }
495
+ } else {
496
+ children.push(createElkNode(node))
497
+ }
498
+ }
394
499
  }
500
+
501
+ return children
395
502
  }
396
503
 
397
- // Add root-level nodes (skip nodes in HA pairs)
504
+ // Build node to parent map
505
+ const nodeParentMap = new Map<string, string | undefined>()
398
506
  for (const node of graph.nodes) {
399
- if (nodesInHAPairs.has(node.id)) continue
400
- if (!node.parent || !subgraphMap.has(node.parent)) {
401
- rootChildren.push(createElkNode_(node))
402
- }
507
+ nodeParentMap.set(node.id, node.parent)
403
508
  }
404
509
 
405
- // Build edges with redirects for HA nodes
406
- const edges: ElkExtendedEdge[] = graph.links
407
- .filter(link => {
408
- // Skip HA internal links (the link connecting the HA pair nodes themselves)
409
- const fromId = getNodeId(link.from)
410
- const toId = getNodeId(link.to)
411
- for (const pair of haPairs) {
412
- if ((pair.nodeA === fromId && pair.nodeB === toId) ||
413
- (pair.nodeA === toId && pair.nodeB === fromId)) {
414
- return false
415
- }
510
+ // Find LCA (Lowest Common Ancestor) of two nodes
511
+ const findLCA = (nodeA: string, nodeB: string): string | undefined => {
512
+ const ancestorsA = new Set<string | undefined>()
513
+ let current: string | undefined = nodeA
514
+ while (current) {
515
+ ancestorsA.add(current)
516
+ current = nodeParentMap.get(current)
517
+ }
518
+ ancestorsA.add(undefined) // root
519
+
520
+ current = nodeB
521
+ while (current !== undefined) {
522
+ if (ancestorsA.has(current)) {
523
+ return current
416
524
  }
417
- return true
418
- })
419
- .map((link, index) => {
420
- const fromEndpoint = toEndpoint(link.from)
421
- const toEndpoint_ = toEndpoint(link.to)
525
+ current = nodeParentMap.get(current)
526
+ }
527
+ return undefined // root
528
+ }
422
529
 
423
- // Redirect endpoints if they're in HA pairs (preserve port names)
424
- let sourceId: string
425
- let targetId: string
530
+ // Build HA pair set for quick lookup
531
+ const haPairSet = new Set<string>()
532
+ for (const pair of haPairs) {
533
+ haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
534
+ }
426
535
 
427
- const fromRedirect = edgeRedirect.get(fromEndpoint.node)
428
- const toRedirect = edgeRedirect.get(toEndpoint_.node)
536
+ const isHALink = (fromNode: string, toNode: string): boolean => {
537
+ const key = [fromNode, toNode].sort().join(':')
538
+ return haPairSet.has(key)
539
+ }
429
540
 
430
- if (fromRedirect) {
431
- // Redirect to virtual node, preserving port name if any
432
- if (fromEndpoint.port) {
433
- sourceId = `${fromRedirect.virtualId}:${fromRedirect.side}:${fromEndpoint.port}`
434
- } else {
435
- sourceId = `${fromRedirect.virtualId}:${fromRedirect.side}`
436
- }
437
- } else if (fromEndpoint.port) {
438
- sourceId = `${fromEndpoint.node}:${fromEndpoint.port}`
439
- } else {
440
- sourceId = fromEndpoint.node
441
- }
541
+ // Group edges by their LCA container (skip HA links - they're in containers)
542
+ const edgesByContainer = new Map<string, ElkExtendedEdge[]>()
543
+ edgesByContainer.set('root', [])
442
544
 
443
- if (toRedirect) {
444
- // Redirect to virtual node, preserving port name if any
445
- if (toEndpoint_.port) {
446
- targetId = `${toRedirect.virtualId}:${toRedirect.side}:${toEndpoint_.port}`
447
- } else {
448
- targetId = `${toRedirect.virtualId}:${toRedirect.side}`
449
- }
450
- } else if (toEndpoint_.port) {
451
- targetId = `${toEndpoint_.node}:${toEndpoint_.port}`
452
- } else {
453
- targetId = toEndpoint_.node
454
- }
545
+ for (const sg of subgraphMap.values()) {
546
+ edgesByContainer.set(sg.id, [])
547
+ }
455
548
 
456
- const edge: ElkExtendedEdge = {
457
- id: link.id || `edge-${index}`,
458
- sources: [sourceId],
459
- targets: [targetId],
460
- }
549
+ graph.links.forEach((link, index) => {
550
+ const from = toEndpoint(link.from)
551
+ const to = toEndpoint(link.to)
461
552
 
462
- // Build label
463
- const labelParts: string[] = []
464
- if (link.label) {
465
- labelParts.push(Array.isArray(link.label) ? link.label.join(' / ') : link.label)
466
- }
467
- if (fromEndpoint.ip) labelParts.push(fromEndpoint.ip)
468
- if (toEndpoint_.ip) labelParts.push(toEndpoint_.ip)
469
-
470
- if (labelParts.length > 0) {
471
- edge.labels = [{
472
- text: labelParts.join('\n'),
473
- layoutOptions: { 'elk.edgeLabels.placement': 'CENTER' }
474
- }]
475
- }
553
+ // Skip HA links (they're inside HA containers)
554
+ if (link.redundancy && isHALink(from.node, to.node)) {
555
+ return
556
+ }
476
557
 
477
- return edge
478
- })
558
+ const sourceId = from.port ? `${from.node}:${from.port}` : from.node
559
+ const targetId = to.port ? `${to.node}:${to.port}` : to.node
560
+
561
+ const edge: ElkExtendedEdge = {
562
+ id: link.id || `edge-${index}`,
563
+ sources: [sourceId],
564
+ targets: [targetId],
565
+ }
566
+
567
+ // Add label
568
+ const labelParts: string[] = []
569
+ if (link.label) {
570
+ labelParts.push(Array.isArray(link.label) ? link.label.join(' / ') : link.label)
571
+ }
572
+ if (from.ip) labelParts.push(from.ip)
573
+ if (to.ip) labelParts.push(to.ip)
574
+
575
+ if (labelParts.length > 0) {
576
+ edge.labels = [{
577
+ text: labelParts.join('\n'),
578
+ layoutOptions: { 'elk.edgeLabels.placement': 'CENTER' }
579
+ }]
580
+ }
479
581
 
480
- // Root graph
582
+ // Find LCA and place edge in appropriate container
583
+ const lca = findLCA(from.node, to.node)
584
+ let container = lca
585
+ if (container === from.node || container === to.node) {
586
+ container = nodeParentMap.get(container)
587
+ }
588
+
589
+ const containerId = container && subgraphMap.has(container) ? container : 'root'
590
+ edgesByContainer.get(containerId)!.push(edge)
591
+ })
592
+
593
+ // Dynamic edge spacing
594
+ const edgeNodeSpacing = Math.max(10, Math.round(options.nodeSpacing * 0.4))
595
+ const edgeEdgeSpacing = Math.max(8, Math.round(options.nodeSpacing * 0.25))
596
+
597
+ // Root layout options
481
598
  const rootLayoutOptions: LayoutOptions = {
482
599
  'elk.algorithm': 'layered',
483
600
  'elk.direction': elkDirection,
484
601
  'elk.spacing.nodeNode': String(options.nodeSpacing),
485
602
  'elk.layered.spacing.nodeNodeBetweenLayers': String(options.rankSpacing),
486
- 'elk.spacing.edgeNode': '50',
487
- 'elk.spacing.edgeEdge': '20',
603
+ 'elk.spacing.edgeNode': String(edgeNodeSpacing),
604
+ 'elk.spacing.edgeEdge': String(edgeEdgeSpacing),
605
+ 'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
606
+ 'elk.layered.compaction.connectedComponents': 'true',
488
607
  'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
489
608
  'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
490
- 'elk.edgeRouting': 'SPLINES',
609
+ 'elk.edgeRouting': 'ORTHOGONAL',
491
610
  'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
611
+ // Use ROOT coordinate system
612
+ 'org.eclipse.elk.json.edgeCoords': 'ROOT',
613
+ 'org.eclipse.elk.json.shapeCoords': 'ROOT',
492
614
  }
493
615
 
616
+ // Build the graph with edges in correct containers
617
+ const rootChildren = buildRootChildren(edgesByContainer)
618
+ const rootEdges = edgesByContainer.get('root') || []
619
+
494
620
  return {
495
- elkGraph: {
496
- id: 'root',
497
- children: rootChildren,
498
- edges,
499
- layoutOptions: rootLayoutOptions,
500
- },
501
- haVirtualNodes,
621
+ id: 'root',
622
+ children: rootChildren,
623
+ edges: rootEdges,
624
+ layoutOptions: rootLayoutOptions,
502
625
  }
503
626
  }
504
627
 
505
628
  /**
506
- * Extract layout result and expand HA virtual nodes back to original pairs
629
+ * Extract layout result from ELK output - uses ELK's edge routing directly
507
630
  */
508
- private extractLayoutResultWithHAExpand(
631
+ private extractLayoutResult(
509
632
  graph: NetworkGraph,
510
633
  elkGraph: ElkNode,
511
- haVirtualNodes: Map<string, { virtualId: string; nodeA: Node; nodeB: Node; gap: number; widthA: number; widthB: number; height: number }>,
634
+ nodePorts: Map<string, NodePortInfo>,
512
635
  _options: Required<HierarchicalLayoutOptions>
513
636
  ): LayoutResult {
514
637
  const layoutNodes = new Map<string, LayoutNode>()
515
638
  const layoutSubgraphs = new Map<string, LayoutSubgraph>()
516
639
  const layoutLinks = new Map<string, LayoutLink>()
517
640
 
518
- // Build subgraph map for reference
641
+ // Build maps
519
642
  const subgraphMap = new Map<string, Subgraph>()
520
643
  if (graph.subgraphs) {
521
644
  for (const sg of graph.subgraphs) {
@@ -523,286 +646,254 @@ export class HierarchicalLayout {
523
646
  }
524
647
  }
525
648
 
526
- // Extract node and subgraph positions recursively
527
- const processElkNode = (elkNode: ElkNode, offsetX: number, offsetY: number) => {
528
- const x = (elkNode.x || 0) + offsetX
529
- const y = (elkNode.y || 0) + offsetY
649
+ const nodeMap = new Map<string, Node>()
650
+ for (const node of graph.nodes) {
651
+ nodeMap.set(node.id, node)
652
+ }
653
+
654
+ // Process ELK nodes recursively
655
+ // With shapeCoords=ROOT, all coordinates are absolute (no offset needed)
656
+ const processElkNode = (elkNode: ElkNode) => {
657
+ const x = elkNode.x || 0
658
+ const y = elkNode.y || 0
530
659
  const width = elkNode.width || 0
531
660
  const height = elkNode.height || 0
532
661
 
533
- // Check if this is an HA virtual node
534
- const haInfo = haVirtualNodes.get(elkNode.id)
535
- if (haInfo) {
536
- // Expand virtual node back to original pair
537
- // Position nodeA on the left, nodeB on the right
538
- const heightA = this.calculateNodeHeight(haInfo.nodeA)
539
- const heightB = this.calculateNodeHeight(haInfo.nodeB)
540
-
541
- const nodeAX = x + haInfo.widthA / 2
542
- const nodeBX = x + haInfo.widthA + haInfo.gap + haInfo.widthB / 2
543
- const centerY = y + height / 2
544
-
545
- // Create layout nodes for A and B
546
- const layoutNodeA: LayoutNode = {
547
- id: haInfo.nodeA.id,
548
- position: { x: nodeAX, y: centerY },
549
- size: { width: haInfo.widthA, height: heightA },
550
- node: haInfo.nodeA,
551
- }
552
-
553
- const layoutNodeB: LayoutNode = {
554
- id: haInfo.nodeB.id,
555
- position: { x: nodeBX, y: centerY },
556
- size: { width: haInfo.widthB, height: heightB },
557
- node: haInfo.nodeB,
558
- }
559
-
560
- // For HA nodes, calculate port positions ourselves
561
- // We need to determine which ports are for incoming vs outgoing edges
562
- // Build maps of ports for each node (A and B), grouped by edge direction
563
- const portsForATop: { name: string; portW: number; portH: number }[] = []
564
- const portsForABottom: { name: string; portW: number; portH: number }[] = []
565
- const portsForARight: { name: string; portW: number; portH: number }[] = [] // For HA link (facing B)
566
- const portsForBTop: { name: string; portW: number; portH: number }[] = []
567
- const portsForBBottom: { name: string; portW: number; portH: number }[] = []
568
- const portsForBLeft: { name: string; portW: number; portH: number }[] = [] // For HA link (facing A)
569
-
570
- // Analyze links to determine port direction
571
- for (const link of graph.links) {
572
- const fromEndpoint = toEndpoint(link.from)
573
- const toEndpoint_ = toEndpoint(link.to)
574
-
575
- // HA internal link - ports go on inner sides (A's right, B's left)
576
- if (link.redundancy) {
577
- if (fromEndpoint.node === haInfo.nodeA.id && fromEndpoint.port) {
578
- portsForARight.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
579
- }
580
- if (toEndpoint_.node === haInfo.nodeA.id && toEndpoint_.port) {
581
- portsForARight.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
582
- }
583
- if (fromEndpoint.node === haInfo.nodeB.id && fromEndpoint.port) {
584
- portsForBLeft.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
585
- }
586
- if (toEndpoint_.node === haInfo.nodeB.id && toEndpoint_.port) {
587
- portsForBLeft.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
588
- }
589
- continue
590
- }
662
+ if (subgraphMap.has(elkNode.id)) {
663
+ // Subgraph
664
+ const sg = subgraphMap.get(elkNode.id)!
665
+ layoutSubgraphs.set(elkNode.id, {
666
+ id: elkNode.id,
667
+ bounds: { x, y, width, height },
668
+ subgraph: sg,
669
+ })
591
670
 
592
- // Check if this link involves nodeA
593
- if (fromEndpoint.node === haInfo.nodeA.id && fromEndpoint.port) {
594
- // Outgoing from A → bottom
595
- portsForABottom.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
671
+ if (elkNode.children) {
672
+ for (const child of elkNode.children) {
673
+ processElkNode(child)
596
674
  }
597
- if (toEndpoint_.node === haInfo.nodeA.id && toEndpoint_.port) {
598
- // Incoming to A → top
599
- portsForATop.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
675
+ }
676
+ } else if (elkNode.id.startsWith('__ha_container_')) {
677
+ // HA container - process children
678
+ if (elkNode.children) {
679
+ for (const child of elkNode.children) {
680
+ processElkNode(child)
600
681
  }
682
+ }
683
+ } else if (nodeMap.has(elkNode.id)) {
684
+ // Regular node
685
+ const node = nodeMap.get(elkNode.id)!
686
+ const portInfo = nodePorts.get(node.id)
687
+ const nodeHeight = this.calculateNodeHeight(node, portInfo?.all.size || 0)
601
688
 
602
- // Check if this link involves nodeB
603
- if (fromEndpoint.node === haInfo.nodeB.id && fromEndpoint.port) {
604
- // Outgoing from B bottom
605
- portsForBBottom.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
606
- }
607
- if (toEndpoint_.node === haInfo.nodeB.id && toEndpoint_.port) {
608
- // Incoming to B → top
609
- portsForBTop.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
610
- }
689
+ const layoutNode: LayoutNode = {
690
+ id: elkNode.id,
691
+ position: { x: x + width / 2, y: y + nodeHeight / 2 },
692
+ size: { width, height: nodeHeight },
693
+ node,
611
694
  }
612
695
 
613
- // Distribute ports on an edge (top/bottom/left/right) - ports placed OUTSIDE the node
614
- const distributePortsOnEdge = (
615
- portsList: { name: string; portW: number; portH: number }[],
616
- nodeWidth: number,
617
- nodeHeight: number,
618
- nodeId: string,
619
- side: 'top' | 'bottom' | 'left' | 'right'
620
- ): Map<string, { id: string; label: string; position: Position; size: { width: number; height: number }; side: 'top' | 'bottom' | 'left' | 'right' }> => {
621
- const result = new Map<string, { id: string; label: string; position: Position; size: { width: number; height: number }; side: 'top' | 'bottom' | 'left' | 'right' }>()
622
- if (portsList.length === 0) return result
623
-
624
- portsList.forEach((p, i) => {
625
- let relX: number
626
- let relY: number
627
-
628
- if (side === 'top' || side === 'bottom') {
629
- // Horizontal distribution along top/bottom edge
630
- const portSpacing = nodeWidth / (portsList.length + 1)
631
- relX = portSpacing * (i + 1) - nodeWidth / 2
632
- // Place port completely outside the node
633
- relY = side === 'top'
634
- ? -nodeHeight / 2 - p.portH / 2 // Above the node
635
- : nodeHeight / 2 + p.portH / 2 // Below the node
696
+ // Extract port positions from ELK
697
+ if (elkNode.ports && elkNode.ports.length > 0) {
698
+ layoutNode.ports = new Map()
699
+ const nodeCenterX = x + width / 2
700
+ const nodeCenterY = y + nodeHeight / 2
701
+
702
+ for (const elkPort of elkNode.ports) {
703
+ const portX = elkPort.x ?? 0
704
+ const portY = elkPort.y ?? 0
705
+ const portW = elkPort.width ?? PORT_WIDTH
706
+ const portH = elkPort.height ?? PORT_HEIGHT
707
+
708
+ const portCenterX = portX + portW / 2
709
+ const portCenterY = portY + portH / 2
710
+
711
+ const relX = portCenterX - nodeCenterX
712
+ const relY = portCenterY - nodeCenterY
713
+
714
+ // Determine side based on which edge the port is closest to
715
+ // Use node boundaries, not relative distance from center
716
+ const distToTop = Math.abs(portCenterY - y)
717
+ const distToBottom = Math.abs(portCenterY - (y + nodeHeight))
718
+ const distToLeft = Math.abs(portCenterX - x)
719
+ const distToRight = Math.abs(portCenterX - (x + width))
720
+
721
+ const minDist = Math.min(distToTop, distToBottom, distToLeft, distToRight)
722
+ let side: 'top' | 'bottom' | 'left' | 'right' = 'bottom'
723
+ if (minDist === distToTop) {
724
+ side = 'top'
725
+ } else if (minDist === distToBottom) {
726
+ side = 'bottom'
727
+ } else if (minDist === distToLeft) {
728
+ side = 'left'
636
729
  } else {
637
- // Vertical distribution along left/right edge
638
- const portSpacing = nodeHeight / (portsList.length + 1)
639
- relY = portSpacing * (i + 1) - nodeHeight / 2
640
- // Place port completely outside the node
641
- relX = side === 'left'
642
- ? -nodeWidth / 2 - p.portW / 2 // Left of the node
643
- : nodeWidth / 2 + p.portW / 2 // Right of the node
730
+ side = 'right'
644
731
  }
645
732
 
646
- const originalPortId = `${nodeId}:${p.name}`
647
- result.set(originalPortId, {
648
- id: originalPortId,
649
- label: p.name,
733
+ const portName = elkPort.id.includes(':') ? elkPort.id.split(':').slice(1).join(':') : elkPort.id
734
+
735
+ layoutNode.ports.set(elkPort.id, {
736
+ id: elkPort.id,
737
+ label: portName,
650
738
  position: { x: relX, y: relY },
651
- size: { width: p.portW, height: p.portH },
739
+ size: { width: portW, height: portH },
652
740
  side,
653
741
  })
654
- })
655
- return result
742
+ }
656
743
  }
657
744
 
658
- // Create port maps for A and B
659
- // For nodeA (left side of HA pair): ports should be positioned toward the inner side (right)
660
- // For nodeB (right side of HA pair): ports should be positioned toward the inner side (left)
661
- // This reduces line crossings when connecting to centered nodes below/above
662
- const portsMapA = new Map<string, { id: string; label: string; position: Position; size: { width: number; height: number }; side: 'top' | 'bottom' | 'left' | 'right' }>()
663
- // For nodeA: reverse the port order so inner ports (right side) come first
664
- const topPortsA = distributePortsOnEdge([...portsForATop].reverse(), haInfo.widthA, heightA, haInfo.nodeA.id, 'top')
665
- const bottomPortsA = distributePortsOnEdge([...portsForABottom].reverse(), haInfo.widthA, heightA, haInfo.nodeA.id, 'bottom')
666
- const rightPortsA = distributePortsOnEdge(portsForARight, haInfo.widthA, heightA, haInfo.nodeA.id, 'right')
667
- for (const [k, v] of topPortsA) portsMapA.set(k, v)
668
- for (const [k, v] of bottomPortsA) portsMapA.set(k, v)
669
- for (const [k, v] of rightPortsA) portsMapA.set(k, v)
670
-
671
- const portsMapB = new Map<string, { id: string; label: string; position: Position; size: { width: number; height: number }; side: 'top' | 'bottom' | 'left' | 'right' }>()
672
- // For nodeB: keep normal order so inner ports (left side) come first
673
- const topPortsB = distributePortsOnEdge(portsForBTop, haInfo.widthB, heightB, haInfo.nodeB.id, 'top')
674
- const bottomPortsB = distributePortsOnEdge(portsForBBottom, haInfo.widthB, heightB, haInfo.nodeB.id, 'bottom')
675
- const leftPortsB = distributePortsOnEdge(portsForBLeft, haInfo.widthB, heightB, haInfo.nodeB.id, 'left')
676
- for (const [k, v] of topPortsB) portsMapB.set(k, v)
677
- for (const [k, v] of bottomPortsB) portsMapB.set(k, v)
678
- for (const [k, v] of leftPortsB) portsMapB.set(k, v)
679
-
680
- if (portsMapA.size > 0) layoutNodeA.ports = portsMapA
681
- if (portsMapB.size > 0) layoutNodeB.ports = portsMapB
682
-
683
- layoutNodes.set(haInfo.nodeA.id, layoutNodeA)
684
- layoutNodes.set(haInfo.nodeB.id, layoutNodeB)
685
- } else if (subgraphMap.has(elkNode.id)) {
686
- // This is a subgraph
687
- const sg = subgraphMap.get(elkNode.id)!
688
- layoutSubgraphs.set(elkNode.id, {
689
- id: elkNode.id,
690
- bounds: { x, y, width, height },
691
- subgraph: sg,
692
- })
745
+ layoutNodes.set(elkNode.id, layoutNode)
746
+ }
747
+ }
693
748
 
694
- // Process children
695
- if (elkNode.children) {
696
- for (const child of elkNode.children) {
697
- processElkNode(child, x, y)
698
- }
699
- }
700
- } else {
701
- // This is a regular node
702
- const node = graph.nodes.find(n => n.id === elkNode.id)
703
- if (node) {
704
- const layoutNode: LayoutNode = {
705
- id: elkNode.id,
706
- position: { x: x + width / 2, y: y + height / 2 },
707
- size: { width, height },
708
- node,
709
- }
749
+ // Process root children (coordinates are absolute with shapeCoords=ROOT)
750
+ if (elkGraph.children) {
751
+ for (const child of elkGraph.children) {
752
+ processElkNode(child)
753
+ }
754
+ }
710
755
 
711
- // Extract port positions if any
712
- // Note: Ports will be repositioned by reorderPortsByConnectedNodePositions later
713
- if (elkNode.ports && elkNode.ports.length > 0) {
714
- layoutNode.ports = new Map()
715
- for (const elkPort of elkNode.ports) {
716
- const portX = (elkPort.x ?? 0)
717
- const portY = (elkPort.y ?? 0)
718
- const portW = elkPort.width ?? PORT_WIDTH
719
- const portH = elkPort.height ?? PORT_HEIGHT
720
-
721
- // Determine which side based on ELK position
722
- let side: 'top' | 'bottom' | 'left' | 'right' = 'bottom'
723
- if (portY <= portH) side = 'top'
724
- else if (portY >= height - portH * 2) side = 'bottom'
725
- else if (portX <= portW) side = 'left'
726
- else if (portX >= width - portW * 2) side = 'right'
727
-
728
- const portName = elkPort.id.includes(':')
729
- ? elkPort.id.split(':').slice(1).join(':')
730
- : elkPort.id
731
-
732
- // Calculate position outside the node edge
733
- let relX: number
734
- let relY: number
735
- if (side === 'top') {
736
- relX = portX - width / 2 + portW / 2
737
- relY = -height / 2 - portH / 2
738
- } else if (side === 'bottom') {
739
- relX = portX - width / 2 + portW / 2
740
- relY = height / 2 + portH / 2
741
- } else if (side === 'left') {
742
- relX = -width / 2 - portW / 2
743
- relY = portY - height / 2 + portH / 2
744
- } else {
745
- relX = width / 2 + portW / 2
746
- relY = portY - height / 2 + portH / 2
756
+ // Build link map for ID matching
757
+ const linkById = new Map<string, { link: typeof graph.links[0]; index: number }>()
758
+ graph.links.forEach((link, index) => {
759
+ linkById.set(link.id || `edge-${index}`, { link, index })
760
+ })
761
+
762
+ // Track processed edges to prevent duplicates
763
+ const processedEdgeIds = new Set<string>()
764
+
765
+ // Check if container is an HA container
766
+ const isHAContainer = (id: string) => id.startsWith('__ha_container_')
767
+
768
+ // Process edges from a container
769
+ // With edgeCoords=ROOT, all edge coordinates are absolute (no offset needed)
770
+ const processEdgesInContainer = (container: ElkNode) => {
771
+ const elkEdges = container.edges as ElkEdgeWithSections[] | undefined
772
+ if (elkEdges) {
773
+ for (const elkEdge of elkEdges) {
774
+ // Skip if already processed
775
+ if (processedEdgeIds.has(elkEdge.id)) continue
776
+ processedEdgeIds.add(elkEdge.id)
777
+
778
+ const entry = linkById.get(elkEdge.id)
779
+ if (!entry) continue
780
+
781
+ const { link, index } = entry
782
+ const id = link.id || `link-${index}`
783
+ const fromEndpoint = toEndpoint(link.from)
784
+ const toEndpoint_ = toEndpoint(link.to)
785
+
786
+ const fromNode = layoutNodes.get(fromEndpoint.node)
787
+ const toNode = layoutNodes.get(toEndpoint_.node)
788
+ if (!fromNode || !toNode) continue
789
+
790
+ let points: Position[] = []
791
+
792
+ // HA edges inside HA containers: use ELK's edge routing directly
793
+ if (isHAContainer(container.id) && elkEdge.sections && elkEdge.sections.length > 0) {
794
+ const section = elkEdge.sections[0]
795
+ points.push({ x: section.startPoint.x, y: section.startPoint.y })
796
+ if (section.bendPoints) {
797
+ for (const bp of section.bendPoints) {
798
+ points.push({ x: bp.x, y: bp.y })
799
+ }
800
+ }
801
+ points.push({ x: section.endPoint.x, y: section.endPoint.y })
802
+ } else if (!isHAContainer(container.id)) {
803
+ // Normal vertical edges
804
+ const fromBottomY = fromNode.position.y + fromNode.size.height / 2
805
+ const toTopY = toNode.position.y - toNode.size.height / 2
806
+
807
+ if (elkEdge.sections && elkEdge.sections.length > 0) {
808
+ const section = elkEdge.sections[0]
809
+
810
+ points.push({
811
+ x: section.startPoint.x,
812
+ y: fromBottomY,
813
+ })
814
+
815
+ if (section.bendPoints) {
816
+ for (const bp of section.bendPoints) {
817
+ points.push({ x: bp.x, y: bp.y })
818
+ }
747
819
  }
748
820
 
749
- layoutNode.ports.set(elkPort.id, {
750
- id: elkPort.id,
751
- label: portName,
752
- position: { x: relX, y: relY },
753
- size: { width: portW, height: portH },
754
- side,
821
+ points.push({
822
+ x: section.endPoint.x,
823
+ y: toTopY,
755
824
  })
825
+ } else {
826
+ points = this.generateOrthogonalPath(
827
+ { x: fromNode.position.x, y: fromBottomY },
828
+ { x: toNode.position.x, y: toTopY }
829
+ )
756
830
  }
831
+ } else {
832
+ // HA edge fallback: simple horizontal line
833
+ const leftNode = fromNode.position.x < toNode.position.x ? fromNode : toNode
834
+ const rightNode = fromNode.position.x < toNode.position.x ? toNode : fromNode
835
+ const y = (leftNode.position.y + rightNode.position.y) / 2
836
+ points = [
837
+ { x: leftNode.position.x + leftNode.size.width / 2, y },
838
+ { x: rightNode.position.x - rightNode.size.width / 2, y },
839
+ ]
757
840
  }
758
841
 
759
- layoutNodes.set(elkNode.id, layoutNode)
842
+ layoutLinks.set(id, {
843
+ id,
844
+ from: fromEndpoint.node,
845
+ to: toEndpoint_.node,
846
+ fromEndpoint,
847
+ toEndpoint: toEndpoint_,
848
+ points,
849
+ link,
850
+ })
760
851
  }
761
852
  }
762
- }
763
853
 
764
- // Process root children
765
- if (elkGraph.children) {
766
- for (const child of elkGraph.children) {
767
- processElkNode(child, 0, 0)
854
+ // Recursively process child containers (subgraphs and HA containers)
855
+ if (container.children) {
856
+ for (const child of container.children) {
857
+ if (subgraphMap.has(child.id) || child.id.startsWith('__ha_container_')) {
858
+ processEdgesInContainer(child)
859
+ }
860
+ }
768
861
  }
769
862
  }
770
863
 
771
- // Post-process: reorder ports based on connected node positions to minimize crossings
772
- this.reorderPortsByConnectedNodePositions(graph, layoutNodes)
864
+ // Process all edges (coordinates are absolute with edgeCoords=ROOT)
865
+ processEdgesInContainer(elkGraph)
773
866
 
774
- // Calculate edges based on extracted node positions
867
+ // Fallback for any missing links
775
868
  graph.links.forEach((link, index) => {
776
869
  const id = link.id || `link-${index}`
777
- const fromEndpoint_ = toEndpoint(link.from)
778
- const toEndpoint_ = toEndpoint(link.to)
779
- const fromId = fromEndpoint_.node
780
- const toId = toEndpoint_.node
781
- const fromNode = layoutNodes.get(fromId)
782
- const toNode = layoutNodes.get(toId)
870
+ if (layoutLinks.has(id)) return
783
871
 
872
+ const fromEndpoint = toEndpoint(link.from)
873
+ const toEndpoint_ = toEndpoint(link.to)
874
+ const fromNode = layoutNodes.get(fromEndpoint.node)
875
+ const toNode = layoutNodes.get(toEndpoint_.node)
784
876
  if (!fromNode || !toNode) return
785
877
 
786
- // Get port positions if specified
787
- const fromPortId = fromEndpoint_.port ? `${fromId}:${fromEndpoint_.port}` : undefined
788
- const toPortId = toEndpoint_.port ? `${toId}:${toEndpoint_.port}` : undefined
789
- const fromPort = fromPortId ? fromNode.ports?.get(fromPortId) : undefined
790
- const toPort = toPortId ? toNode.ports?.get(toPortId) : undefined
791
-
792
- const points = this.calculateEdgePathWithPorts(fromNode, toNode, fromPort, toPort)
878
+ const startY = fromNode.position.y + fromNode.size.height / 2
879
+ const endY = toNode.position.y - toNode.size.height / 2
880
+ const points = this.generateOrthogonalPath(
881
+ { x: fromNode.position.x, y: startY },
882
+ { x: toNode.position.x, y: endY }
883
+ )
793
884
 
794
885
  layoutLinks.set(id, {
795
886
  id,
796
- from: fromId,
797
- to: toId,
798
- fromEndpoint: fromEndpoint_,
887
+ from: fromEndpoint.node,
888
+ to: toEndpoint_.node,
889
+ fromEndpoint,
799
890
  toEndpoint: toEndpoint_,
800
891
  points,
801
892
  link,
802
893
  })
803
894
  })
804
895
 
805
- // Calculate total bounds
896
+ // Calculate bounds
806
897
  const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs)
807
898
 
808
899
  return {
@@ -813,128 +904,15 @@ export class HierarchicalLayout {
813
904
  }
814
905
  }
815
906
 
816
- /**
817
- * Reorder ports on each node based on connected node positions to minimize edge crossings
818
- */
819
- private reorderPortsByConnectedNodePositions(
820
- graph: NetworkGraph,
821
- layoutNodes: Map<string, LayoutNode>
822
- ): void {
823
- // Build a map of port -> connected node position
824
- // For each port, find the node it connects to and get that node's position
825
- const portConnections = new Map<string, { connectedNodeId: string; isOutgoing: boolean }>()
826
-
827
- for (const link of graph.links) {
828
- const fromEndpoint = toEndpoint(link.from)
829
- const toEndpoint_ = toEndpoint(link.to)
830
-
831
- // From port connects to "to" node
832
- if (fromEndpoint.port) {
833
- const portId = `${fromEndpoint.node}:${fromEndpoint.port}`
834
- portConnections.set(portId, { connectedNodeId: toEndpoint_.node, isOutgoing: true })
835
- }
836
-
837
- // To port connects to "from" node
838
- if (toEndpoint_.port) {
839
- const portId = `${toEndpoint_.node}:${toEndpoint_.port}`
840
- portConnections.set(portId, { connectedNodeId: fromEndpoint.node, isOutgoing: false })
841
- }
842
- }
843
-
844
- // For each node, reorder its ports based on connected node positions
845
- layoutNodes.forEach((layoutNode) => {
846
- if (!layoutNode.ports || layoutNode.ports.size === 0) return
847
-
848
- // Group ports by side
849
- const portsBySide = new Map<string, { portId: string; port: typeof layoutNode.ports extends Map<string, infer V> ? V : never }[]>()
850
-
851
- layoutNode.ports.forEach((port, portId) => {
852
- const side = port.side
853
- if (!portsBySide.has(side)) {
854
- portsBySide.set(side, [])
855
- }
856
- portsBySide.get(side)!.push({ portId, port })
857
- })
858
-
859
- // Sort and redistribute ports on each side
860
- portsBySide.forEach((ports, side) => {
861
- if (ports.length <= 1) return // No need to reorder single port
862
-
863
- // Sort ports by connected node position
864
- ports.sort((a, b) => {
865
- const connA = portConnections.get(a.portId)
866
- const connB = portConnections.get(b.portId)
867
- if (!connA || !connB) return 0
868
-
869
- const nodeA = layoutNodes.get(connA.connectedNodeId)
870
- const nodeB = layoutNodes.get(connB.connectedNodeId)
871
- if (!nodeA || !nodeB) return 0
872
-
873
- // For top/bottom sides, sort by X position (left to right)
874
- if (side === 'top' || side === 'bottom') {
875
- return nodeA.position.x - nodeB.position.x
876
- }
877
- // For left/right sides, sort by Y position (top to bottom)
878
- return nodeA.position.y - nodeB.position.y
879
- })
880
-
881
- // Redistribute port positions along the edge (ports placed OUTSIDE the node)
882
- const nodeWidth = layoutNode.size.width
883
- const nodeHeight = layoutNode.size.height
884
-
885
- if (side === 'top' || side === 'bottom') {
886
- const portSpacing = nodeWidth / (ports.length + 1)
887
- ports.forEach((p, i) => {
888
- const relX = portSpacing * (i + 1) - nodeWidth / 2
889
- // Place port completely outside the node edge
890
- const relY = side === 'top'
891
- ? -nodeHeight / 2 - p.port.size.height / 2 // Above the node
892
- : nodeHeight / 2 + p.port.size.height / 2 // Below the node
893
- p.port.position = { x: relX, y: relY }
894
- })
895
- } else {
896
- const portSpacing = nodeHeight / (ports.length + 1)
897
- ports.forEach((p, i) => {
898
- const relY = portSpacing * (i + 1) - nodeHeight / 2
899
- // Place port completely outside the node edge
900
- const relX = side === 'left'
901
- ? -nodeWidth / 2 - p.port.size.width / 2 // Left of the node
902
- : nodeWidth / 2 + p.port.size.width / 2 // Right of the node
903
- p.port.position = { x: relX, y: relY }
904
- })
905
- }
906
- })
907
- })
908
- }
909
-
910
- // Synchronous wrapper that runs async internally
907
+ // Synchronous wrapper
911
908
  layout(graph: NetworkGraph): LayoutResult {
912
- // For sync compatibility, we need to run layout synchronously
913
- // This is a simplified version - ideally use layoutAsync for full HA support
914
- const effectiveOptions = this.getEffectiveOptions(graph)
915
- const direction = effectiveOptions.direction
916
- const haPairs = this.detectHAPairs(graph)
909
+ const options = this.getEffectiveOptions(graph)
910
+ const result = this.calculateFallbackLayout(graph, options.direction)
917
911
 
918
- // Build ELK graph with HA merge (same as async)
919
- const { elkGraph, haVirtualNodes } = this.buildElkGraphWithHAMerge(
920
- graph, direction, effectiveOptions, haPairs
921
- )
922
-
923
- // Run synchronous layout using elk's sync API
924
- let result: LayoutResult
925
-
926
- // Use a workaround: pre-calculate positions based on structure
927
- result = this.calculateFallbackLayout(graph, direction)
928
-
929
- // Start async layout and update when done (for future renders)
930
- this.elk.layout(elkGraph).then((layoutedGraph: ElkNode) => {
931
- // This will be available for next render
932
- Object.assign(result, this.extractLayoutResultWithHAExpand(
933
- graph, layoutedGraph, haVirtualNodes, effectiveOptions
934
- ))
935
- }).catch(() => {
936
- // Keep fallback layout
937
- })
912
+ // Start async layout
913
+ this.layoutAsync(graph).then((asyncResult) => {
914
+ Object.assign(result, asyncResult)
915
+ }).catch(() => {})
938
916
 
939
917
  return result
940
918
  }
@@ -949,189 +927,209 @@ export class HierarchicalLayout {
949
927
  }
950
928
  }
951
929
 
952
- private calculateNodeHeight(node: Node): number {
930
+ private calculateNodeHeight(node: Node, portCount: number = 0): number {
953
931
  const lines = Array.isArray(node.label) ? node.label.length : 1
954
- const baseHeight = 40
955
- const lineHeight = 16
932
+ const labelHeight = lines * LABEL_LINE_HEIGHT
933
+
934
+ const labels = Array.isArray(node.label) ? node.label : [node.label]
935
+ const maxLabelLength = Math.max(...labels.map(l => l.length))
936
+ const labelWidth = maxLabelLength * ESTIMATED_CHAR_WIDTH
937
+ const portWidth = portCount > 0 ? (portCount + 1) * MIN_PORT_SPACING : 0
938
+ const baseContentWidth = Math.max(labelWidth, portWidth)
939
+ const baseNodeWidth = Math.max(this.options.nodeWidth, baseContentWidth + NODE_HORIZONTAL_PADDING)
940
+ const maxIconWidth = Math.round(baseNodeWidth * MAX_ICON_WIDTH_RATIO)
956
941
 
957
- // Calculate actual icon height based on vendor icon or default
958
942
  let iconHeight = 0
959
943
  const iconKey = node.service || node.model
960
944
  if (node.vendor && iconKey) {
961
945
  const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
962
946
  if (iconEntry) {
963
- const defaultIconSize = 40
964
- const iconPadding = 16
965
- const maxIconWidth = this.options.nodeWidth - iconPadding
966
-
967
947
  const vendorIcon = iconEntry.default
968
948
  const viewBox = iconEntry.viewBox || '0 0 48 48'
969
949
 
970
- // Check for PNG-based icons with embedded viewBox
971
950
  if (vendorIcon.startsWith('<svg')) {
972
951
  const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
973
952
  if (viewBoxMatch) {
974
953
  const vbWidth = parseInt(viewBoxMatch[1])
975
954
  const vbHeight = parseInt(viewBoxMatch[2])
976
955
  const aspectRatio = vbWidth / vbHeight
977
- let calcHeight = defaultIconSize
978
- if (Math.round(defaultIconSize * aspectRatio) > maxIconWidth) {
979
- calcHeight = Math.round(maxIconWidth / aspectRatio)
956
+ let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
957
+ iconHeight = DEFAULT_ICON_SIZE
958
+ if (iconWidth > maxIconWidth) {
959
+ iconHeight = Math.round(maxIconWidth / aspectRatio)
980
960
  }
981
- iconHeight = calcHeight
982
961
  } else {
983
- iconHeight = defaultIconSize
962
+ iconHeight = DEFAULT_ICON_SIZE
984
963
  }
985
964
  } else {
986
- // Parse viewBox for aspect ratio
987
965
  const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
988
966
  if (vbMatch) {
989
967
  const vbWidth = parseInt(vbMatch[3])
990
968
  const vbHeight = parseInt(vbMatch[4])
991
969
  const aspectRatio = vbWidth / vbHeight
992
- let calcHeight = defaultIconSize
993
- if (Math.round(defaultIconSize * aspectRatio) > maxIconWidth) {
994
- calcHeight = Math.round(maxIconWidth / aspectRatio)
970
+ let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
971
+ iconHeight = DEFAULT_ICON_SIZE
972
+ if (iconWidth > maxIconWidth) {
973
+ iconHeight = Math.round(maxIconWidth / aspectRatio)
995
974
  }
996
- iconHeight = calcHeight
997
975
  } else {
998
- iconHeight = defaultIconSize
976
+ iconHeight = DEFAULT_ICON_SIZE
999
977
  }
1000
978
  }
1001
979
  }
1002
- } else if (node.type) {
1003
- // Default device type icon (fixed size)
1004
- iconHeight = 36
1005
980
  }
1006
981
 
1007
- return Math.max(this.options.nodeHeight, baseHeight + (lines - 1) * lineHeight + iconHeight)
982
+ if (iconHeight === 0 && node.type && getDeviceIcon(node.type)) {
983
+ iconHeight = DEFAULT_ICON_SIZE
984
+ }
985
+
986
+ const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
987
+ const contentHeight = iconHeight + gap + labelHeight
988
+ return Math.max(this.options.nodeHeight, contentHeight + NODE_VERTICAL_PADDING)
1008
989
  }
1009
990
 
1010
- /**
1011
- * Calculate edge path using port positions when available
1012
- * Port positions are from ELK - lines connect FROM the ports
1013
- */
1014
- private calculateEdgePathWithPorts(
1015
- fromNode: LayoutNode,
1016
- toNode: LayoutNode,
1017
- fromPort?: { position: Position; side: string },
1018
- toPort?: { position: Position; side: string }
1019
- ): Position[] {
1020
- // Use port positions from ELK (lines come FROM ports)
1021
- let fromPoint: Position
1022
- let fromSide: string
1023
-
1024
- if (fromPort) {
1025
- // Line starts from port position
1026
- fromPoint = {
1027
- x: fromNode.position.x + fromPort.position.x,
1028
- y: fromNode.position.y + fromPort.position.y,
1029
- }
1030
- fromSide = fromPort.side
1031
- } else {
1032
- // No port - calculate edge position based on target node direction
1033
- const dx = toNode.position.x - fromNode.position.x
1034
- const dy = toNode.position.y - fromNode.position.y
1035
- const halfW = fromNode.size.width / 2
1036
- const halfH = fromNode.size.height / 2
1037
-
1038
- if (Math.abs(dx) > Math.abs(dy) * 0.5) {
1039
- fromSide = dx > 0 ? 'right' : 'left'
1040
- fromPoint = {
1041
- x: fromNode.position.x + (dx > 0 ? halfW : -halfW),
1042
- y: fromNode.position.y,
1043
- }
1044
- } else {
1045
- fromSide = dy > 0 ? 'bottom' : 'top'
1046
- fromPoint = {
1047
- x: fromNode.position.x,
1048
- y: fromNode.position.y + (dy > 0 ? halfH : -halfH),
1049
- }
1050
- }
991
+ private calculatePortSpacing(portNames: Set<string> | undefined): number {
992
+ if (!portNames || portNames.size === 0) return MIN_PORT_SPACING
993
+
994
+ let maxLabelLength = 0
995
+ for (const name of portNames) {
996
+ maxLabelLength = Math.max(maxLabelLength, name.length)
1051
997
  }
1052
998
 
1053
- let toPoint: Position
1054
- let toSide: string
999
+ const charWidth = PORT_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO
1000
+ const maxLabelWidth = maxLabelLength * charWidth
1001
+ const spacingFromLabel = maxLabelWidth + PORT_LABEL_PADDING
1002
+ return Math.max(MIN_PORT_SPACING, spacingFromLabel)
1003
+ }
1055
1004
 
1056
- if (toPort) {
1057
- // Line ends at port position
1058
- toPoint = {
1059
- x: toNode.position.x + toPort.position.x,
1060
- y: toNode.position.y + toPort.position.y,
1061
- }
1062
- toSide = toPort.side
1063
- } else {
1064
- // No port - calculate edge position based on source node direction
1065
- const dx = fromNode.position.x - toNode.position.x
1066
- const dy = fromNode.position.y - toNode.position.y
1067
- const halfW = toNode.size.width / 2
1068
- const halfH = toNode.size.height / 2
1069
-
1070
- if (Math.abs(dx) > Math.abs(dy) * 0.5) {
1071
- toSide = dx > 0 ? 'right' : 'left'
1072
- toPoint = {
1073
- x: toNode.position.x + (dx > 0 ? halfW : -halfW),
1074
- y: toNode.position.y,
1075
- }
1076
- } else {
1077
- toSide = dy > 0 ? 'bottom' : 'top'
1078
- toPoint = {
1079
- x: toNode.position.x,
1080
- y: toNode.position.y + (dy > 0 ? halfH : -halfH),
1005
+ private calculateNodeWidth(node: Node, portInfo: NodePortInfo | undefined): number {
1006
+ const labels = Array.isArray(node.label) ? node.label : [node.label]
1007
+ const maxLabelLength = Math.max(...labels.map(l => l.length))
1008
+ const labelWidth = maxLabelLength * ESTIMATED_CHAR_WIDTH
1009
+
1010
+ const topCount = portInfo?.top.size || 0
1011
+ const bottomCount = portInfo?.bottom.size || 0
1012
+ const maxPortsPerSide = Math.max(topCount, bottomCount)
1013
+
1014
+ const portSpacing = this.calculatePortSpacing(portInfo?.all)
1015
+ const edgeMargin = Math.round(MIN_PORT_SPACING / 2)
1016
+ const portWidth = maxPortsPerSide > 0 ? (maxPortsPerSide - 1) * portSpacing + edgeMargin * 2 : 0
1017
+
1018
+ const paddedContentWidth = Math.max(labelWidth, 0) + NODE_HORIZONTAL_PADDING
1019
+ const baseNodeWidth = Math.max(paddedContentWidth, portWidth)
1020
+
1021
+ const maxIconWidth = Math.round(baseNodeWidth * MAX_ICON_WIDTH_RATIO)
1022
+ let iconWidth = DEFAULT_ICON_SIZE
1023
+ const iconKey = node.service || node.model
1024
+ if (node.vendor && iconKey) {
1025
+ const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
1026
+ if (iconEntry) {
1027
+ const vendorIcon = iconEntry.default
1028
+ const viewBox = iconEntry.viewBox || '0 0 48 48'
1029
+
1030
+ if (vendorIcon.startsWith('<svg')) {
1031
+ const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
1032
+ if (viewBoxMatch) {
1033
+ const vbWidth = parseInt(viewBoxMatch[1])
1034
+ const vbHeight = parseInt(viewBoxMatch[2])
1035
+ const aspectRatio = vbWidth / vbHeight
1036
+ iconWidth = Math.min(Math.round(DEFAULT_ICON_SIZE * aspectRatio), maxIconWidth)
1037
+ }
1038
+ } else {
1039
+ const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
1040
+ if (vbMatch) {
1041
+ const vbWidth = parseInt(vbMatch[3])
1042
+ const vbHeight = parseInt(vbMatch[4])
1043
+ const aspectRatio = vbWidth / vbHeight
1044
+ iconWidth = Math.min(Math.round(DEFAULT_ICON_SIZE * aspectRatio), maxIconWidth)
1045
+ }
1081
1046
  }
1082
1047
  }
1083
1048
  }
1084
1049
 
1085
- // Calculate bezier control points based on port sides
1086
- const dx = toPoint.x - fromPoint.x
1087
- const dy = toPoint.y - fromPoint.y
1088
- const dist = Math.sqrt(dx * dx + dy * dy)
1089
- const controlDist = Math.min(dist * 0.4, 80)
1050
+ const paddedIconLabelWidth = Math.max(iconWidth, labelWidth) + NODE_HORIZONTAL_PADDING
1051
+ return Math.max(paddedIconLabelWidth, portWidth)
1052
+ }
1090
1053
 
1091
- const fromControl = this.getControlPointForSide(fromPoint, fromSide, controlDist)
1092
- const toControl = this.getControlPointForSide(toPoint, toSide, controlDist)
1054
+ private calculateTotalBounds(
1055
+ nodes: Map<string, LayoutNode>,
1056
+ subgraphs: Map<string, LayoutSubgraph>
1057
+ ): Bounds {
1058
+ let minX = Infinity, minY = Infinity
1059
+ let maxX = -Infinity, maxY = -Infinity
1093
1060
 
1094
- return [fromPoint, fromControl, toControl, toPoint]
1095
- }
1061
+ nodes.forEach((node) => {
1062
+ let left = node.position.x - node.size.width / 2
1063
+ let right = node.position.x + node.size.width / 2
1064
+ let top = node.position.y - node.size.height / 2
1065
+ let bottom = node.position.y + node.size.height / 2
1066
+
1067
+ if (node.ports) {
1068
+ node.ports.forEach((port) => {
1069
+ const portX = node.position.x + port.position.x
1070
+ const portY = node.position.y + port.position.y
1071
+ left = Math.min(left, portX - port.size.width / 2)
1072
+ right = Math.max(right, portX + port.size.width / 2)
1073
+ top = Math.min(top, portY - port.size.height / 2)
1074
+ bottom = Math.max(bottom, portY + port.size.height / 2)
1075
+ })
1076
+ }
1096
1077
 
1097
- /**
1098
- * Get control point position based on port side
1099
- */
1100
- private getControlPointForSide(point: Position, side: string, dist: number): Position {
1101
- switch (side) {
1102
- case 'top':
1103
- return { x: point.x, y: point.y - Math.abs(dist) }
1104
- case 'bottom':
1105
- return { x: point.x, y: point.y + Math.abs(dist) }
1106
- case 'left':
1107
- return { x: point.x - Math.abs(dist), y: point.y }
1108
- case 'right':
1109
- return { x: point.x + Math.abs(dist), y: point.y }
1110
- default:
1111
- return { x: point.x, y: point.y + dist }
1078
+ minX = Math.min(minX, left)
1079
+ minY = Math.min(minY, top)
1080
+ maxX = Math.max(maxX, right)
1081
+ maxY = Math.max(maxY, bottom)
1082
+ })
1083
+
1084
+ subgraphs.forEach((sg) => {
1085
+ minX = Math.min(minX, sg.bounds.x)
1086
+ minY = Math.min(minY, sg.bounds.y)
1087
+ maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width)
1088
+ maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height)
1089
+ })
1090
+
1091
+ const padding = 50
1092
+
1093
+ if (minX === Infinity) {
1094
+ return { x: 0, y: 0, width: 400, height: 300 }
1095
+ }
1096
+
1097
+ return {
1098
+ x: minX - padding,
1099
+ y: minY - padding,
1100
+ width: maxX - minX + padding * 2,
1101
+ height: maxY - minY + padding * 2,
1112
1102
  }
1113
1103
  }
1114
1104
 
1115
1105
  private calculateFallbackLayout(graph: NetworkGraph, _direction: LayoutDirection): LayoutResult {
1116
- // Simple fallback layout when async isn't available
1117
1106
  const layoutNodes = new Map<string, LayoutNode>()
1118
1107
  const layoutSubgraphs = new Map<string, LayoutSubgraph>()
1119
1108
  const layoutLinks = new Map<string, LayoutLink>()
1120
1109
 
1121
- // Simple grid layout for nodes
1122
- let x = 100
1123
- let y = 100
1124
- const rowHeight = this.options.nodeHeight + this.options.rankSpacing
1125
- const colWidth = this.options.nodeWidth + this.options.nodeSpacing
1126
- let col = 0
1110
+ // Detect HA pairs for port assignment
1111
+ const haPairs = this.detectHAPairs(graph)
1112
+ const haPairSet = new Set<string>()
1113
+ for (const pair of haPairs) {
1114
+ haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
1115
+ }
1116
+ const nodePorts = collectNodePorts(graph, haPairSet)
1117
+
1118
+ let x = 100, y = 100, col = 0
1127
1119
  const maxCols = 4
1120
+ const rowHeight = this.options.nodeHeight + this.options.rankSpacing
1128
1121
 
1129
1122
  for (const node of graph.nodes) {
1130
- const height = this.calculateNodeHeight(node)
1123
+ const portInfo = nodePorts.get(node.id)
1124
+ const portCount = portInfo?.all.size || 0
1125
+ const height = this.calculateNodeHeight(node, portCount)
1126
+ const width = this.calculateNodeWidth(node, portInfo)
1127
+ const colWidth = width + this.options.nodeSpacing
1128
+
1131
1129
  layoutNodes.set(node.id, {
1132
1130
  id: node.id,
1133
- position: { x: x + this.options.nodeWidth / 2, y: y + height / 2 },
1134
- size: { width: this.options.nodeWidth, height },
1131
+ position: { x: x + width / 2, y: y + height / 2 },
1132
+ size: { width, height },
1135
1133
  node,
1136
1134
  })
1137
1135
 
@@ -1145,7 +1143,6 @@ export class HierarchicalLayout {
1145
1143
  }
1146
1144
  }
1147
1145
 
1148
- // Simple links
1149
1146
  graph.links.forEach((link, index) => {
1150
1147
  const fromId = getNodeId(link.from)
1151
1148
  const toId = getNodeId(link.to)
@@ -1175,368 +1172,49 @@ export class HierarchicalLayout {
1175
1172
  }
1176
1173
  }
1177
1174
 
1178
- private calculateTotalBounds(
1179
- nodes: Map<string, LayoutNode>,
1180
- subgraphs: Map<string, LayoutSubgraph>
1181
- ): Bounds {
1182
- let minX = Infinity, minY = Infinity
1183
- let maxX = -Infinity, maxY = -Infinity
1184
-
1185
- nodes.forEach((node) => {
1186
- minX = Math.min(minX, node.position.x - node.size.width / 2)
1187
- minY = Math.min(minY, node.position.y - node.size.height / 2)
1188
- maxX = Math.max(maxX, node.position.x + node.size.width / 2)
1189
- maxY = Math.max(maxY, node.position.y + node.size.height / 2)
1190
- })
1191
-
1192
- subgraphs.forEach((sg) => {
1193
- minX = Math.min(minX, sg.bounds.x)
1194
- minY = Math.min(minY, sg.bounds.y)
1195
- maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width)
1196
- maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height)
1197
- })
1198
-
1199
- const padding = 50
1200
-
1201
- if (minX === Infinity) {
1202
- return { x: 0, y: 0, width: 400, height: 300 }
1203
- }
1204
-
1205
- return {
1206
- x: minX - padding,
1207
- y: minY - padding,
1208
- width: maxX - minX + padding * 2,
1209
- height: maxY - minY + padding * 2,
1210
- }
1211
- }
1212
-
1213
- /**
1214
- * Detect redundancy pairs based on link.redundancy property
1215
- * Returns array of [nodeA, nodeB] pairs that should be placed on the same layer
1216
- */
1217
- private detectHAPairs(graph: NetworkGraph): { nodeA: string; nodeB: string; minLength?: number }[] {
1218
- const pairs: { nodeA: string; nodeB: string; minLength?: number }[] = []
1219
- const processedPairs = new Set<string>()
1175
+ /** Detect HA pairs from redundancy links */
1176
+ private detectHAPairs(graph: NetworkGraph): { nodeA: string; nodeB: string }[] {
1177
+ const pairs: { nodeA: string; nodeB: string }[] = []
1178
+ const processed = new Set<string>()
1220
1179
 
1221
1180
  for (const link of graph.links) {
1222
- // Only process links with redundancy property set
1223
1181
  if (!link.redundancy) continue
1224
1182
 
1225
1183
  const fromId = getNodeId(link.from)
1226
1184
  const toId = getNodeId(link.to)
1227
- const pairKey = [fromId, toId].sort().join(':')
1228
- if (processedPairs.has(pairKey)) continue
1185
+ const key = [fromId, toId].sort().join(':')
1186
+ if (processed.has(key)) continue
1229
1187
 
1230
- pairs.push({
1231
- nodeA: fromId,
1232
- nodeB: toId,
1233
- minLength: link.style?.minLength,
1234
- })
1235
- processedPairs.add(pairKey)
1188
+ pairs.push({ nodeA: fromId, nodeB: toId })
1189
+ processed.add(key)
1236
1190
  }
1237
1191
 
1238
1192
  return pairs
1239
1193
  }
1240
1194
 
1241
- /**
1242
- * Adjust node distances based on link minLength property
1243
- */
1244
- private adjustLinkDistances(
1245
- result: LayoutResult,
1246
- graph: NetworkGraph,
1247
- direction: LayoutDirection
1248
- ): void {
1249
- const isVertical = direction === 'TB' || direction === 'BT'
1250
- let adjusted = false
1251
-
1252
- for (const link of graph.links) {
1253
- const minLength = link.style?.minLength
1254
- if (!minLength) continue
1255
-
1256
- // Skip HA links (already handled by two-pass layout with layerChoiceConstraint)
1257
- if (link.redundancy) continue
1258
-
1259
- const fromId = getNodeId(link.from)
1260
- const toId = getNodeId(link.to)
1261
- const fromNode = result.nodes.get(fromId)
1262
- const toNode = result.nodes.get(toId)
1263
-
1264
- if (!fromNode || !toNode) continue
1265
-
1266
- // Calculate current distance
1267
- const dx = toNode.position.x - fromNode.position.x
1268
- const dy = toNode.position.y - fromNode.position.y
1269
- const currentDist = Math.sqrt(dx * dx + dy * dy)
1195
+ /** Generate orthogonal path between two points */
1196
+ private generateOrthogonalPath(start: Position, end: Position): Position[] {
1197
+ const dx = end.x - start.x
1198
+ const dy = end.y - start.y
1270
1199
 
1271
- if (currentDist >= minLength) continue
1272
-
1273
- // Need to increase distance
1274
- const scale = minLength / currentDist
1275
-
1276
- if (isVertical) {
1277
- // For TB/BT layout, primarily adjust Y (move target node down/up)
1278
- const newDy = dy * scale
1279
- const deltaY = newDy - dy
1280
- toNode.position.y += deltaY
1281
- } else {
1282
- // For LR/RL layout, primarily adjust X
1283
- const newDx = dx * scale
1284
- const deltaX = newDx - dx
1285
- toNode.position.x += deltaX
1286
- }
1287
-
1288
- adjusted = true
1289
- }
1290
-
1291
- if (adjusted) {
1292
- this.recalculateAllEdges(result)
1293
- }
1294
- }
1295
-
1296
- /**
1297
- * Calculate edge path with offset for multiple connections
1298
- */
1299
- private calculateEdgePathWithOffset(
1300
- fromNode: LayoutNode,
1301
- toNode: LayoutNode,
1302
- outIndex: number,
1303
- outTotal: number,
1304
- inIndex: number,
1305
- inTotal: number
1306
- ): Position[] {
1307
- const fromPos = fromNode.position
1308
- const toPos = toNode.position
1309
- const fromSize = fromNode.size
1310
- const toSize = toNode.size
1311
-
1312
- // Calculate direction from source to target
1313
- const dx = toPos.x - fromPos.x
1314
- const dy = toPos.y - fromPos.y
1315
-
1316
- // Calculate offset for distributing multiple connections
1317
- const portSpacing = 15 // pixels between port connections
1318
-
1319
- let fromPoint: Position
1320
- let toPoint: Position
1321
- let controlDist: number
1322
-
1323
- // For vertical layouts (TB), prefer top/bottom connections
1324
- if (Math.abs(dy) > Math.abs(dx) * 0.3) {
1325
- // Primarily vertical
1326
- // Calculate horizontal offset for multiple outgoing/incoming links
1327
- const outOffset = outTotal > 1 ? (outIndex - (outTotal - 1) / 2) * portSpacing : 0
1328
- const inOffset = inTotal > 1 ? (inIndex - (inTotal - 1) / 2) * portSpacing : 0
1329
-
1330
- if (dy > 0) {
1331
- fromPoint = { x: fromPos.x + outOffset, y: fromPos.y + fromSize.height / 2 }
1332
- toPoint = { x: toPos.x + inOffset, y: toPos.y - toSize.height / 2 }
1333
- } else {
1334
- fromPoint = { x: fromPos.x + outOffset, y: fromPos.y - fromSize.height / 2 }
1335
- toPoint = { x: toPos.x + inOffset, y: toPos.y + toSize.height / 2 }
1336
- }
1337
- controlDist = Math.min(Math.abs(toPoint.y - fromPoint.y) * 0.4, 80)
1338
-
1339
- return [
1340
- fromPoint,
1341
- { x: fromPoint.x, y: fromPoint.y + Math.sign(dy) * controlDist },
1342
- { x: toPoint.x, y: toPoint.y - Math.sign(dy) * controlDist },
1343
- toPoint,
1344
- ]
1345
- } else {
1346
- // Primarily horizontal
1347
- // Calculate vertical offset for multiple outgoing/incoming links
1348
- const outOffset = outTotal > 1 ? (outIndex - (outTotal - 1) / 2) * portSpacing : 0
1349
- const inOffset = inTotal > 1 ? (inIndex - (inTotal - 1) / 2) * portSpacing : 0
1350
-
1351
- if (dx > 0) {
1352
- fromPoint = { x: fromPos.x + fromSize.width / 2, y: fromPos.y + outOffset }
1353
- toPoint = { x: toPos.x - toSize.width / 2, y: toPos.y + inOffset }
1354
- } else {
1355
- fromPoint = { x: fromPos.x - fromSize.width / 2, y: fromPos.y + outOffset }
1356
- toPoint = { x: toPos.x + toSize.width / 2, y: toPos.y + inOffset }
1357
- }
1358
- controlDist = Math.min(Math.abs(toPoint.x - fromPoint.x) * 0.4, 80)
1359
-
1360
- return [
1361
- fromPoint,
1362
- { x: fromPoint.x + Math.sign(dx) * controlDist, y: fromPoint.y },
1363
- { x: toPoint.x - Math.sign(dx) * controlDist, y: toPoint.y },
1364
- toPoint,
1365
- ]
1200
+ // If points are nearly aligned, use direct line
1201
+ if (Math.abs(dx) < 5) {
1202
+ return [start, end]
1366
1203
  }
1367
- }
1368
-
1369
- /**
1370
- * Recalculate all edge paths with port offset distribution
1371
- */
1372
- private recalculateAllEdges(result: LayoutResult): void {
1373
- // Group links by source and target nodes to calculate offsets
1374
- const outgoingLinks = new Map<string, string[]>() // nodeId -> [linkIds]
1375
- const incomingLinks = new Map<string, string[]>() // nodeId -> [linkIds]
1376
-
1377
- result.links.forEach((layoutLink, linkId) => {
1378
- // Outgoing from source
1379
- if (!outgoingLinks.has(layoutLink.from)) {
1380
- outgoingLinks.set(layoutLink.from, [])
1381
- }
1382
- outgoingLinks.get(layoutLink.from)!.push(linkId)
1383
-
1384
- // Incoming to target
1385
- if (!incomingLinks.has(layoutLink.to)) {
1386
- incomingLinks.set(layoutLink.to, [])
1387
- }
1388
- incomingLinks.get(layoutLink.to)!.push(linkId)
1389
- })
1390
-
1391
- // Sort outgoing links by target X position to reduce crossings
1392
- outgoingLinks.forEach((linkIds, _nodeId) => {
1393
- linkIds.sort((a, b) => {
1394
- const linkA = result.links.get(a)
1395
- const linkB = result.links.get(b)
1396
- if (!linkA || !linkB) return 0
1397
- const targetA = result.nodes.get(linkA.to)
1398
- const targetB = result.nodes.get(linkB.to)
1399
- if (!targetA || !targetB) return 0
1400
- return targetA.position.x - targetB.position.x
1401
- })
1402
- })
1403
-
1404
- // Sort incoming links by source X position to reduce crossings
1405
- incomingLinks.forEach((linkIds, _nodeId) => {
1406
- linkIds.sort((a, b) => {
1407
- const linkA = result.links.get(a)
1408
- const linkB = result.links.get(b)
1409
- if (!linkA || !linkB) return 0
1410
- const sourceA = result.nodes.get(linkA.from)
1411
- const sourceB = result.nodes.get(linkB.from)
1412
- if (!sourceA || !sourceB) return 0
1413
- return sourceA.position.x - sourceB.position.x
1414
- })
1415
- })
1416
-
1417
- // Calculate edge paths with offset - prefer port positions when available
1418
- result.links.forEach((layoutLink, linkId) => {
1419
- const fromNode = result.nodes.get(layoutLink.from)
1420
- const toNode = result.nodes.get(layoutLink.to)
1421
-
1422
- if (fromNode && toNode) {
1423
- // Check if this link uses ports
1424
- const fromPortId = layoutLink.fromEndpoint.port
1425
- ? `${layoutLink.from}:${layoutLink.fromEndpoint.port}`
1426
- : undefined
1427
- const toPortId = layoutLink.toEndpoint.port
1428
- ? `${layoutLink.to}:${layoutLink.toEndpoint.port}`
1429
- : undefined
1430
- const fromPort = fromPortId ? fromNode.ports?.get(fromPortId) : undefined
1431
- const toPort = toPortId ? toNode.ports?.get(toPortId) : undefined
1432
-
1433
- if (fromPort || toPort) {
1434
- // Use port-aware edge calculation
1435
- layoutLink.points = this.calculateEdgePathWithPorts(fromNode, toNode, fromPort, toPort)
1436
- } else {
1437
- // No ports - use offset-based calculation
1438
- const outLinks = outgoingLinks.get(layoutLink.from) || []
1439
- const inLinks = incomingLinks.get(layoutLink.to) || []
1440
- const outIndex = outLinks.indexOf(linkId)
1441
- const inIndex = inLinks.indexOf(linkId)
1442
-
1443
- layoutLink.points = this.calculateEdgePathWithOffset(
1444
- fromNode, toNode,
1445
- outIndex, outLinks.length,
1446
- inIndex, inLinks.length
1447
- )
1448
- }
1449
- }
1450
- })
1451
- }
1452
-
1453
- /**
1454
- * Recalculate subgraph bounds to contain all child nodes after adjustments
1455
- */
1456
- private recalculateSubgraphBounds(result: LayoutResult, graph: NetworkGraph): void {
1457
- if (!graph.subgraphs) return
1458
-
1459
- const padding = this.options.subgraphPadding
1460
- const labelHeight = this.options.subgraphLabelHeight
1461
-
1462
- // Build node-to-subgraph mapping
1463
- const nodeToSubgraph = new Map<string, string>()
1464
- for (const node of graph.nodes) {
1465
- if (node.parent) {
1466
- nodeToSubgraph.set(node.id, node.parent)
1467
- }
1204
+ if (Math.abs(dy) < 5) {
1205
+ return [start, end]
1468
1206
  }
1469
1207
 
1470
- // Recalculate bounds for each subgraph
1471
- result.subgraphs.forEach((layoutSubgraph, subgraphId) => {
1472
- const childNodes: LayoutNode[] = []
1208
+ // Use midpoint for orthogonal routing
1209
+ const midY = start.y + dy / 2
1473
1210
 
1474
- // Find all nodes that belong to this subgraph
1475
- result.nodes.forEach((layoutNode, nodeId) => {
1476
- if (nodeToSubgraph.get(nodeId) === subgraphId) {
1477
- childNodes.push(layoutNode)
1478
- }
1479
- })
1480
-
1481
- if (childNodes.length === 0) return
1482
-
1483
- // Calculate bounding box of all child nodes
1484
- let minX = Infinity, minY = Infinity
1485
- let maxX = -Infinity, maxY = -Infinity
1486
-
1487
- for (const node of childNodes) {
1488
- const left = node.position.x - node.size.width / 2
1489
- const right = node.position.x + node.size.width / 2
1490
- const top = node.position.y - node.size.height / 2
1491
- const bottom = node.position.y + node.size.height / 2
1492
-
1493
- minX = Math.min(minX, left)
1494
- minY = Math.min(minY, top)
1495
- maxX = Math.max(maxX, right)
1496
- maxY = Math.max(maxY, bottom)
1497
- }
1498
-
1499
- // Apply padding
1500
- layoutSubgraph.bounds = {
1501
- x: minX - padding,
1502
- y: minY - padding - labelHeight,
1503
- width: (maxX - minX) + padding * 2,
1504
- height: (maxY - minY) + padding * 2 + labelHeight,
1505
- }
1506
- })
1507
-
1508
- // Update overall bounds
1509
- let minX = Infinity, minY = Infinity
1510
- let maxX = -Infinity, maxY = -Infinity
1511
-
1512
- result.nodes.forEach((node) => {
1513
- const left = node.position.x - node.size.width / 2
1514
- const right = node.position.x + node.size.width / 2
1515
- const top = node.position.y - node.size.height / 2
1516
- const bottom = node.position.y + node.size.height / 2
1517
-
1518
- minX = Math.min(minX, left)
1519
- minY = Math.min(minY, top)
1520
- maxX = Math.max(maxX, right)
1521
- maxY = Math.max(maxY, bottom)
1522
- })
1523
-
1524
- result.subgraphs.forEach((sg) => {
1525
- minX = Math.min(minX, sg.bounds.x)
1526
- minY = Math.min(minY, sg.bounds.y)
1527
- maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width)
1528
- maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height)
1529
- })
1530
-
1531
- const margin = 40
1532
- result.bounds = {
1533
- x: minX - margin,
1534
- y: minY - margin,
1535
- width: (maxX - minX) + margin * 2,
1536
- height: (maxY - minY) + margin * 2,
1537
- }
1211
+ return [
1212
+ start,
1213
+ { x: start.x, y: midY },
1214
+ { x: end.x, y: midY },
1215
+ end,
1216
+ ]
1538
1217
  }
1539
-
1540
1218
  }
1541
1219
 
1542
1220
  // Default instance