@shumoku/core 0.1.0 → 0.2.0

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