@shumoku/core 0.1.1 → 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.
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/hierarchical.d.ts +12 -39
- package/dist/layout/hierarchical.d.ts.map +1 -1
- package/dist/layout/hierarchical.js +697 -1015
- package/dist/layout/hierarchical.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.map +1 -1
- package/dist/renderer/svg.d.ts +31 -5
- package/dist/renderer/svg.d.ts.map +1 -1
- package/dist/renderer/svg.js +312 -85
- package/dist/renderer/svg.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +35 -0
- package/src/index.ts +3 -0
- package/src/layout/hierarchical.ts +805 -1127
- package/src/models/types.ts +37 -0
- package/src/renderer/svg.ts +368 -88
- 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/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/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
|
@@ -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
|
-
|
|
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
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
57
|
-
rankSpacing:
|
|
58
|
-
subgraphPadding:
|
|
59
|
-
subgraphLabelHeight:
|
|
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
|
-
*
|
|
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 ||
|
|
80
|
-
rankSpacing: settings?.rankSpacing ||
|
|
81
|
-
subgraphPadding: settings?.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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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);
|
|
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
|
|
146
|
+
algorithm: 'elk-layered',
|
|
103
147
|
duration: performance.now() - startTime,
|
|
104
148
|
};
|
|
105
149
|
return result;
|
|
106
150
|
}
|
|
107
151
|
/**
|
|
108
|
-
* Build ELK graph
|
|
152
|
+
* Build ELK graph - uses container nodes for HA pairs
|
|
109
153
|
*/
|
|
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);
|
|
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
|
|
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);
|
|
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
|
|
180
|
+
width,
|
|
185
181
|
height,
|
|
186
182
|
labels: [{ text: Array.isArray(node.label) ? node.label.join('\n') : node.label }],
|
|
187
183
|
};
|
|
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}`,
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
id:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
//
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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));
|
|
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
|
|
269
|
-
|
|
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
|
|
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
|
|
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;
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
//
|
|
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 (
|
|
371
|
-
labelParts.push(
|
|
372
|
-
if (
|
|
373
|
-
labelParts.push(
|
|
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
|
-
|
|
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
|
-
//
|
|
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':
|
|
389
|
-
'elk.spacing.edgeEdge':
|
|
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': '
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
487
|
+
* Extract layout result from ELK output - uses ELK's edge routing directly
|
|
407
488
|
*/
|
|
408
|
-
|
|
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
|
|
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
|
-
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
|
521
|
+
processElkNode(child);
|
|
578
522
|
}
|
|
579
523
|
}
|
|
580
524
|
}
|
|
581
|
-
else {
|
|
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
|
-
|
|
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
|
|
594
|
+
processElkNode(child);
|
|
649
595
|
}
|
|
650
596
|
}
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
698
|
+
if (layoutLinks.has(id))
|
|
699
|
+
return;
|
|
700
|
+
const fromEndpoint = toEndpoint(link.from);
|
|
657
701
|
const toEndpoint_ = toEndpoint(link.to);
|
|
658
|
-
const
|
|
659
|
-
const
|
|
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
|
-
|
|
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);
|
|
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:
|
|
673
|
-
to:
|
|
674
|
-
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
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
806
|
-
const
|
|
807
|
-
|
|
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
|
|
826
|
-
|
|
827
|
-
|
|
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 =
|
|
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
|
|
843
|
-
|
|
844
|
-
|
|
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 =
|
|
793
|
+
iconHeight = DEFAULT_ICON_SIZE;
|
|
850
794
|
}
|
|
851
795
|
}
|
|
852
796
|
}
|
|
853
797
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
iconHeight = 36;
|
|
798
|
+
if (iconHeight === 0 && node.type && getDeviceIcon(node.type)) {
|
|
799
|
+
iconHeight = DEFAULT_ICON_SIZE;
|
|
857
800
|
}
|
|
858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
929
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
//
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const
|
|
963
|
-
|
|
964
|
-
|
|
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
|
|
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 +
|
|
971
|
-
size: { width
|
|
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
|
-
|
|
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
|
|
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
|
|
1051
|
-
if (
|
|
971
|
+
const key = [fromId, toId].sort().join(':');
|
|
972
|
+
if (processed.has(key))
|
|
1052
973
|
continue;
|
|
1053
|
-
pairs.push({
|
|
1054
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|