@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,1320 @@
1
+ /**
2
+ * Hierarchical Layout Engine
3
+ * Uses ELK.js for advanced graph layout with proper edge routing
4
+ */
5
+ import ELK from 'elkjs/lib/elk.bundled.js';
6
+ import { getNodeId, } from '../models/index.js';
7
+ import { getVendorIconEntry } from '../icons/index.js';
8
+ // ============================================
9
+ // Helper Functions
10
+ // ============================================
11
+ /**
12
+ * Convert endpoint to full LinkEndpoint object
13
+ */
14
+ function toEndpoint(endpoint) {
15
+ if (typeof endpoint === 'string') {
16
+ return { node: endpoint };
17
+ }
18
+ return endpoint;
19
+ }
20
+ /**
21
+ * Collect all ports for each node from links
22
+ * @param haNodePairs - Set of "nodeA:nodeB" strings for HA pairs to exclude their ports
23
+ */
24
+ function collectNodePorts(graph, haNodePairs) {
25
+ const nodePorts = new Map();
26
+ for (const link of graph.links) {
27
+ const fromEndpoint = toEndpoint(link.from);
28
+ const toEndpoint_ = toEndpoint(link.to);
29
+ // Skip ports for HA links - they will be handled separately
30
+ const pairKey = `${fromEndpoint.node}:${toEndpoint_.node}`;
31
+ if (haNodePairs?.has(pairKey)) {
32
+ continue;
33
+ }
34
+ if (fromEndpoint.port) {
35
+ if (!nodePorts.has(fromEndpoint.node)) {
36
+ nodePorts.set(fromEndpoint.node, new Set());
37
+ }
38
+ nodePorts.get(fromEndpoint.node).add(fromEndpoint.port);
39
+ }
40
+ if (toEndpoint_.port) {
41
+ if (!nodePorts.has(toEndpoint_.node)) {
42
+ nodePorts.set(toEndpoint_.node, new Set());
43
+ }
44
+ nodePorts.get(toEndpoint_.node).add(toEndpoint_.port);
45
+ }
46
+ }
47
+ return nodePorts;
48
+ }
49
+ /** Port size constants */
50
+ const PORT_WIDTH = 8;
51
+ const PORT_HEIGHT = 8;
52
+ const DEFAULT_OPTIONS = {
53
+ direction: 'TB',
54
+ nodeWidth: 180,
55
+ nodeHeight: 60,
56
+ nodeSpacing: 120, // Horizontal spacing between nodes
57
+ rankSpacing: 180, // Vertical spacing between layers
58
+ subgraphPadding: 60, // Padding inside subgraphs
59
+ subgraphLabelHeight: 28,
60
+ };
61
+ // ============================================
62
+ // Layout Engine
63
+ // ============================================
64
+ export class HierarchicalLayout {
65
+ options;
66
+ elk;
67
+ constructor(options) {
68
+ this.options = { ...DEFAULT_OPTIONS, ...options };
69
+ this.elk = new ELK();
70
+ }
71
+ /**
72
+ * Get effective options by merging graph settings with defaults
73
+ */
74
+ getEffectiveOptions(graph) {
75
+ const settings = graph.settings;
76
+ return {
77
+ ...this.options,
78
+ direction: settings?.direction || this.options.direction,
79
+ nodeSpacing: settings?.nodeSpacing || this.options.nodeSpacing,
80
+ rankSpacing: settings?.rankSpacing || this.options.rankSpacing,
81
+ subgraphPadding: settings?.subgraphPadding || this.options.subgraphPadding,
82
+ };
83
+ }
84
+ async layoutAsync(graph) {
85
+ const startTime = performance.now();
86
+ // Merge graph settings with default options
87
+ const effectiveOptions = this.getEffectiveOptions(graph);
88
+ const direction = effectiveOptions.direction;
89
+ // Detect HA pairs first (before building ELK graph)
90
+ const haPairs = this.detectHAPairs(graph);
91
+ // Build ELK graph with HA pairs merged into virtual nodes
92
+ const { elkGraph, haVirtualNodes } = this.buildElkGraphWithHAMerge(graph, direction, effectiveOptions, haPairs);
93
+ // Run ELK layout
94
+ const layoutedGraph = await this.elk.layout(elkGraph);
95
+ // Extract results and expand HA virtual nodes back to original pairs
96
+ const result = this.extractLayoutResultWithHAExpand(graph, layoutedGraph, haVirtualNodes, effectiveOptions);
97
+ // Adjust node distances based on link minLength (for non-HA links)
98
+ this.adjustLinkDistances(result, graph, direction);
99
+ // Recalculate subgraph bounds after node adjustments
100
+ this.recalculateSubgraphBounds(result, graph);
101
+ result.metadata = {
102
+ algorithm: 'elk-layered-ha-merge',
103
+ duration: performance.now() - startTime,
104
+ };
105
+ return result;
106
+ }
107
+ /**
108
+ * Build ELK graph with HA pairs merged into single virtual nodes
109
+ */
110
+ buildElkGraphWithHAMerge(graph, direction, options, haPairs) {
111
+ const elkDirection = this.toElkDirection(direction);
112
+ const haVirtualNodes = new Map();
113
+ // Build set of nodes in HA pairs
114
+ const nodesInHAPairs = new Set();
115
+ for (const pair of haPairs) {
116
+ nodesInHAPairs.add(pair.nodeA);
117
+ nodesInHAPairs.add(pair.nodeB);
118
+ }
119
+ // Build node map for quick lookup
120
+ const nodeMap = new Map();
121
+ for (const node of graph.nodes) {
122
+ nodeMap.set(node.id, node);
123
+ }
124
+ // Create virtual nodes for HA pairs
125
+ const gap = 40; // Gap between HA pair nodes
126
+ for (let i = 0; i < haPairs.length; i++) {
127
+ const pair = haPairs[i];
128
+ const nodeA = nodeMap.get(pair.nodeA);
129
+ const nodeB = nodeMap.get(pair.nodeB);
130
+ if (!nodeA || !nodeB)
131
+ continue;
132
+ const virtualId = `__ha_virtual_${i}`;
133
+ const widthA = options.nodeWidth;
134
+ const widthB = options.nodeWidth;
135
+ const heightA = this.calculateNodeHeight(nodeA);
136
+ const heightB = this.calculateNodeHeight(nodeB);
137
+ haVirtualNodes.set(virtualId, {
138
+ virtualId,
139
+ nodeA,
140
+ nodeB,
141
+ gap: pair.minLength ?? gap,
142
+ widthA,
143
+ widthB,
144
+ height: Math.max(heightA, heightB),
145
+ });
146
+ }
147
+ // Build edge redirect map: original node -> virtual node + side (A or B)
148
+ const edgeRedirect = new Map();
149
+ for (const [virtualId, info] of haVirtualNodes) {
150
+ edgeRedirect.set(info.nodeA.id, { virtualId, side: 'A' });
151
+ edgeRedirect.set(info.nodeB.id, { virtualId, side: 'B' });
152
+ }
153
+ // Collect all ports for each node from links
154
+ const nodePorts = collectNodePorts(graph);
155
+ // Build subgraph map
156
+ const subgraphMap = new Map();
157
+ if (graph.subgraphs) {
158
+ for (const sg of graph.subgraphs) {
159
+ subgraphMap.set(sg.id, sg);
160
+ }
161
+ }
162
+ // Build node to parent map
163
+ const nodeParent = new Map();
164
+ for (const node of graph.nodes) {
165
+ if (node.parent) {
166
+ nodeParent.set(node.id, node.parent);
167
+ }
168
+ }
169
+ // Build subgraph children map
170
+ const subgraphChildren = new Map();
171
+ for (const sg of subgraphMap.values()) {
172
+ subgraphChildren.set(sg.id, []);
173
+ }
174
+ for (const sg of subgraphMap.values()) {
175
+ if (sg.parent && subgraphMap.has(sg.parent)) {
176
+ subgraphChildren.get(sg.parent).push(sg.id);
177
+ }
178
+ }
179
+ // Create ELK node for regular nodes
180
+ const createElkNode_ = (node) => {
181
+ const height = this.calculateNodeHeight(node);
182
+ const elkNode = {
183
+ id: node.id,
184
+ width: options.nodeWidth,
185
+ height,
186
+ labels: [{ text: Array.isArray(node.label) ? node.label.join('\n') : node.label }],
187
+ };
188
+ // Add ports if this node has any
189
+ const ports = nodePorts.get(node.id);
190
+ if (ports && ports.size > 0) {
191
+ elkNode.ports = Array.from(ports).map(portName => ({
192
+ id: `${node.id}:${portName}`,
193
+ width: PORT_WIDTH,
194
+ height: PORT_HEIGHT,
195
+ labels: [{ text: portName }],
196
+ }));
197
+ elkNode.layoutOptions = { 'elk.portConstraints': 'FREE' };
198
+ }
199
+ return elkNode;
200
+ };
201
+ // Create ELK node for HA virtual node with all ports from both nodes
202
+ const createHAVirtualElkNode = (info) => {
203
+ const totalWidth = info.widthA + info.gap + info.widthB;
204
+ const ports = [];
205
+ // Add all ports from nodeA (with prefix to identify them)
206
+ const portsA = nodePorts.get(info.nodeA.id);
207
+ if (portsA) {
208
+ for (const portName of portsA) {
209
+ ports.push({
210
+ id: `${info.virtualId}:A:${portName}`,
211
+ width: PORT_WIDTH,
212
+ height: PORT_HEIGHT,
213
+ labels: [{ text: portName }],
214
+ });
215
+ }
216
+ }
217
+ // Add all ports from nodeB (with prefix to identify them)
218
+ const portsB = nodePorts.get(info.nodeB.id);
219
+ if (portsB) {
220
+ for (const portName of portsB) {
221
+ ports.push({
222
+ id: `${info.virtualId}:B:${portName}`,
223
+ width: PORT_WIDTH,
224
+ height: PORT_HEIGHT,
225
+ labels: [{ text: portName }],
226
+ });
227
+ }
228
+ }
229
+ // Add fallback ports if no specific ports
230
+ if (ports.length === 0) {
231
+ ports.push({ id: `${info.virtualId}:A`, width: PORT_WIDTH, height: PORT_HEIGHT }, { id: `${info.virtualId}:B`, width: PORT_WIDTH, height: PORT_HEIGHT });
232
+ }
233
+ const elkNode = {
234
+ id: info.virtualId,
235
+ width: totalWidth,
236
+ height: info.height,
237
+ labels: [{ text: `${info.nodeA.id} + ${info.nodeB.id}` }],
238
+ ports,
239
+ layoutOptions: { 'elk.portConstraints': 'FREE' },
240
+ };
241
+ return elkNode;
242
+ };
243
+ // Determine which subgraph each HA virtual node belongs to
244
+ const virtualNodeParent = new Map();
245
+ for (const [virtualId, info] of haVirtualNodes) {
246
+ // Use nodeA's parent (both should be in same subgraph for HA to make sense)
247
+ virtualNodeParent.set(virtualId, info.nodeA.parent);
248
+ }
249
+ // Create ELK nodes recursively for subgraphs
250
+ const createSubgraphNode = (subgraph) => {
251
+ const childNodes = [];
252
+ // Add child subgraphs
253
+ const childSgIds = subgraphChildren.get(subgraph.id) || [];
254
+ for (const childId of childSgIds) {
255
+ const childSg = subgraphMap.get(childId);
256
+ if (childSg) {
257
+ childNodes.push(createSubgraphNode(childSg));
258
+ }
259
+ }
260
+ // Add HA virtual nodes that belong to this subgraph
261
+ for (const [virtualId, info] of haVirtualNodes) {
262
+ if (virtualNodeParent.get(virtualId) === subgraph.id) {
263
+ childNodes.push(createHAVirtualElkNode(info));
264
+ }
265
+ }
266
+ // Add regular nodes in this subgraph (skip nodes in HA pairs)
267
+ for (const node of graph.nodes) {
268
+ if (node.parent === subgraph.id && !nodesInHAPairs.has(node.id)) {
269
+ childNodes.push(createElkNode_(node));
270
+ }
271
+ }
272
+ const sgPadding = subgraph.style?.padding ?? options.subgraphPadding;
273
+ const sgNodeSpacing = subgraph.style?.nodeSpacing ?? options.nodeSpacing;
274
+ const sgRankSpacing = subgraph.style?.rankSpacing ?? options.rankSpacing;
275
+ return {
276
+ id: subgraph.id,
277
+ labels: [{ text: subgraph.label }],
278
+ children: childNodes,
279
+ layoutOptions: {
280
+ 'elk.padding': `[top=${sgPadding + options.subgraphLabelHeight},left=${sgPadding},bottom=${sgPadding},right=${sgPadding}]`,
281
+ 'elk.spacing.nodeNode': String(sgNodeSpacing),
282
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(sgRankSpacing),
283
+ },
284
+ };
285
+ };
286
+ // Build root children
287
+ const rootChildren = [];
288
+ // Add root-level subgraphs
289
+ for (const sg of subgraphMap.values()) {
290
+ if (!sg.parent || !subgraphMap.has(sg.parent)) {
291
+ rootChildren.push(createSubgraphNode(sg));
292
+ }
293
+ }
294
+ // Add root-level HA virtual nodes
295
+ for (const [virtualId, info] of haVirtualNodes) {
296
+ if (!virtualNodeParent.get(virtualId) || !subgraphMap.has(virtualNodeParent.get(virtualId))) {
297
+ rootChildren.push(createHAVirtualElkNode(info));
298
+ }
299
+ }
300
+ // Add root-level nodes (skip nodes in HA pairs)
301
+ for (const node of graph.nodes) {
302
+ if (nodesInHAPairs.has(node.id))
303
+ continue;
304
+ if (!node.parent || !subgraphMap.has(node.parent)) {
305
+ rootChildren.push(createElkNode_(node));
306
+ }
307
+ }
308
+ // Build edges with redirects for HA nodes
309
+ const edges = graph.links
310
+ .filter(link => {
311
+ // Skip HA internal links (the link connecting the HA pair nodes themselves)
312
+ const fromId = getNodeId(link.from);
313
+ const toId = getNodeId(link.to);
314
+ for (const pair of haPairs) {
315
+ if ((pair.nodeA === fromId && pair.nodeB === toId) ||
316
+ (pair.nodeA === toId && pair.nodeB === fromId)) {
317
+ return false;
318
+ }
319
+ }
320
+ return true;
321
+ })
322
+ .map((link, index) => {
323
+ const fromEndpoint = toEndpoint(link.from);
324
+ const toEndpoint_ = toEndpoint(link.to);
325
+ // Redirect endpoints if they're in HA pairs (preserve port names)
326
+ let sourceId;
327
+ let targetId;
328
+ const fromRedirect = edgeRedirect.get(fromEndpoint.node);
329
+ const toRedirect = edgeRedirect.get(toEndpoint_.node);
330
+ if (fromRedirect) {
331
+ // Redirect to virtual node, preserving port name if any
332
+ if (fromEndpoint.port) {
333
+ sourceId = `${fromRedirect.virtualId}:${fromRedirect.side}:${fromEndpoint.port}`;
334
+ }
335
+ else {
336
+ sourceId = `${fromRedirect.virtualId}:${fromRedirect.side}`;
337
+ }
338
+ }
339
+ else if (fromEndpoint.port) {
340
+ sourceId = `${fromEndpoint.node}:${fromEndpoint.port}`;
341
+ }
342
+ else {
343
+ sourceId = fromEndpoint.node;
344
+ }
345
+ if (toRedirect) {
346
+ // Redirect to virtual node, preserving port name if any
347
+ if (toEndpoint_.port) {
348
+ targetId = `${toRedirect.virtualId}:${toRedirect.side}:${toEndpoint_.port}`;
349
+ }
350
+ else {
351
+ targetId = `${toRedirect.virtualId}:${toRedirect.side}`;
352
+ }
353
+ }
354
+ else if (toEndpoint_.port) {
355
+ targetId = `${toEndpoint_.node}:${toEndpoint_.port}`;
356
+ }
357
+ else {
358
+ targetId = toEndpoint_.node;
359
+ }
360
+ const edge = {
361
+ id: link.id || `edge-${index}`,
362
+ sources: [sourceId],
363
+ targets: [targetId],
364
+ };
365
+ // Build label
366
+ const labelParts = [];
367
+ if (link.label) {
368
+ labelParts.push(Array.isArray(link.label) ? link.label.join(' / ') : link.label);
369
+ }
370
+ if (fromEndpoint.ip)
371
+ labelParts.push(fromEndpoint.ip);
372
+ if (toEndpoint_.ip)
373
+ labelParts.push(toEndpoint_.ip);
374
+ if (labelParts.length > 0) {
375
+ edge.labels = [{
376
+ text: labelParts.join('\n'),
377
+ layoutOptions: { 'elk.edgeLabels.placement': 'CENTER' }
378
+ }];
379
+ }
380
+ return edge;
381
+ });
382
+ // Root graph
383
+ const rootLayoutOptions = {
384
+ 'elk.algorithm': 'layered',
385
+ 'elk.direction': elkDirection,
386
+ 'elk.spacing.nodeNode': String(options.nodeSpacing),
387
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(options.rankSpacing),
388
+ 'elk.spacing.edgeNode': '50',
389
+ 'elk.spacing.edgeEdge': '20',
390
+ 'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
391
+ 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
392
+ 'elk.edgeRouting': 'SPLINES',
393
+ 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
394
+ };
395
+ return {
396
+ elkGraph: {
397
+ id: 'root',
398
+ children: rootChildren,
399
+ edges,
400
+ layoutOptions: rootLayoutOptions,
401
+ },
402
+ haVirtualNodes,
403
+ };
404
+ }
405
+ /**
406
+ * Extract layout result and expand HA virtual nodes back to original pairs
407
+ */
408
+ extractLayoutResultWithHAExpand(graph, elkGraph, haVirtualNodes, _options) {
409
+ const layoutNodes = new Map();
410
+ const layoutSubgraphs = new Map();
411
+ const layoutLinks = new Map();
412
+ // Build subgraph map for reference
413
+ const subgraphMap = new Map();
414
+ if (graph.subgraphs) {
415
+ for (const sg of graph.subgraphs) {
416
+ subgraphMap.set(sg.id, sg);
417
+ }
418
+ }
419
+ // Extract node and subgraph positions recursively
420
+ const processElkNode = (elkNode, offsetX, offsetY) => {
421
+ const x = (elkNode.x || 0) + offsetX;
422
+ const y = (elkNode.y || 0) + offsetY;
423
+ const width = elkNode.width || 0;
424
+ const height = elkNode.height || 0;
425
+ // Check if this is an HA virtual node
426
+ const haInfo = haVirtualNodes.get(elkNode.id);
427
+ if (haInfo) {
428
+ // Expand virtual node back to original pair
429
+ // Position nodeA on the left, nodeB on the right
430
+ const heightA = this.calculateNodeHeight(haInfo.nodeA);
431
+ const heightB = this.calculateNodeHeight(haInfo.nodeB);
432
+ const nodeAX = x + haInfo.widthA / 2;
433
+ const nodeBX = x + haInfo.widthA + haInfo.gap + haInfo.widthB / 2;
434
+ const centerY = y + height / 2;
435
+ // Create layout nodes for A and B
436
+ const layoutNodeA = {
437
+ id: haInfo.nodeA.id,
438
+ position: { x: nodeAX, y: centerY },
439
+ size: { width: haInfo.widthA, height: heightA },
440
+ node: haInfo.nodeA,
441
+ };
442
+ const layoutNodeB = {
443
+ id: haInfo.nodeB.id,
444
+ position: { x: nodeBX, y: centerY },
445
+ size: { width: haInfo.widthB, height: heightB },
446
+ node: haInfo.nodeB,
447
+ };
448
+ // For HA nodes, calculate port positions ourselves
449
+ // We need to determine which ports are for incoming vs outgoing edges
450
+ // Build maps of ports for each node (A and B), grouped by edge direction
451
+ const portsForATop = [];
452
+ const portsForABottom = [];
453
+ const portsForARight = []; // For HA link (facing B)
454
+ const portsForBTop = [];
455
+ const portsForBBottom = [];
456
+ const portsForBLeft = []; // For HA link (facing A)
457
+ // Analyze links to determine port direction
458
+ for (const link of graph.links) {
459
+ const fromEndpoint = toEndpoint(link.from);
460
+ const toEndpoint_ = toEndpoint(link.to);
461
+ // HA internal link - ports go on inner sides (A's right, B's left)
462
+ if (link.redundancy) {
463
+ if (fromEndpoint.node === haInfo.nodeA.id && fromEndpoint.port) {
464
+ portsForARight.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT });
465
+ }
466
+ if (toEndpoint_.node === haInfo.nodeA.id && toEndpoint_.port) {
467
+ portsForARight.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT });
468
+ }
469
+ if (fromEndpoint.node === haInfo.nodeB.id && fromEndpoint.port) {
470
+ portsForBLeft.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT });
471
+ }
472
+ if (toEndpoint_.node === haInfo.nodeB.id && toEndpoint_.port) {
473
+ portsForBLeft.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT });
474
+ }
475
+ continue;
476
+ }
477
+ // Check if this link involves nodeA
478
+ if (fromEndpoint.node === haInfo.nodeA.id && fromEndpoint.port) {
479
+ // Outgoing from A → bottom
480
+ portsForABottom.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT });
481
+ }
482
+ if (toEndpoint_.node === haInfo.nodeA.id && toEndpoint_.port) {
483
+ // Incoming to A → top
484
+ portsForATop.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT });
485
+ }
486
+ // Check if this link involves nodeB
487
+ if (fromEndpoint.node === haInfo.nodeB.id && fromEndpoint.port) {
488
+ // Outgoing from B → bottom
489
+ portsForBBottom.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT });
490
+ }
491
+ if (toEndpoint_.node === haInfo.nodeB.id && toEndpoint_.port) {
492
+ // Incoming to B → top
493
+ portsForBTop.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT });
494
+ }
495
+ }
496
+ // Distribute ports on an edge (top/bottom/left/right) - ports placed OUTSIDE the node
497
+ const distributePortsOnEdge = (portsList, nodeWidth, nodeHeight, nodeId, side) => {
498
+ const result = new Map();
499
+ if (portsList.length === 0)
500
+ return result;
501
+ portsList.forEach((p, i) => {
502
+ let relX;
503
+ let relY;
504
+ if (side === 'top' || side === 'bottom') {
505
+ // Horizontal distribution along top/bottom edge
506
+ const portSpacing = nodeWidth / (portsList.length + 1);
507
+ relX = portSpacing * (i + 1) - nodeWidth / 2;
508
+ // Place port completely outside the node
509
+ relY = side === 'top'
510
+ ? -nodeHeight / 2 - p.portH / 2 // Above the node
511
+ : nodeHeight / 2 + p.portH / 2; // Below the node
512
+ }
513
+ else {
514
+ // Vertical distribution along left/right edge
515
+ const portSpacing = nodeHeight / (portsList.length + 1);
516
+ relY = portSpacing * (i + 1) - nodeHeight / 2;
517
+ // Place port completely outside the node
518
+ relX = side === 'left'
519
+ ? -nodeWidth / 2 - p.portW / 2 // Left of the node
520
+ : nodeWidth / 2 + p.portW / 2; // Right of the node
521
+ }
522
+ const originalPortId = `${nodeId}:${p.name}`;
523
+ result.set(originalPortId, {
524
+ id: originalPortId,
525
+ label: p.name,
526
+ position: { x: relX, y: relY },
527
+ size: { width: p.portW, height: p.portH },
528
+ side,
529
+ });
530
+ });
531
+ return result;
532
+ };
533
+ // Create port maps for A and B
534
+ // For nodeA (left side of HA pair): ports should be positioned toward the inner side (right)
535
+ // For nodeB (right side of HA pair): ports should be positioned toward the inner side (left)
536
+ // This reduces line crossings when connecting to centered nodes below/above
537
+ const portsMapA = new Map();
538
+ // For nodeA: reverse the port order so inner ports (right side) come first
539
+ const topPortsA = distributePortsOnEdge([...portsForATop].reverse(), haInfo.widthA, heightA, haInfo.nodeA.id, 'top');
540
+ const bottomPortsA = distributePortsOnEdge([...portsForABottom].reverse(), haInfo.widthA, heightA, haInfo.nodeA.id, 'bottom');
541
+ const rightPortsA = distributePortsOnEdge(portsForARight, haInfo.widthA, heightA, haInfo.nodeA.id, 'right');
542
+ for (const [k, v] of topPortsA)
543
+ portsMapA.set(k, v);
544
+ for (const [k, v] of bottomPortsA)
545
+ portsMapA.set(k, v);
546
+ for (const [k, v] of rightPortsA)
547
+ portsMapA.set(k, v);
548
+ const portsMapB = new Map();
549
+ // For nodeB: keep normal order so inner ports (left side) come first
550
+ const topPortsB = distributePortsOnEdge(portsForBTop, haInfo.widthB, heightB, haInfo.nodeB.id, 'top');
551
+ const bottomPortsB = distributePortsOnEdge(portsForBBottom, haInfo.widthB, heightB, haInfo.nodeB.id, 'bottom');
552
+ const leftPortsB = distributePortsOnEdge(portsForBLeft, haInfo.widthB, heightB, haInfo.nodeB.id, 'left');
553
+ for (const [k, v] of topPortsB)
554
+ portsMapB.set(k, v);
555
+ for (const [k, v] of bottomPortsB)
556
+ portsMapB.set(k, v);
557
+ for (const [k, v] of leftPortsB)
558
+ portsMapB.set(k, v);
559
+ if (portsMapA.size > 0)
560
+ layoutNodeA.ports = portsMapA;
561
+ if (portsMapB.size > 0)
562
+ layoutNodeB.ports = portsMapB;
563
+ layoutNodes.set(haInfo.nodeA.id, layoutNodeA);
564
+ layoutNodes.set(haInfo.nodeB.id, layoutNodeB);
565
+ }
566
+ else if (subgraphMap.has(elkNode.id)) {
567
+ // This is a subgraph
568
+ const sg = subgraphMap.get(elkNode.id);
569
+ layoutSubgraphs.set(elkNode.id, {
570
+ id: elkNode.id,
571
+ bounds: { x, y, width, height },
572
+ subgraph: sg,
573
+ });
574
+ // Process children
575
+ if (elkNode.children) {
576
+ for (const child of elkNode.children) {
577
+ processElkNode(child, x, y);
578
+ }
579
+ }
580
+ }
581
+ else {
582
+ // This is a regular node
583
+ const node = graph.nodes.find(n => n.id === elkNode.id);
584
+ if (node) {
585
+ const layoutNode = {
586
+ id: elkNode.id,
587
+ position: { x: x + width / 2, y: y + height / 2 },
588
+ size: { width, height },
589
+ node,
590
+ };
591
+ // Extract port positions if any
592
+ // Note: Ports will be repositioned by reorderPortsByConnectedNodePositions later
593
+ if (elkNode.ports && elkNode.ports.length > 0) {
594
+ layoutNode.ports = new Map();
595
+ for (const elkPort of elkNode.ports) {
596
+ const portX = (elkPort.x ?? 0);
597
+ const portY = (elkPort.y ?? 0);
598
+ const portW = elkPort.width ?? PORT_WIDTH;
599
+ const portH = elkPort.height ?? PORT_HEIGHT;
600
+ // Determine which side based on ELK position
601
+ let side = 'bottom';
602
+ if (portY <= portH)
603
+ side = 'top';
604
+ else if (portY >= height - portH * 2)
605
+ side = 'bottom';
606
+ else if (portX <= portW)
607
+ side = 'left';
608
+ else if (portX >= width - portW * 2)
609
+ side = 'right';
610
+ const portName = elkPort.id.includes(':')
611
+ ? elkPort.id.split(':').slice(1).join(':')
612
+ : elkPort.id;
613
+ // Calculate position outside the node edge
614
+ let relX;
615
+ let relY;
616
+ if (side === 'top') {
617
+ relX = portX - width / 2 + portW / 2;
618
+ relY = -height / 2 - portH / 2;
619
+ }
620
+ else if (side === 'bottom') {
621
+ relX = portX - width / 2 + portW / 2;
622
+ relY = height / 2 + portH / 2;
623
+ }
624
+ else if (side === 'left') {
625
+ relX = -width / 2 - portW / 2;
626
+ relY = portY - height / 2 + portH / 2;
627
+ }
628
+ else {
629
+ relX = width / 2 + portW / 2;
630
+ relY = portY - height / 2 + portH / 2;
631
+ }
632
+ layoutNode.ports.set(elkPort.id, {
633
+ id: elkPort.id,
634
+ label: portName,
635
+ position: { x: relX, y: relY },
636
+ size: { width: portW, height: portH },
637
+ side,
638
+ });
639
+ }
640
+ }
641
+ layoutNodes.set(elkNode.id, layoutNode);
642
+ }
643
+ }
644
+ };
645
+ // Process root children
646
+ if (elkGraph.children) {
647
+ for (const child of elkGraph.children) {
648
+ processElkNode(child, 0, 0);
649
+ }
650
+ }
651
+ // Post-process: reorder ports based on connected node positions to minimize crossings
652
+ this.reorderPortsByConnectedNodePositions(graph, layoutNodes);
653
+ // Calculate edges based on extracted node positions
654
+ graph.links.forEach((link, index) => {
655
+ const id = link.id || `link-${index}`;
656
+ const fromEndpoint_ = toEndpoint(link.from);
657
+ const toEndpoint_ = toEndpoint(link.to);
658
+ const fromId = fromEndpoint_.node;
659
+ const toId = toEndpoint_.node;
660
+ const fromNode = layoutNodes.get(fromId);
661
+ const toNode = layoutNodes.get(toId);
662
+ if (!fromNode || !toNode)
663
+ return;
664
+ // Get port positions if specified
665
+ const fromPortId = fromEndpoint_.port ? `${fromId}:${fromEndpoint_.port}` : undefined;
666
+ const toPortId = toEndpoint_.port ? `${toId}:${toEndpoint_.port}` : undefined;
667
+ const fromPort = fromPortId ? fromNode.ports?.get(fromPortId) : undefined;
668
+ const toPort = toPortId ? toNode.ports?.get(toPortId) : undefined;
669
+ const points = this.calculateEdgePathWithPorts(fromNode, toNode, fromPort, toPort);
670
+ layoutLinks.set(id, {
671
+ id,
672
+ from: fromId,
673
+ to: toId,
674
+ fromEndpoint: fromEndpoint_,
675
+ toEndpoint: toEndpoint_,
676
+ points,
677
+ link,
678
+ });
679
+ });
680
+ // Calculate total bounds
681
+ const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs);
682
+ return {
683
+ nodes: layoutNodes,
684
+ links: layoutLinks,
685
+ subgraphs: layoutSubgraphs,
686
+ bounds,
687
+ };
688
+ }
689
+ /**
690
+ * Reorder ports on each node based on connected node positions to minimize edge crossings
691
+ */
692
+ reorderPortsByConnectedNodePositions(graph, layoutNodes) {
693
+ // Build a map of port -> connected node position
694
+ // For each port, find the node it connects to and get that node's position
695
+ const portConnections = new Map();
696
+ for (const link of graph.links) {
697
+ const fromEndpoint = toEndpoint(link.from);
698
+ const toEndpoint_ = toEndpoint(link.to);
699
+ // From port connects to "to" node
700
+ if (fromEndpoint.port) {
701
+ const portId = `${fromEndpoint.node}:${fromEndpoint.port}`;
702
+ portConnections.set(portId, { connectedNodeId: toEndpoint_.node, isOutgoing: true });
703
+ }
704
+ // To port connects to "from" node
705
+ if (toEndpoint_.port) {
706
+ const portId = `${toEndpoint_.node}:${toEndpoint_.port}`;
707
+ portConnections.set(portId, { connectedNodeId: fromEndpoint.node, isOutgoing: false });
708
+ }
709
+ }
710
+ // For each node, reorder its ports based on connected node positions
711
+ layoutNodes.forEach((layoutNode) => {
712
+ if (!layoutNode.ports || layoutNode.ports.size === 0)
713
+ return;
714
+ // Group ports by side
715
+ const portsBySide = new Map();
716
+ layoutNode.ports.forEach((port, portId) => {
717
+ const side = port.side;
718
+ if (!portsBySide.has(side)) {
719
+ portsBySide.set(side, []);
720
+ }
721
+ portsBySide.get(side).push({ portId, port });
722
+ });
723
+ // Sort and redistribute ports on each side
724
+ portsBySide.forEach((ports, side) => {
725
+ if (ports.length <= 1)
726
+ return; // No need to reorder single port
727
+ // Sort ports by connected node position
728
+ ports.sort((a, b) => {
729
+ const connA = portConnections.get(a.portId);
730
+ const connB = portConnections.get(b.portId);
731
+ if (!connA || !connB)
732
+ return 0;
733
+ const nodeA = layoutNodes.get(connA.connectedNodeId);
734
+ const nodeB = layoutNodes.get(connB.connectedNodeId);
735
+ if (!nodeA || !nodeB)
736
+ return 0;
737
+ // For top/bottom sides, sort by X position (left to right)
738
+ if (side === 'top' || side === 'bottom') {
739
+ return nodeA.position.x - nodeB.position.x;
740
+ }
741
+ // For left/right sides, sort by Y position (top to bottom)
742
+ return nodeA.position.y - nodeB.position.y;
743
+ });
744
+ // Redistribute port positions along the edge (ports placed OUTSIDE the node)
745
+ const nodeWidth = layoutNode.size.width;
746
+ const nodeHeight = layoutNode.size.height;
747
+ if (side === 'top' || side === 'bottom') {
748
+ const portSpacing = nodeWidth / (ports.length + 1);
749
+ ports.forEach((p, i) => {
750
+ const relX = portSpacing * (i + 1) - nodeWidth / 2;
751
+ // Place port completely outside the node edge
752
+ const relY = side === 'top'
753
+ ? -nodeHeight / 2 - p.port.size.height / 2 // Above the node
754
+ : nodeHeight / 2 + p.port.size.height / 2; // Below the node
755
+ p.port.position = { x: relX, y: relY };
756
+ });
757
+ }
758
+ else {
759
+ const portSpacing = nodeHeight / (ports.length + 1);
760
+ ports.forEach((p, i) => {
761
+ const relY = portSpacing * (i + 1) - nodeHeight / 2;
762
+ // Place port completely outside the node edge
763
+ const relX = side === 'left'
764
+ ? -nodeWidth / 2 - p.port.size.width / 2 // Left of the node
765
+ : nodeWidth / 2 + p.port.size.width / 2; // Right of the node
766
+ p.port.position = { x: relX, y: relY };
767
+ });
768
+ }
769
+ });
770
+ });
771
+ }
772
+ // Synchronous wrapper that runs async internally
773
+ layout(graph) {
774
+ // For sync compatibility, we need to run layout synchronously
775
+ // This is a simplified version - ideally use layoutAsync for full HA support
776
+ const effectiveOptions = this.getEffectiveOptions(graph);
777
+ const direction = effectiveOptions.direction;
778
+ const haPairs = this.detectHAPairs(graph);
779
+ // Build ELK graph with HA merge (same as async)
780
+ const { elkGraph, haVirtualNodes } = this.buildElkGraphWithHAMerge(graph, direction, effectiveOptions, haPairs);
781
+ // Run synchronous layout using elk's sync API
782
+ let result;
783
+ // Use a workaround: pre-calculate positions based on structure
784
+ result = this.calculateFallbackLayout(graph, direction);
785
+ // Start async layout and update when done (for future renders)
786
+ this.elk.layout(elkGraph).then((layoutedGraph) => {
787
+ // This will be available for next render
788
+ Object.assign(result, this.extractLayoutResultWithHAExpand(graph, layoutedGraph, haVirtualNodes, effectiveOptions));
789
+ }).catch(() => {
790
+ // Keep fallback layout
791
+ });
792
+ return result;
793
+ }
794
+ toElkDirection(direction) {
795
+ switch (direction) {
796
+ case 'TB': return 'DOWN';
797
+ case 'BT': return 'UP';
798
+ case 'LR': return 'RIGHT';
799
+ case 'RL': return 'LEFT';
800
+ default: return 'DOWN';
801
+ }
802
+ }
803
+ calculateNodeHeight(node) {
804
+ const lines = Array.isArray(node.label) ? node.label.length : 1;
805
+ const baseHeight = 40;
806
+ const lineHeight = 16;
807
+ // Calculate actual icon height based on vendor icon or default
808
+ let iconHeight = 0;
809
+ const iconKey = node.service || node.model;
810
+ if (node.vendor && iconKey) {
811
+ const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource);
812
+ if (iconEntry) {
813
+ const defaultIconSize = 40;
814
+ const iconPadding = 16;
815
+ const maxIconWidth = this.options.nodeWidth - iconPadding;
816
+ const vendorIcon = iconEntry.default;
817
+ const viewBox = iconEntry.viewBox || '0 0 48 48';
818
+ // Check for PNG-based icons with embedded viewBox
819
+ if (vendorIcon.startsWith('<svg')) {
820
+ const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/);
821
+ if (viewBoxMatch) {
822
+ const vbWidth = parseInt(viewBoxMatch[1]);
823
+ const vbHeight = parseInt(viewBoxMatch[2]);
824
+ const aspectRatio = vbWidth / vbHeight;
825
+ let calcHeight = defaultIconSize;
826
+ if (Math.round(defaultIconSize * aspectRatio) > maxIconWidth) {
827
+ calcHeight = Math.round(maxIconWidth / aspectRatio);
828
+ }
829
+ iconHeight = calcHeight;
830
+ }
831
+ else {
832
+ iconHeight = defaultIconSize;
833
+ }
834
+ }
835
+ else {
836
+ // Parse viewBox for aspect ratio
837
+ const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
838
+ if (vbMatch) {
839
+ const vbWidth = parseInt(vbMatch[3]);
840
+ const vbHeight = parseInt(vbMatch[4]);
841
+ const aspectRatio = vbWidth / vbHeight;
842
+ let calcHeight = defaultIconSize;
843
+ if (Math.round(defaultIconSize * aspectRatio) > maxIconWidth) {
844
+ calcHeight = Math.round(maxIconWidth / aspectRatio);
845
+ }
846
+ iconHeight = calcHeight;
847
+ }
848
+ else {
849
+ iconHeight = defaultIconSize;
850
+ }
851
+ }
852
+ }
853
+ }
854
+ else if (node.type) {
855
+ // Default device type icon (fixed size)
856
+ iconHeight = 36;
857
+ }
858
+ return Math.max(this.options.nodeHeight, baseHeight + (lines - 1) * lineHeight + iconHeight);
859
+ }
860
+ /**
861
+ * Calculate edge path using port positions when available
862
+ * Port positions are from ELK - lines connect FROM the ports
863
+ */
864
+ calculateEdgePathWithPorts(fromNode, toNode, fromPort, toPort) {
865
+ // Use port positions from ELK (lines come FROM ports)
866
+ let fromPoint;
867
+ let fromSide;
868
+ if (fromPort) {
869
+ // Line starts from port position
870
+ fromPoint = {
871
+ x: fromNode.position.x + fromPort.position.x,
872
+ y: fromNode.position.y + fromPort.position.y,
873
+ };
874
+ fromSide = fromPort.side;
875
+ }
876
+ else {
877
+ // No port - calculate edge position based on target node direction
878
+ const dx = toNode.position.x - fromNode.position.x;
879
+ const dy = toNode.position.y - fromNode.position.y;
880
+ const halfW = fromNode.size.width / 2;
881
+ const halfH = fromNode.size.height / 2;
882
+ if (Math.abs(dx) > Math.abs(dy) * 0.5) {
883
+ fromSide = dx > 0 ? 'right' : 'left';
884
+ fromPoint = {
885
+ x: fromNode.position.x + (dx > 0 ? halfW : -halfW),
886
+ y: fromNode.position.y,
887
+ };
888
+ }
889
+ else {
890
+ fromSide = dy > 0 ? 'bottom' : 'top';
891
+ fromPoint = {
892
+ x: fromNode.position.x,
893
+ y: fromNode.position.y + (dy > 0 ? halfH : -halfH),
894
+ };
895
+ }
896
+ }
897
+ let toPoint;
898
+ let toSide;
899
+ if (toPort) {
900
+ // Line ends at port position
901
+ toPoint = {
902
+ x: toNode.position.x + toPort.position.x,
903
+ y: toNode.position.y + toPort.position.y,
904
+ };
905
+ toSide = toPort.side;
906
+ }
907
+ else {
908
+ // No port - calculate edge position based on source node direction
909
+ const dx = fromNode.position.x - toNode.position.x;
910
+ const dy = fromNode.position.y - toNode.position.y;
911
+ const halfW = toNode.size.width / 2;
912
+ const halfH = toNode.size.height / 2;
913
+ if (Math.abs(dx) > Math.abs(dy) * 0.5) {
914
+ toSide = dx > 0 ? 'right' : 'left';
915
+ toPoint = {
916
+ x: toNode.position.x + (dx > 0 ? halfW : -halfW),
917
+ y: toNode.position.y,
918
+ };
919
+ }
920
+ else {
921
+ toSide = dy > 0 ? 'bottom' : 'top';
922
+ toPoint = {
923
+ x: toNode.position.x,
924
+ y: toNode.position.y + (dy > 0 ? halfH : -halfH),
925
+ };
926
+ }
927
+ }
928
+ // Calculate bezier control points based on port sides
929
+ const dx = toPoint.x - fromPoint.x;
930
+ const dy = toPoint.y - fromPoint.y;
931
+ const dist = Math.sqrt(dx * dx + dy * dy);
932
+ const controlDist = Math.min(dist * 0.4, 80);
933
+ const fromControl = this.getControlPointForSide(fromPoint, fromSide, controlDist);
934
+ const toControl = this.getControlPointForSide(toPoint, toSide, controlDist);
935
+ return [fromPoint, fromControl, toControl, toPoint];
936
+ }
937
+ /**
938
+ * Get control point position based on port side
939
+ */
940
+ getControlPointForSide(point, side, dist) {
941
+ switch (side) {
942
+ case 'top':
943
+ return { x: point.x, y: point.y - Math.abs(dist) };
944
+ case 'bottom':
945
+ return { x: point.x, y: point.y + Math.abs(dist) };
946
+ case 'left':
947
+ return { x: point.x - Math.abs(dist), y: point.y };
948
+ case 'right':
949
+ return { x: point.x + Math.abs(dist), y: point.y };
950
+ default:
951
+ return { x: point.x, y: point.y + dist };
952
+ }
953
+ }
954
+ calculateFallbackLayout(graph, _direction) {
955
+ // Simple fallback layout when async isn't available
956
+ const layoutNodes = new Map();
957
+ const layoutSubgraphs = new Map();
958
+ const layoutLinks = new Map();
959
+ // Simple grid layout for nodes
960
+ let x = 100;
961
+ let y = 100;
962
+ const rowHeight = this.options.nodeHeight + this.options.rankSpacing;
963
+ const colWidth = this.options.nodeWidth + this.options.nodeSpacing;
964
+ let col = 0;
965
+ const maxCols = 4;
966
+ for (const node of graph.nodes) {
967
+ const height = this.calculateNodeHeight(node);
968
+ layoutNodes.set(node.id, {
969
+ id: node.id,
970
+ position: { x: x + this.options.nodeWidth / 2, y: y + height / 2 },
971
+ size: { width: this.options.nodeWidth, height },
972
+ node,
973
+ });
974
+ col++;
975
+ if (col >= maxCols) {
976
+ col = 0;
977
+ x = 100;
978
+ y += rowHeight;
979
+ }
980
+ else {
981
+ x += colWidth;
982
+ }
983
+ }
984
+ // Simple links
985
+ graph.links.forEach((link, index) => {
986
+ const fromId = getNodeId(link.from);
987
+ const toId = getNodeId(link.to);
988
+ const from = layoutNodes.get(fromId);
989
+ const to = layoutNodes.get(toId);
990
+ if (from && to) {
991
+ layoutLinks.set(link.id || `link-${index}`, {
992
+ id: link.id || `link-${index}`,
993
+ from: fromId,
994
+ to: toId,
995
+ fromEndpoint: toEndpoint(link.from),
996
+ toEndpoint: toEndpoint(link.to),
997
+ points: [from.position, to.position],
998
+ link,
999
+ });
1000
+ }
1001
+ });
1002
+ const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs);
1003
+ return {
1004
+ nodes: layoutNodes,
1005
+ links: layoutLinks,
1006
+ subgraphs: layoutSubgraphs,
1007
+ bounds,
1008
+ metadata: { algorithm: 'fallback-grid', duration: 0 },
1009
+ };
1010
+ }
1011
+ calculateTotalBounds(nodes, subgraphs) {
1012
+ let minX = Infinity, minY = Infinity;
1013
+ let maxX = -Infinity, maxY = -Infinity;
1014
+ nodes.forEach((node) => {
1015
+ minX = Math.min(minX, node.position.x - node.size.width / 2);
1016
+ minY = Math.min(minY, node.position.y - node.size.height / 2);
1017
+ maxX = Math.max(maxX, node.position.x + node.size.width / 2);
1018
+ maxY = Math.max(maxY, node.position.y + node.size.height / 2);
1019
+ });
1020
+ subgraphs.forEach((sg) => {
1021
+ minX = Math.min(minX, sg.bounds.x);
1022
+ minY = Math.min(minY, sg.bounds.y);
1023
+ maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width);
1024
+ maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height);
1025
+ });
1026
+ const padding = 50;
1027
+ if (minX === Infinity) {
1028
+ return { x: 0, y: 0, width: 400, height: 300 };
1029
+ }
1030
+ return {
1031
+ x: minX - padding,
1032
+ y: minY - padding,
1033
+ width: maxX - minX + padding * 2,
1034
+ height: maxY - minY + padding * 2,
1035
+ };
1036
+ }
1037
+ /**
1038
+ * Detect redundancy pairs based on link.redundancy property
1039
+ * Returns array of [nodeA, nodeB] pairs that should be placed on the same layer
1040
+ */
1041
+ detectHAPairs(graph) {
1042
+ const pairs = [];
1043
+ const processedPairs = new Set();
1044
+ for (const link of graph.links) {
1045
+ // Only process links with redundancy property set
1046
+ if (!link.redundancy)
1047
+ continue;
1048
+ const fromId = getNodeId(link.from);
1049
+ const toId = getNodeId(link.to);
1050
+ const pairKey = [fromId, toId].sort().join(':');
1051
+ if (processedPairs.has(pairKey))
1052
+ continue;
1053
+ pairs.push({
1054
+ nodeA: fromId,
1055
+ nodeB: toId,
1056
+ minLength: link.style?.minLength,
1057
+ });
1058
+ processedPairs.add(pairKey);
1059
+ }
1060
+ return pairs;
1061
+ }
1062
+ /**
1063
+ * Adjust node distances based on link minLength property
1064
+ */
1065
+ adjustLinkDistances(result, graph, direction) {
1066
+ const isVertical = direction === 'TB' || direction === 'BT';
1067
+ let adjusted = false;
1068
+ for (const link of graph.links) {
1069
+ const minLength = link.style?.minLength;
1070
+ if (!minLength)
1071
+ continue;
1072
+ // Skip HA links (already handled by two-pass layout with layerChoiceConstraint)
1073
+ if (link.redundancy)
1074
+ continue;
1075
+ const fromId = getNodeId(link.from);
1076
+ const toId = getNodeId(link.to);
1077
+ const fromNode = result.nodes.get(fromId);
1078
+ const toNode = result.nodes.get(toId);
1079
+ if (!fromNode || !toNode)
1080
+ continue;
1081
+ // Calculate current distance
1082
+ const dx = toNode.position.x - fromNode.position.x;
1083
+ const dy = toNode.position.y - fromNode.position.y;
1084
+ const currentDist = Math.sqrt(dx * dx + dy * dy);
1085
+ if (currentDist >= minLength)
1086
+ continue;
1087
+ // Need to increase distance
1088
+ const scale = minLength / currentDist;
1089
+ if (isVertical) {
1090
+ // For TB/BT layout, primarily adjust Y (move target node down/up)
1091
+ const newDy = dy * scale;
1092
+ const deltaY = newDy - dy;
1093
+ toNode.position.y += deltaY;
1094
+ }
1095
+ else {
1096
+ // For LR/RL layout, primarily adjust X
1097
+ const newDx = dx * scale;
1098
+ const deltaX = newDx - dx;
1099
+ toNode.position.x += deltaX;
1100
+ }
1101
+ adjusted = true;
1102
+ }
1103
+ if (adjusted) {
1104
+ this.recalculateAllEdges(result);
1105
+ }
1106
+ }
1107
+ /**
1108
+ * Calculate edge path with offset for multiple connections
1109
+ */
1110
+ calculateEdgePathWithOffset(fromNode, toNode, outIndex, outTotal, inIndex, inTotal) {
1111
+ const fromPos = fromNode.position;
1112
+ const toPos = toNode.position;
1113
+ const fromSize = fromNode.size;
1114
+ const toSize = toNode.size;
1115
+ // Calculate direction from source to target
1116
+ const dx = toPos.x - fromPos.x;
1117
+ const dy = toPos.y - fromPos.y;
1118
+ // Calculate offset for distributing multiple connections
1119
+ const portSpacing = 15; // pixels between port connections
1120
+ let fromPoint;
1121
+ let toPoint;
1122
+ let controlDist;
1123
+ // For vertical layouts (TB), prefer top/bottom connections
1124
+ if (Math.abs(dy) > Math.abs(dx) * 0.3) {
1125
+ // Primarily vertical
1126
+ // Calculate horizontal offset for multiple outgoing/incoming links
1127
+ const outOffset = outTotal > 1 ? (outIndex - (outTotal - 1) / 2) * portSpacing : 0;
1128
+ const inOffset = inTotal > 1 ? (inIndex - (inTotal - 1) / 2) * portSpacing : 0;
1129
+ if (dy > 0) {
1130
+ fromPoint = { x: fromPos.x + outOffset, y: fromPos.y + fromSize.height / 2 };
1131
+ toPoint = { x: toPos.x + inOffset, y: toPos.y - toSize.height / 2 };
1132
+ }
1133
+ else {
1134
+ fromPoint = { x: fromPos.x + outOffset, y: fromPos.y - fromSize.height / 2 };
1135
+ toPoint = { x: toPos.x + inOffset, y: toPos.y + toSize.height / 2 };
1136
+ }
1137
+ controlDist = Math.min(Math.abs(toPoint.y - fromPoint.y) * 0.4, 80);
1138
+ return [
1139
+ fromPoint,
1140
+ { x: fromPoint.x, y: fromPoint.y + Math.sign(dy) * controlDist },
1141
+ { x: toPoint.x, y: toPoint.y - Math.sign(dy) * controlDist },
1142
+ toPoint,
1143
+ ];
1144
+ }
1145
+ else {
1146
+ // Primarily horizontal
1147
+ // Calculate vertical offset for multiple outgoing/incoming links
1148
+ const outOffset = outTotal > 1 ? (outIndex - (outTotal - 1) / 2) * portSpacing : 0;
1149
+ const inOffset = inTotal > 1 ? (inIndex - (inTotal - 1) / 2) * portSpacing : 0;
1150
+ if (dx > 0) {
1151
+ fromPoint = { x: fromPos.x + fromSize.width / 2, y: fromPos.y + outOffset };
1152
+ toPoint = { x: toPos.x - toSize.width / 2, y: toPos.y + inOffset };
1153
+ }
1154
+ else {
1155
+ fromPoint = { x: fromPos.x - fromSize.width / 2, y: fromPos.y + outOffset };
1156
+ toPoint = { x: toPos.x + toSize.width / 2, y: toPos.y + inOffset };
1157
+ }
1158
+ controlDist = Math.min(Math.abs(toPoint.x - fromPoint.x) * 0.4, 80);
1159
+ return [
1160
+ fromPoint,
1161
+ { x: fromPoint.x + Math.sign(dx) * controlDist, y: fromPoint.y },
1162
+ { x: toPoint.x - Math.sign(dx) * controlDist, y: toPoint.y },
1163
+ toPoint,
1164
+ ];
1165
+ }
1166
+ }
1167
+ /**
1168
+ * Recalculate all edge paths with port offset distribution
1169
+ */
1170
+ recalculateAllEdges(result) {
1171
+ // Group links by source and target nodes to calculate offsets
1172
+ const outgoingLinks = new Map(); // nodeId -> [linkIds]
1173
+ const incomingLinks = new Map(); // nodeId -> [linkIds]
1174
+ result.links.forEach((layoutLink, linkId) => {
1175
+ // Outgoing from source
1176
+ if (!outgoingLinks.has(layoutLink.from)) {
1177
+ outgoingLinks.set(layoutLink.from, []);
1178
+ }
1179
+ outgoingLinks.get(layoutLink.from).push(linkId);
1180
+ // Incoming to target
1181
+ if (!incomingLinks.has(layoutLink.to)) {
1182
+ incomingLinks.set(layoutLink.to, []);
1183
+ }
1184
+ incomingLinks.get(layoutLink.to).push(linkId);
1185
+ });
1186
+ // Sort outgoing links by target X position to reduce crossings
1187
+ outgoingLinks.forEach((linkIds, _nodeId) => {
1188
+ linkIds.sort((a, b) => {
1189
+ const linkA = result.links.get(a);
1190
+ const linkB = result.links.get(b);
1191
+ if (!linkA || !linkB)
1192
+ return 0;
1193
+ const targetA = result.nodes.get(linkA.to);
1194
+ const targetB = result.nodes.get(linkB.to);
1195
+ if (!targetA || !targetB)
1196
+ return 0;
1197
+ return targetA.position.x - targetB.position.x;
1198
+ });
1199
+ });
1200
+ // Sort incoming links by source X position to reduce crossings
1201
+ incomingLinks.forEach((linkIds, _nodeId) => {
1202
+ linkIds.sort((a, b) => {
1203
+ const linkA = result.links.get(a);
1204
+ const linkB = result.links.get(b);
1205
+ if (!linkA || !linkB)
1206
+ return 0;
1207
+ const sourceA = result.nodes.get(linkA.from);
1208
+ const sourceB = result.nodes.get(linkB.from);
1209
+ if (!sourceA || !sourceB)
1210
+ return 0;
1211
+ return sourceA.position.x - sourceB.position.x;
1212
+ });
1213
+ });
1214
+ // Calculate edge paths with offset - prefer port positions when available
1215
+ result.links.forEach((layoutLink, linkId) => {
1216
+ const fromNode = result.nodes.get(layoutLink.from);
1217
+ const toNode = result.nodes.get(layoutLink.to);
1218
+ if (fromNode && toNode) {
1219
+ // Check if this link uses ports
1220
+ const fromPortId = layoutLink.fromEndpoint.port
1221
+ ? `${layoutLink.from}:${layoutLink.fromEndpoint.port}`
1222
+ : undefined;
1223
+ const toPortId = layoutLink.toEndpoint.port
1224
+ ? `${layoutLink.to}:${layoutLink.toEndpoint.port}`
1225
+ : undefined;
1226
+ const fromPort = fromPortId ? fromNode.ports?.get(fromPortId) : undefined;
1227
+ const toPort = toPortId ? toNode.ports?.get(toPortId) : undefined;
1228
+ if (fromPort || toPort) {
1229
+ // Use port-aware edge calculation
1230
+ layoutLink.points = this.calculateEdgePathWithPorts(fromNode, toNode, fromPort, toPort);
1231
+ }
1232
+ else {
1233
+ // No ports - use offset-based calculation
1234
+ const outLinks = outgoingLinks.get(layoutLink.from) || [];
1235
+ const inLinks = incomingLinks.get(layoutLink.to) || [];
1236
+ const outIndex = outLinks.indexOf(linkId);
1237
+ const inIndex = inLinks.indexOf(linkId);
1238
+ layoutLink.points = this.calculateEdgePathWithOffset(fromNode, toNode, outIndex, outLinks.length, inIndex, inLinks.length);
1239
+ }
1240
+ }
1241
+ });
1242
+ }
1243
+ /**
1244
+ * Recalculate subgraph bounds to contain all child nodes after adjustments
1245
+ */
1246
+ recalculateSubgraphBounds(result, graph) {
1247
+ if (!graph.subgraphs)
1248
+ return;
1249
+ const padding = this.options.subgraphPadding;
1250
+ const labelHeight = this.options.subgraphLabelHeight;
1251
+ // Build node-to-subgraph mapping
1252
+ const nodeToSubgraph = new Map();
1253
+ for (const node of graph.nodes) {
1254
+ if (node.parent) {
1255
+ nodeToSubgraph.set(node.id, node.parent);
1256
+ }
1257
+ }
1258
+ // Recalculate bounds for each subgraph
1259
+ result.subgraphs.forEach((layoutSubgraph, subgraphId) => {
1260
+ const childNodes = [];
1261
+ // Find all nodes that belong to this subgraph
1262
+ result.nodes.forEach((layoutNode, nodeId) => {
1263
+ if (nodeToSubgraph.get(nodeId) === subgraphId) {
1264
+ childNodes.push(layoutNode);
1265
+ }
1266
+ });
1267
+ if (childNodes.length === 0)
1268
+ return;
1269
+ // Calculate bounding box of all child nodes
1270
+ let minX = Infinity, minY = Infinity;
1271
+ let maxX = -Infinity, maxY = -Infinity;
1272
+ for (const node of childNodes) {
1273
+ const left = node.position.x - node.size.width / 2;
1274
+ const right = node.position.x + node.size.width / 2;
1275
+ const top = node.position.y - node.size.height / 2;
1276
+ const bottom = node.position.y + node.size.height / 2;
1277
+ minX = Math.min(minX, left);
1278
+ minY = Math.min(minY, top);
1279
+ maxX = Math.max(maxX, right);
1280
+ maxY = Math.max(maxY, bottom);
1281
+ }
1282
+ // Apply padding
1283
+ layoutSubgraph.bounds = {
1284
+ x: minX - padding,
1285
+ y: minY - padding - labelHeight,
1286
+ width: (maxX - minX) + padding * 2,
1287
+ height: (maxY - minY) + padding * 2 + labelHeight,
1288
+ };
1289
+ });
1290
+ // Update overall bounds
1291
+ let minX = Infinity, minY = Infinity;
1292
+ let maxX = -Infinity, maxY = -Infinity;
1293
+ result.nodes.forEach((node) => {
1294
+ const left = node.position.x - node.size.width / 2;
1295
+ const right = node.position.x + node.size.width / 2;
1296
+ const top = node.position.y - node.size.height / 2;
1297
+ const bottom = node.position.y + node.size.height / 2;
1298
+ minX = Math.min(minX, left);
1299
+ minY = Math.min(minY, top);
1300
+ maxX = Math.max(maxX, right);
1301
+ maxY = Math.max(maxY, bottom);
1302
+ });
1303
+ result.subgraphs.forEach((sg) => {
1304
+ minX = Math.min(minX, sg.bounds.x);
1305
+ minY = Math.min(minY, sg.bounds.y);
1306
+ maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width);
1307
+ maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height);
1308
+ });
1309
+ const margin = 40;
1310
+ result.bounds = {
1311
+ x: minX - margin,
1312
+ y: minY - margin,
1313
+ width: (maxX - minX) + margin * 2,
1314
+ height: (maxY - minY) + margin * 2,
1315
+ };
1316
+ }
1317
+ }
1318
+ // Default instance
1319
+ export const hierarchicalLayout = new HierarchicalLayout();
1320
+ //# sourceMappingURL=hierarchical.js.map