@shumoku/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/dist/icons/build-icons.d.ts +6 -0
  2. package/dist/icons/build-icons.d.ts.map +1 -0
  3. package/dist/icons/build-icons.js +163 -0
  4. package/dist/icons/build-icons.js.map +1 -0
  5. package/dist/icons/generated-icons.d.ts +32 -0
  6. package/dist/icons/generated-icons.d.ts.map +1 -0
  7. package/dist/icons/generated-icons.js +88 -0
  8. package/dist/icons/generated-icons.js.map +1 -0
  9. package/dist/icons/index.d.ts +2 -0
  10. package/dist/icons/index.d.ts.map +1 -0
  11. package/dist/icons/index.js +2 -0
  12. package/dist/icons/index.js.map +1 -0
  13. package/dist/index.d.ts +10 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +16 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/layout/hierarchical.d.ts +73 -0
  18. package/dist/layout/hierarchical.d.ts.map +1 -0
  19. package/dist/layout/hierarchical.js +1320 -0
  20. package/dist/layout/hierarchical.js.map +1 -0
  21. package/dist/layout/index.d.ts +6 -0
  22. package/dist/layout/index.d.ts.map +1 -0
  23. package/dist/layout/index.js +5 -0
  24. package/dist/layout/index.js.map +1 -0
  25. package/dist/models/index.d.ts +5 -0
  26. package/dist/models/index.d.ts.map +1 -0
  27. package/dist/models/index.js +5 -0
  28. package/dist/models/index.js.map +1 -0
  29. package/dist/models/types.d.ts +381 -0
  30. package/dist/models/types.d.ts.map +1 -0
  31. package/dist/models/types.js +61 -0
  32. package/dist/models/types.js.map +1 -0
  33. package/dist/renderer/components/index.d.ts +8 -0
  34. package/dist/renderer/components/index.d.ts.map +1 -0
  35. package/dist/renderer/components/index.js +8 -0
  36. package/dist/renderer/components/index.js.map +1 -0
  37. package/dist/renderer/components/link-renderer.d.ts +11 -0
  38. package/dist/renderer/components/link-renderer.d.ts.map +1 -0
  39. package/dist/renderer/components/link-renderer.js +340 -0
  40. package/dist/renderer/components/link-renderer.js.map +1 -0
  41. package/dist/renderer/components/node-renderer.d.ts +14 -0
  42. package/dist/renderer/components/node-renderer.d.ts.map +1 -0
  43. package/dist/renderer/components/node-renderer.js +242 -0
  44. package/dist/renderer/components/node-renderer.js.map +1 -0
  45. package/dist/renderer/components/port-renderer.d.ts +8 -0
  46. package/dist/renderer/components/port-renderer.d.ts.map +1 -0
  47. package/dist/renderer/components/port-renderer.js +85 -0
  48. package/dist/renderer/components/port-renderer.js.map +1 -0
  49. package/dist/renderer/components/subgraph-renderer.d.ts +13 -0
  50. package/dist/renderer/components/subgraph-renderer.d.ts.map +1 -0
  51. package/dist/renderer/components/subgraph-renderer.js +85 -0
  52. package/dist/renderer/components/subgraph-renderer.js.map +1 -0
  53. package/dist/renderer/icon-registry/index.d.ts +6 -0
  54. package/dist/renderer/icon-registry/index.d.ts.map +1 -0
  55. package/dist/renderer/icon-registry/index.js +5 -0
  56. package/dist/renderer/icon-registry/index.js.map +1 -0
  57. package/dist/renderer/icon-registry/registry.d.ts +25 -0
  58. package/dist/renderer/icon-registry/registry.d.ts.map +1 -0
  59. package/dist/renderer/icon-registry/registry.js +85 -0
  60. package/dist/renderer/icon-registry/registry.js.map +1 -0
  61. package/dist/renderer/icon-registry/types.d.ts +44 -0
  62. package/dist/renderer/icon-registry/types.d.ts.map +1 -0
  63. package/dist/renderer/icon-registry/types.js +5 -0
  64. package/dist/renderer/icon-registry/types.js.map +1 -0
  65. package/dist/renderer/index.d.ts +6 -0
  66. package/dist/renderer/index.d.ts.map +1 -0
  67. package/dist/renderer/index.js +5 -0
  68. package/dist/renderer/index.js.map +1 -0
  69. package/dist/renderer/render-model/builder.d.ts +43 -0
  70. package/dist/renderer/render-model/builder.d.ts.map +1 -0
  71. package/dist/renderer/render-model/builder.js +646 -0
  72. package/dist/renderer/render-model/builder.js.map +1 -0
  73. package/dist/renderer/render-model/index.d.ts +6 -0
  74. package/dist/renderer/render-model/index.d.ts.map +1 -0
  75. package/dist/renderer/render-model/index.js +5 -0
  76. package/dist/renderer/render-model/index.js.map +1 -0
  77. package/dist/renderer/render-model/types.d.ts +216 -0
  78. package/dist/renderer/render-model/types.d.ts.map +1 -0
  79. package/dist/renderer/render-model/types.js +6 -0
  80. package/dist/renderer/render-model/types.js.map +1 -0
  81. package/dist/renderer/renderer-types.d.ts +55 -0
  82. package/dist/renderer/renderer-types.d.ts.map +1 -0
  83. package/dist/renderer/renderer-types.js +5 -0
  84. package/dist/renderer/renderer-types.js.map +1 -0
  85. package/dist/renderer/svg-builder.d.ts +152 -0
  86. package/dist/renderer/svg-builder.d.ts.map +1 -0
  87. package/dist/renderer/svg-builder.js +176 -0
  88. package/dist/renderer/svg-builder.js.map +1 -0
  89. package/dist/renderer/svg-dom/builders/defs.d.ts +10 -0
  90. package/dist/renderer/svg-dom/builders/defs.d.ts.map +1 -0
  91. package/dist/renderer/svg-dom/builders/defs.js +82 -0
  92. package/dist/renderer/svg-dom/builders/defs.js.map +1 -0
  93. package/dist/renderer/svg-dom/builders/index.d.ts +9 -0
  94. package/dist/renderer/svg-dom/builders/index.d.ts.map +1 -0
  95. package/dist/renderer/svg-dom/builders/index.js +9 -0
  96. package/dist/renderer/svg-dom/builders/index.js.map +1 -0
  97. package/dist/renderer/svg-dom/builders/link.d.ts +18 -0
  98. package/dist/renderer/svg-dom/builders/link.d.ts.map +1 -0
  99. package/dist/renderer/svg-dom/builders/link.js +188 -0
  100. package/dist/renderer/svg-dom/builders/link.js.map +1 -0
  101. package/dist/renderer/svg-dom/builders/node.d.ts +15 -0
  102. package/dist/renderer/svg-dom/builders/node.d.ts.map +1 -0
  103. package/dist/renderer/svg-dom/builders/node.js +262 -0
  104. package/dist/renderer/svg-dom/builders/node.js.map +1 -0
  105. package/dist/renderer/svg-dom/builders/subgraph.d.ts +14 -0
  106. package/dist/renderer/svg-dom/builders/subgraph.d.ts.map +1 -0
  107. package/dist/renderer/svg-dom/builders/subgraph.js +63 -0
  108. package/dist/renderer/svg-dom/builders/subgraph.js.map +1 -0
  109. package/dist/renderer/svg-dom/builders/utils.d.ts +40 -0
  110. package/dist/renderer/svg-dom/builders/utils.d.ts.map +1 -0
  111. package/dist/renderer/svg-dom/builders/utils.js +79 -0
  112. package/dist/renderer/svg-dom/builders/utils.js.map +1 -0
  113. package/dist/renderer/svg-dom/index.d.ts +9 -0
  114. package/dist/renderer/svg-dom/index.d.ts.map +1 -0
  115. package/dist/renderer/svg-dom/index.js +7 -0
  116. package/dist/renderer/svg-dom/index.js.map +1 -0
  117. package/dist/renderer/svg-dom/interaction.d.ts +69 -0
  118. package/dist/renderer/svg-dom/interaction.d.ts.map +1 -0
  119. package/dist/renderer/svg-dom/interaction.js +296 -0
  120. package/dist/renderer/svg-dom/interaction.js.map +1 -0
  121. package/dist/renderer/svg-dom/renderer.d.ts +47 -0
  122. package/dist/renderer/svg-dom/renderer.d.ts.map +1 -0
  123. package/dist/renderer/svg-dom/renderer.js +188 -0
  124. package/dist/renderer/svg-dom/renderer.js.map +1 -0
  125. package/dist/renderer/svg-string/builders/defs.d.ts +10 -0
  126. package/dist/renderer/svg-string/builders/defs.d.ts.map +1 -0
  127. package/dist/renderer/svg-string/builders/defs.js +43 -0
  128. package/dist/renderer/svg-string/builders/defs.js.map +1 -0
  129. package/dist/renderer/svg-string/builders/link.d.ts +10 -0
  130. package/dist/renderer/svg-string/builders/link.d.ts.map +1 -0
  131. package/dist/renderer/svg-string/builders/link.js +149 -0
  132. package/dist/renderer/svg-string/builders/link.js.map +1 -0
  133. package/dist/renderer/svg-string/builders/node.d.ts +10 -0
  134. package/dist/renderer/svg-string/builders/node.d.ts.map +1 -0
  135. package/dist/renderer/svg-string/builders/node.js +134 -0
  136. package/dist/renderer/svg-string/builders/node.js.map +1 -0
  137. package/dist/renderer/svg-string/builders/subgraph.d.ts +10 -0
  138. package/dist/renderer/svg-string/builders/subgraph.d.ts.map +1 -0
  139. package/dist/renderer/svg-string/builders/subgraph.js +59 -0
  140. package/dist/renderer/svg-string/builders/subgraph.js.map +1 -0
  141. package/dist/renderer/svg-string/index.d.ts +5 -0
  142. package/dist/renderer/svg-string/index.d.ts.map +1 -0
  143. package/dist/renderer/svg-string/index.js +5 -0
  144. package/dist/renderer/svg-string/index.js.map +1 -0
  145. package/dist/renderer/svg-string/renderer.d.ts +17 -0
  146. package/dist/renderer/svg-string/renderer.d.ts.map +1 -0
  147. package/dist/renderer/svg-string/renderer.js +53 -0
  148. package/dist/renderer/svg-string/renderer.js.map +1 -0
  149. package/dist/renderer/svg.d.ts +105 -0
  150. package/dist/renderer/svg.d.ts.map +1 -0
  151. package/dist/renderer/svg.js +804 -0
  152. package/dist/renderer/svg.js.map +1 -0
  153. package/dist/renderer/text-measurer/browser-measurer.d.ts +25 -0
  154. package/dist/renderer/text-measurer/browser-measurer.d.ts.map +1 -0
  155. package/dist/renderer/text-measurer/browser-measurer.js +85 -0
  156. package/dist/renderer/text-measurer/browser-measurer.js.map +1 -0
  157. package/dist/renderer/text-measurer/fallback-measurer.d.ts +22 -0
  158. package/dist/renderer/text-measurer/fallback-measurer.d.ts.map +1 -0
  159. package/dist/renderer/text-measurer/fallback-measurer.js +113 -0
  160. package/dist/renderer/text-measurer/fallback-measurer.js.map +1 -0
  161. package/dist/renderer/text-measurer/index.d.ts +13 -0
  162. package/dist/renderer/text-measurer/index.d.ts.map +1 -0
  163. package/dist/renderer/text-measurer/index.js +35 -0
  164. package/dist/renderer/text-measurer/index.js.map +1 -0
  165. package/dist/renderer/text-measurer/types.d.ts +30 -0
  166. package/dist/renderer/text-measurer/types.d.ts.map +1 -0
  167. package/dist/renderer/text-measurer/types.js +5 -0
  168. package/dist/renderer/text-measurer/types.js.map +1 -0
  169. package/dist/renderer/theme.d.ts +29 -0
  170. package/dist/renderer/theme.d.ts.map +1 -0
  171. package/dist/renderer/theme.js +80 -0
  172. package/dist/renderer/theme.js.map +1 -0
  173. package/dist/themes/dark.d.ts +6 -0
  174. package/dist/themes/dark.d.ts.map +1 -0
  175. package/dist/themes/dark.js +96 -0
  176. package/dist/themes/dark.js.map +1 -0
  177. package/dist/themes/index.d.ts +13 -0
  178. package/dist/themes/index.d.ts.map +1 -0
  179. package/dist/themes/index.js +15 -0
  180. package/dist/themes/index.js.map +1 -0
  181. package/dist/themes/modern.d.ts +6 -0
  182. package/dist/themes/modern.d.ts.map +1 -0
  183. package/dist/themes/modern.js +164 -0
  184. package/dist/themes/modern.js.map +1 -0
  185. package/dist/themes/types.d.ts +234 -0
  186. package/dist/themes/types.d.ts.map +1 -0
  187. package/dist/themes/types.js +5 -0
  188. package/dist/themes/types.js.map +1 -0
  189. package/dist/themes/utils.d.ts +21 -0
  190. package/dist/themes/utils.d.ts.map +1 -0
  191. package/dist/themes/utils.js +124 -0
  192. package/dist/themes/utils.js.map +1 -0
  193. package/package.json +92 -0
  194. package/src/icons/build-icons.ts +189 -0
  195. package/src/icons/default/access-point.svg +3 -0
  196. package/src/icons/default/cloud.svg +3 -0
  197. package/src/icons/default/database.svg +3 -0
  198. package/src/icons/default/firewall.svg +4 -0
  199. package/src/icons/default/generic.svg +3 -0
  200. package/src/icons/default/internet.svg +3 -0
  201. package/src/icons/default/l2-switch.svg +3 -0
  202. package/src/icons/default/l3-switch.svg +3 -0
  203. package/src/icons/default/load-balancer.svg +3 -0
  204. package/src/icons/default/router.svg +3 -0
  205. package/src/icons/default/server.svg +3 -0
  206. package/src/icons/default/vpn.svg +3 -0
  207. package/src/icons/generated-icons.ts +111 -0
  208. package/src/icons/index.ts +1 -0
  209. package/src/index.ts +21 -0
  210. package/src/layout/hierarchical.ts +1543 -0
  211. package/src/layout/index.ts +6 -0
  212. package/src/models/index.ts +5 -0
  213. package/src/models/types.ts +528 -0
  214. package/src/renderer/index.ts +6 -0
  215. package/src/renderer/svg.ts +997 -0
  216. package/src/themes/dark.ts +110 -0
  217. package/src/themes/index.ts +24 -0
  218. package/src/themes/modern.ts +186 -0
  219. package/src/themes/types.ts +262 -0
  220. package/src/themes/utils.ts +143 -0
@@ -0,0 +1,1543 @@
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()