@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.
- package/dist/constants.d.ts +23 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +25 -0
- package/dist/constants.js.map +1 -0
- package/dist/icons/build-icons.js +3 -3
- package/dist/icons/build-icons.js.map +1 -1
- package/dist/icons/generated-icons.js +10 -10
- package/dist/icons/generated-icons.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/layout/hierarchical.d.ts +13 -40
- package/dist/layout/hierarchical.d.ts.map +1 -1
- package/dist/layout/hierarchical.js +726 -1028
- package/dist/layout/hierarchical.js.map +1 -1
- package/dist/layout/index.d.ts +1 -1
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js.map +1 -1
- package/dist/models/types.d.ts +30 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +13 -13
- package/dist/models/types.js.map +1 -1
- package/dist/themes/dark.d.ts.map +1 -1
- package/dist/themes/dark.js +1 -1
- package/dist/themes/dark.js.map +1 -1
- package/dist/themes/index.d.ts +3 -3
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +4 -4
- package/dist/themes/index.js.map +1 -1
- package/dist/themes/modern.d.ts.map +1 -1
- package/dist/themes/modern.js.map +1 -1
- package/dist/themes/types.d.ts.map +1 -1
- package/dist/themes/utils.d.ts +1 -1
- package/dist/themes/utils.d.ts.map +1 -1
- package/dist/themes/utils.js +5 -4
- package/dist/themes/utils.js.map +1 -1
- package/package.json +88 -92
- package/src/constants.ts +35 -0
- package/src/icons/build-icons.ts +12 -6
- package/src/icons/generated-icons.ts +12 -12
- package/src/index.test.ts +66 -0
- package/src/index.ts +6 -10
- package/src/layout/hierarchical.ts +1251 -1543
- package/src/layout/index.ts +1 -1
- package/src/models/types.ts +84 -37
- package/src/themes/dark.ts +15 -15
- package/src/themes/index.ts +7 -7
- package/src/themes/modern.ts +22 -22
- package/src/themes/types.ts +26 -26
- package/src/themes/utils.ts +25 -24
- package/dist/renderer/components/index.d.ts +0 -8
- package/dist/renderer/components/index.d.ts.map +0 -1
- package/dist/renderer/components/index.js +0 -8
- package/dist/renderer/components/index.js.map +0 -1
- package/dist/renderer/components/link-renderer.d.ts +0 -11
- package/dist/renderer/components/link-renderer.d.ts.map +0 -1
- package/dist/renderer/components/link-renderer.js +0 -340
- package/dist/renderer/components/link-renderer.js.map +0 -1
- package/dist/renderer/components/node-renderer.d.ts +0 -14
- package/dist/renderer/components/node-renderer.d.ts.map +0 -1
- package/dist/renderer/components/node-renderer.js +0 -242
- package/dist/renderer/components/node-renderer.js.map +0 -1
- package/dist/renderer/components/port-renderer.d.ts +0 -8
- package/dist/renderer/components/port-renderer.d.ts.map +0 -1
- package/dist/renderer/components/port-renderer.js +0 -85
- package/dist/renderer/components/port-renderer.js.map +0 -1
- package/dist/renderer/components/subgraph-renderer.d.ts +0 -13
- package/dist/renderer/components/subgraph-renderer.d.ts.map +0 -1
- package/dist/renderer/components/subgraph-renderer.js +0 -85
- package/dist/renderer/components/subgraph-renderer.js.map +0 -1
- package/dist/renderer/icon-registry/index.d.ts +0 -6
- package/dist/renderer/icon-registry/index.d.ts.map +0 -1
- package/dist/renderer/icon-registry/index.js +0 -5
- package/dist/renderer/icon-registry/index.js.map +0 -1
- package/dist/renderer/icon-registry/registry.d.ts +0 -25
- package/dist/renderer/icon-registry/registry.d.ts.map +0 -1
- package/dist/renderer/icon-registry/registry.js +0 -85
- package/dist/renderer/icon-registry/registry.js.map +0 -1
- package/dist/renderer/icon-registry/types.d.ts +0 -44
- package/dist/renderer/icon-registry/types.d.ts.map +0 -1
- package/dist/renderer/icon-registry/types.js +0 -5
- package/dist/renderer/icon-registry/types.js.map +0 -1
- package/dist/renderer/index.d.ts +0 -6
- package/dist/renderer/index.d.ts.map +0 -1
- package/dist/renderer/index.js +0 -5
- package/dist/renderer/index.js.map +0 -1
- package/dist/renderer/render-model/builder.d.ts +0 -43
- package/dist/renderer/render-model/builder.d.ts.map +0 -1
- package/dist/renderer/render-model/builder.js +0 -646
- package/dist/renderer/render-model/builder.js.map +0 -1
- package/dist/renderer/render-model/index.d.ts +0 -6
- package/dist/renderer/render-model/index.d.ts.map +0 -1
- package/dist/renderer/render-model/index.js +0 -5
- package/dist/renderer/render-model/index.js.map +0 -1
- package/dist/renderer/render-model/types.d.ts +0 -216
- package/dist/renderer/render-model/types.d.ts.map +0 -1
- package/dist/renderer/render-model/types.js +0 -6
- package/dist/renderer/render-model/types.js.map +0 -1
- package/dist/renderer/renderer-types.d.ts +0 -55
- package/dist/renderer/renderer-types.d.ts.map +0 -1
- package/dist/renderer/renderer-types.js +0 -5
- package/dist/renderer/renderer-types.js.map +0 -1
- package/dist/renderer/svg-builder.d.ts +0 -152
- package/dist/renderer/svg-builder.d.ts.map +0 -1
- package/dist/renderer/svg-builder.js +0 -176
- package/dist/renderer/svg-builder.js.map +0 -1
- package/dist/renderer/svg-dom/builders/defs.d.ts +0 -10
- package/dist/renderer/svg-dom/builders/defs.d.ts.map +0 -1
- package/dist/renderer/svg-dom/builders/defs.js +0 -82
- package/dist/renderer/svg-dom/builders/defs.js.map +0 -1
- package/dist/renderer/svg-dom/builders/index.d.ts +0 -9
- package/dist/renderer/svg-dom/builders/index.d.ts.map +0 -1
- package/dist/renderer/svg-dom/builders/index.js +0 -9
- package/dist/renderer/svg-dom/builders/index.js.map +0 -1
- package/dist/renderer/svg-dom/builders/link.d.ts +0 -18
- package/dist/renderer/svg-dom/builders/link.d.ts.map +0 -1
- package/dist/renderer/svg-dom/builders/link.js +0 -188
- package/dist/renderer/svg-dom/builders/link.js.map +0 -1
- package/dist/renderer/svg-dom/builders/node.d.ts +0 -15
- package/dist/renderer/svg-dom/builders/node.d.ts.map +0 -1
- package/dist/renderer/svg-dom/builders/node.js +0 -262
- package/dist/renderer/svg-dom/builders/node.js.map +0 -1
- package/dist/renderer/svg-dom/builders/subgraph.d.ts +0 -14
- package/dist/renderer/svg-dom/builders/subgraph.d.ts.map +0 -1
- package/dist/renderer/svg-dom/builders/subgraph.js +0 -63
- package/dist/renderer/svg-dom/builders/subgraph.js.map +0 -1
- package/dist/renderer/svg-dom/builders/utils.d.ts +0 -40
- package/dist/renderer/svg-dom/builders/utils.d.ts.map +0 -1
- package/dist/renderer/svg-dom/builders/utils.js +0 -79
- package/dist/renderer/svg-dom/builders/utils.js.map +0 -1
- package/dist/renderer/svg-dom/index.d.ts +0 -9
- package/dist/renderer/svg-dom/index.d.ts.map +0 -1
- package/dist/renderer/svg-dom/index.js +0 -7
- package/dist/renderer/svg-dom/index.js.map +0 -1
- package/dist/renderer/svg-dom/interaction.d.ts +0 -69
- package/dist/renderer/svg-dom/interaction.d.ts.map +0 -1
- package/dist/renderer/svg-dom/interaction.js +0 -296
- package/dist/renderer/svg-dom/interaction.js.map +0 -1
- package/dist/renderer/svg-dom/renderer.d.ts +0 -47
- package/dist/renderer/svg-dom/renderer.d.ts.map +0 -1
- package/dist/renderer/svg-dom/renderer.js +0 -188
- package/dist/renderer/svg-dom/renderer.js.map +0 -1
- package/dist/renderer/svg-string/builders/defs.d.ts +0 -10
- package/dist/renderer/svg-string/builders/defs.d.ts.map +0 -1
- package/dist/renderer/svg-string/builders/defs.js +0 -43
- package/dist/renderer/svg-string/builders/defs.js.map +0 -1
- package/dist/renderer/svg-string/builders/link.d.ts +0 -10
- package/dist/renderer/svg-string/builders/link.d.ts.map +0 -1
- package/dist/renderer/svg-string/builders/link.js +0 -149
- package/dist/renderer/svg-string/builders/link.js.map +0 -1
- package/dist/renderer/svg-string/builders/node.d.ts +0 -10
- package/dist/renderer/svg-string/builders/node.d.ts.map +0 -1
- package/dist/renderer/svg-string/builders/node.js +0 -134
- package/dist/renderer/svg-string/builders/node.js.map +0 -1
- package/dist/renderer/svg-string/builders/subgraph.d.ts +0 -10
- package/dist/renderer/svg-string/builders/subgraph.d.ts.map +0 -1
- package/dist/renderer/svg-string/builders/subgraph.js +0 -59
- package/dist/renderer/svg-string/builders/subgraph.js.map +0 -1
- package/dist/renderer/svg-string/index.d.ts +0 -5
- package/dist/renderer/svg-string/index.d.ts.map +0 -1
- package/dist/renderer/svg-string/index.js +0 -5
- package/dist/renderer/svg-string/index.js.map +0 -1
- package/dist/renderer/svg-string/renderer.d.ts +0 -17
- package/dist/renderer/svg-string/renderer.d.ts.map +0 -1
- package/dist/renderer/svg-string/renderer.js +0 -53
- package/dist/renderer/svg-string/renderer.js.map +0 -1
- package/dist/renderer/svg.d.ts +0 -105
- package/dist/renderer/svg.d.ts.map +0 -1
- package/dist/renderer/svg.js +0 -804
- package/dist/renderer/svg.js.map +0 -1
- package/dist/renderer/text-measurer/browser-measurer.d.ts +0 -25
- package/dist/renderer/text-measurer/browser-measurer.d.ts.map +0 -1
- package/dist/renderer/text-measurer/browser-measurer.js +0 -85
- package/dist/renderer/text-measurer/browser-measurer.js.map +0 -1
- package/dist/renderer/text-measurer/fallback-measurer.d.ts +0 -22
- package/dist/renderer/text-measurer/fallback-measurer.d.ts.map +0 -1
- package/dist/renderer/text-measurer/fallback-measurer.js +0 -113
- package/dist/renderer/text-measurer/fallback-measurer.js.map +0 -1
- package/dist/renderer/text-measurer/index.d.ts +0 -13
- package/dist/renderer/text-measurer/index.d.ts.map +0 -1
- package/dist/renderer/text-measurer/index.js +0 -35
- package/dist/renderer/text-measurer/index.js.map +0 -1
- package/dist/renderer/text-measurer/types.d.ts +0 -30
- package/dist/renderer/text-measurer/types.d.ts.map +0 -1
- package/dist/renderer/text-measurer/types.js +0 -5
- package/dist/renderer/text-measurer/types.js.map +0 -1
- package/dist/renderer/theme.d.ts +0 -29
- package/dist/renderer/theme.d.ts.map +0 -1
- package/dist/renderer/theme.js +0 -80
- package/dist/renderer/theme.js.map +0 -1
- package/src/renderer/index.ts +0 -6
- 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
|
-
|
|
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
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
57
|
-
rankSpacing:
|
|
58
|
-
subgraphPadding:
|
|
59
|
-
subgraphLabelHeight:
|
|
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
|
-
*
|
|
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 ||
|
|
80
|
-
rankSpacing: settings?.rankSpacing ||
|
|
81
|
-
subgraphPadding: settings?.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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
96
|
-
const result = this.
|
|
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
|
|
152
|
+
algorithm: 'elk-layered',
|
|
103
153
|
duration: performance.now() - startTime,
|
|
104
154
|
};
|
|
105
155
|
return result;
|
|
106
156
|
}
|
|
107
157
|
/**
|
|
108
|
-
* Build ELK graph
|
|
158
|
+
* Build ELK graph - uses container nodes for HA pairs
|
|
109
159
|
*/
|
|
110
|
-
|
|
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
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
186
|
+
width,
|
|
185
187
|
height,
|
|
186
188
|
labels: [{ text: Array.isArray(node.label) ? node.label.join('\n') : node.label }],
|
|
187
189
|
};
|
|
188
|
-
// Add ports
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
id:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
269
|
-
|
|
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
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
//
|
|
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 (
|
|
371
|
-
labelParts.push(
|
|
372
|
-
if (
|
|
373
|
-
labelParts.push(
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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':
|
|
389
|
-
'elk.spacing.edgeEdge':
|
|
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': '
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
495
|
+
* Extract layout result from ELK output - uses ELK's edge routing directly
|
|
407
496
|
*/
|
|
408
|
-
|
|
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
|
|
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
|
-
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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:
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
//
|
|
652
|
-
|
|
653
|
-
//
|
|
654
|
-
graph.links.
|
|
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
|
-
|
|
708
|
+
if (layoutLinks.has(id))
|
|
709
|
+
continue;
|
|
710
|
+
const fromEndpoint = toEndpoint(link.from);
|
|
657
711
|
const toEndpoint_ = toEndpoint(link.to);
|
|
658
|
-
const
|
|
659
|
-
const
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
const
|
|
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:
|
|
673
|
-
to:
|
|
674
|
-
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
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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':
|
|
797
|
-
|
|
798
|
-
case '
|
|
799
|
-
|
|
800
|
-
|
|
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
|
|
806
|
-
const
|
|
807
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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 =
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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 =
|
|
810
|
+
iconHeight = DEFAULT_ICON_SIZE;
|
|
850
811
|
}
|
|
851
812
|
}
|
|
852
813
|
}
|
|
853
814
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
iconHeight = 36;
|
|
815
|
+
if (iconHeight === 0 && node.type && getDeviceIcon(node.type)) {
|
|
816
|
+
iconHeight = DEFAULT_ICON_SIZE;
|
|
857
817
|
}
|
|
858
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
//
|
|
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
|
|
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 +
|
|
971
|
-
size: { width
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1051
|
-
if (
|
|
992
|
+
const key = [fromId, toId].sort().join(':');
|
|
993
|
+
if (processed.has(key))
|
|
1052
994
|
continue;
|
|
1053
|
-
pairs.push({
|
|
1054
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|