@shumoku/core 0.1.1 → 0.2.1

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