@shumoku/core 0.1.1 → 0.2.1

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