@shumoku/core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- 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
|
@@ -18,15 +18,51 @@ import {
|
|
|
18
18
|
type LinkEndpoint,
|
|
19
19
|
getNodeId,
|
|
20
20
|
} from '../models/index.js'
|
|
21
|
-
import { getVendorIconEntry } from '../icons/index.js'
|
|
21
|
+
import { getVendorIconEntry, getDeviceIcon } from '../icons/index.js'
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_ICON_SIZE,
|
|
24
|
+
ICON_LABEL_GAP,
|
|
25
|
+
LABEL_LINE_HEIGHT,
|
|
26
|
+
NODE_VERTICAL_PADDING,
|
|
27
|
+
NODE_HORIZONTAL_PADDING,
|
|
28
|
+
MIN_PORT_SPACING,
|
|
29
|
+
PORT_LABEL_FONT_SIZE,
|
|
30
|
+
CHAR_WIDTH_RATIO,
|
|
31
|
+
PORT_LABEL_PADDING,
|
|
32
|
+
ESTIMATED_CHAR_WIDTH,
|
|
33
|
+
MAX_ICON_WIDTH_RATIO,
|
|
34
|
+
} from '../constants.js'
|
|
35
|
+
|
|
36
|
+
// ============================================
|
|
37
|
+
// Types
|
|
38
|
+
// ============================================
|
|
39
|
+
|
|
40
|
+
/** ELK edge section for edge routing */
|
|
41
|
+
interface ElkEdgeSection {
|
|
42
|
+
startPoint: { x: number; y: number }
|
|
43
|
+
endPoint: { x: number; y: number }
|
|
44
|
+
bendPoints?: Array<{ x: number; y: number }>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Extended ELK edge with sections */
|
|
48
|
+
interface ElkEdgeWithSections {
|
|
49
|
+
id: string
|
|
50
|
+
sections?: ElkEdgeSection[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Port info for a node */
|
|
54
|
+
interface NodePortInfo {
|
|
55
|
+
all: Set<string>
|
|
56
|
+
top: Set<string>
|
|
57
|
+
bottom: Set<string>
|
|
58
|
+
left: Set<string>
|
|
59
|
+
right: Set<string>
|
|
60
|
+
}
|
|
22
61
|
|
|
23
62
|
// ============================================
|
|
24
63
|
// Helper Functions
|
|
25
64
|
// ============================================
|
|
26
65
|
|
|
27
|
-
/**
|
|
28
|
-
* Convert endpoint to full LinkEndpoint object
|
|
29
|
-
*/
|
|
30
66
|
function toEndpoint(endpoint: string | LinkEndpoint): LinkEndpoint {
|
|
31
67
|
if (typeof endpoint === 'string') {
|
|
32
68
|
return { node: endpoint }
|
|
@@ -34,38 +70,54 @@ function toEndpoint(endpoint: string | LinkEndpoint): LinkEndpoint {
|
|
|
34
70
|
return endpoint
|
|
35
71
|
}
|
|
36
72
|
|
|
37
|
-
/**
|
|
38
|
-
* Collect all ports for each node from links
|
|
39
|
-
* @param haNodePairs - Set of "nodeA:nodeB" strings for HA pairs to exclude their ports
|
|
40
|
-
*/
|
|
73
|
+
/** Collect ports for each node from links */
|
|
41
74
|
function collectNodePorts(
|
|
42
75
|
graph: NetworkGraph,
|
|
43
|
-
|
|
44
|
-
): Map<string,
|
|
45
|
-
const nodePorts = new Map<string,
|
|
76
|
+
haPairSet: Set<string>
|
|
77
|
+
): Map<string, NodePortInfo> {
|
|
78
|
+
const nodePorts = new Map<string, NodePortInfo>()
|
|
79
|
+
|
|
80
|
+
const getOrCreate = (nodeId: string): NodePortInfo => {
|
|
81
|
+
if (!nodePorts.has(nodeId)) {
|
|
82
|
+
nodePorts.set(nodeId, { all: new Set(), top: new Set(), bottom: new Set(), left: new Set(), right: new Set() })
|
|
83
|
+
}
|
|
84
|
+
return nodePorts.get(nodeId)!
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check if link is between HA pair nodes
|
|
88
|
+
const isHALink = (fromNode: string, toNode: string): boolean => {
|
|
89
|
+
const key = [fromNode, toNode].sort().join(':')
|
|
90
|
+
return haPairSet.has(key)
|
|
91
|
+
}
|
|
46
92
|
|
|
47
93
|
for (const link of graph.links) {
|
|
48
|
-
const
|
|
49
|
-
const
|
|
94
|
+
const from = toEndpoint(link.from)
|
|
95
|
+
const to = toEndpoint(link.to)
|
|
50
96
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
97
|
+
if (link.redundancy && isHALink(from.node, to.node)) {
|
|
98
|
+
// HA links: create side ports (left/right)
|
|
99
|
+
const fromPortName = from.port || 'ha'
|
|
100
|
+
const toPortName = to.port || 'ha'
|
|
56
101
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
nodePorts.get(fromEndpoint.node)!.add(fromEndpoint.port)
|
|
62
|
-
}
|
|
102
|
+
const fromInfo = getOrCreate(from.node)
|
|
103
|
+
fromInfo.all.add(fromPortName)
|
|
104
|
+
fromInfo.right.add(fromPortName)
|
|
63
105
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
106
|
+
const toInfo = getOrCreate(to.node)
|
|
107
|
+
toInfo.all.add(toPortName)
|
|
108
|
+
toInfo.left.add(toPortName)
|
|
109
|
+
} else {
|
|
110
|
+
// Normal links: ports on top/bottom
|
|
111
|
+
if (from.port) {
|
|
112
|
+
const info = getOrCreate(from.node)
|
|
113
|
+
info.all.add(from.port)
|
|
114
|
+
info.bottom.add(from.port)
|
|
115
|
+
}
|
|
116
|
+
if (to.port) {
|
|
117
|
+
const info = getOrCreate(to.node)
|
|
118
|
+
info.all.add(to.port)
|
|
119
|
+
info.top.add(to.port)
|
|
67
120
|
}
|
|
68
|
-
nodePorts.get(toEndpoint_.node)!.add(toEndpoint_.port)
|
|
69
121
|
}
|
|
70
122
|
}
|
|
71
123
|
|
|
@@ -94,10 +146,10 @@ const DEFAULT_OPTIONS: Required<HierarchicalLayoutOptions> = {
|
|
|
94
146
|
direction: 'TB',
|
|
95
147
|
nodeWidth: 180,
|
|
96
148
|
nodeHeight: 60,
|
|
97
|
-
nodeSpacing:
|
|
98
|
-
rankSpacing:
|
|
99
|
-
subgraphPadding:
|
|
100
|
-
subgraphLabelHeight:
|
|
149
|
+
nodeSpacing: 40,
|
|
150
|
+
rankSpacing: 60,
|
|
151
|
+
subgraphPadding: 24,
|
|
152
|
+
subgraphLabelHeight: 24,
|
|
101
153
|
}
|
|
102
154
|
|
|
103
155
|
// ============================================
|
|
@@ -114,51 +166,81 @@ export class HierarchicalLayout {
|
|
|
114
166
|
}
|
|
115
167
|
|
|
116
168
|
/**
|
|
117
|
-
*
|
|
169
|
+
* Calculate dynamic spacing based on graph complexity
|
|
118
170
|
*/
|
|
171
|
+
private calculateDynamicSpacing(graph: NetworkGraph): { nodeSpacing: number; rankSpacing: number; subgraphPadding: number } {
|
|
172
|
+
const nodeCount = graph.nodes.length
|
|
173
|
+
const linkCount = graph.links.length
|
|
174
|
+
const subgraphCount = graph.subgraphs?.length || 0
|
|
175
|
+
|
|
176
|
+
let portCount = 0
|
|
177
|
+
let maxPortLabelLength = 0
|
|
178
|
+
for (const link of graph.links) {
|
|
179
|
+
if (typeof link.from !== 'string' && link.from.port) {
|
|
180
|
+
portCount++
|
|
181
|
+
maxPortLabelLength = Math.max(maxPortLabelLength, link.from.port.length)
|
|
182
|
+
}
|
|
183
|
+
if (typeof link.to !== 'string' && link.to.port) {
|
|
184
|
+
portCount++
|
|
185
|
+
maxPortLabelLength = Math.max(maxPortLabelLength, link.to.port.length)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const avgPortsPerNode = nodeCount > 0 ? portCount / nodeCount : 0
|
|
190
|
+
const complexity = nodeCount * 1.0 + linkCount * 0.8 + portCount * 0.3 + subgraphCount * 2
|
|
191
|
+
const portDensityFactor = Math.min(1.5, 1 + avgPortsPerNode * 0.1)
|
|
192
|
+
const rawSpacing = Math.max(20, Math.min(60, 80 - complexity * 1.2))
|
|
193
|
+
const baseSpacing = rawSpacing * portDensityFactor
|
|
194
|
+
|
|
195
|
+
const portLabelProtrusion = portCount > 0 ? 28 : 0
|
|
196
|
+
const portLabelWidth = maxPortLabelLength * PORT_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO
|
|
197
|
+
const minRankSpacing = Math.max(portLabelWidth, portLabelProtrusion) + 16
|
|
198
|
+
const minSubgraphPadding = portLabelProtrusion + 8
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
nodeSpacing: Math.round(baseSpacing),
|
|
202
|
+
rankSpacing: Math.round(Math.max(baseSpacing * 1.5, minRankSpacing)),
|
|
203
|
+
subgraphPadding: Math.round(Math.max(baseSpacing * 0.6, minSubgraphPadding)),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
119
207
|
private getEffectiveOptions(graph: NetworkGraph): Required<HierarchicalLayoutOptions> {
|
|
120
208
|
const settings = graph.settings
|
|
209
|
+
const dynamicSpacing = this.calculateDynamicSpacing(graph)
|
|
121
210
|
|
|
122
211
|
return {
|
|
123
212
|
...this.options,
|
|
124
213
|
direction: settings?.direction || this.options.direction,
|
|
125
|
-
nodeSpacing: settings?.nodeSpacing ||
|
|
126
|
-
rankSpacing: settings?.rankSpacing ||
|
|
127
|
-
subgraphPadding: settings?.subgraphPadding ||
|
|
214
|
+
nodeSpacing: settings?.nodeSpacing || dynamicSpacing.nodeSpacing,
|
|
215
|
+
rankSpacing: settings?.rankSpacing || dynamicSpacing.rankSpacing,
|
|
216
|
+
subgraphPadding: settings?.subgraphPadding || dynamicSpacing.subgraphPadding,
|
|
128
217
|
}
|
|
129
218
|
}
|
|
130
219
|
|
|
131
220
|
async layoutAsync(graph: NetworkGraph): Promise<LayoutResult> {
|
|
132
221
|
const startTime = performance.now()
|
|
222
|
+
const options = this.getEffectiveOptions(graph)
|
|
133
223
|
|
|
134
|
-
//
|
|
135
|
-
const effectiveOptions = this.getEffectiveOptions(graph)
|
|
136
|
-
const direction = effectiveOptions.direction
|
|
137
|
-
|
|
138
|
-
// Detect HA pairs first (before building ELK graph)
|
|
224
|
+
// Detect HA pairs first (needed for port assignment)
|
|
139
225
|
const haPairs = this.detectHAPairs(graph)
|
|
226
|
+
const haPairSet = new Set<string>()
|
|
227
|
+
for (const pair of haPairs) {
|
|
228
|
+
haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const nodePorts = collectNodePorts(graph, haPairSet)
|
|
140
232
|
|
|
141
|
-
// Build ELK graph
|
|
142
|
-
const
|
|
143
|
-
graph, direction, effectiveOptions, haPairs
|
|
144
|
-
)
|
|
233
|
+
// Build ELK graph
|
|
234
|
+
const elkGraph = this.buildElkGraph(graph, options, nodePorts, haPairs)
|
|
145
235
|
|
|
146
236
|
// Run ELK layout
|
|
147
237
|
const layoutedGraph = await this.elk.layout(elkGraph)
|
|
148
238
|
|
|
149
|
-
// Extract results
|
|
150
|
-
const result = this.
|
|
151
|
-
graph, layoutedGraph, haVirtualNodes, effectiveOptions
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
// Adjust node distances based on link minLength (for non-HA links)
|
|
155
|
-
this.adjustLinkDistances(result, graph, direction)
|
|
156
|
-
|
|
157
|
-
// Recalculate subgraph bounds after node adjustments
|
|
158
|
-
this.recalculateSubgraphBounds(result, graph)
|
|
239
|
+
// Extract results using ELK's positions and edge routes
|
|
240
|
+
const result = this.extractLayoutResult(graph, layoutedGraph, nodePorts, options)
|
|
159
241
|
|
|
160
242
|
result.metadata = {
|
|
161
|
-
algorithm: 'elk-layered
|
|
243
|
+
algorithm: 'elk-layered',
|
|
162
244
|
duration: performance.now() - startTime,
|
|
163
245
|
}
|
|
164
246
|
|
|
@@ -166,64 +248,15 @@ export class HierarchicalLayout {
|
|
|
166
248
|
}
|
|
167
249
|
|
|
168
250
|
/**
|
|
169
|
-
* Build ELK graph
|
|
251
|
+
* Build ELK graph - uses container nodes for HA pairs
|
|
170
252
|
*/
|
|
171
|
-
private
|
|
253
|
+
private buildElkGraph(
|
|
172
254
|
graph: NetworkGraph,
|
|
173
|
-
direction: LayoutDirection,
|
|
174
255
|
options: Required<HierarchicalLayoutOptions>,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
// Build set of nodes in HA pairs
|
|
181
|
-
const nodesInHAPairs = new Set<string>()
|
|
182
|
-
for (const pair of haPairs) {
|
|
183
|
-
nodesInHAPairs.add(pair.nodeA)
|
|
184
|
-
nodesInHAPairs.add(pair.nodeB)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Build node map for quick lookup
|
|
188
|
-
const nodeMap = new Map<string, Node>()
|
|
189
|
-
for (const node of graph.nodes) {
|
|
190
|
-
nodeMap.set(node.id, node)
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Create virtual nodes for HA pairs
|
|
194
|
-
const gap = 40 // Gap between HA pair nodes
|
|
195
|
-
for (let i = 0; i < haPairs.length; i++) {
|
|
196
|
-
const pair = haPairs[i]
|
|
197
|
-
const nodeA = nodeMap.get(pair.nodeA)
|
|
198
|
-
const nodeB = nodeMap.get(pair.nodeB)
|
|
199
|
-
if (!nodeA || !nodeB) continue
|
|
200
|
-
|
|
201
|
-
const virtualId = `__ha_virtual_${i}`
|
|
202
|
-
const widthA = options.nodeWidth
|
|
203
|
-
const widthB = options.nodeWidth
|
|
204
|
-
const heightA = this.calculateNodeHeight(nodeA)
|
|
205
|
-
const heightB = this.calculateNodeHeight(nodeB)
|
|
206
|
-
|
|
207
|
-
haVirtualNodes.set(virtualId, {
|
|
208
|
-
virtualId,
|
|
209
|
-
nodeA,
|
|
210
|
-
nodeB,
|
|
211
|
-
gap: pair.minLength ?? gap,
|
|
212
|
-
widthA,
|
|
213
|
-
widthB,
|
|
214
|
-
height: Math.max(heightA, heightB),
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Build edge redirect map: original node -> virtual node + side (A or B)
|
|
219
|
-
const edgeRedirect = new Map<string, { virtualId: string; side: 'A' | 'B' }>()
|
|
220
|
-
for (const [virtualId, info] of haVirtualNodes) {
|
|
221
|
-
edgeRedirect.set(info.nodeA.id, { virtualId, side: 'A' })
|
|
222
|
-
edgeRedirect.set(info.nodeB.id, { virtualId, side: 'B' })
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Collect all ports for each node from links
|
|
226
|
-
const nodePorts = collectNodePorts(graph)
|
|
256
|
+
nodePorts: Map<string, NodePortInfo>,
|
|
257
|
+
haPairs: { nodeA: string; nodeB: string }[]
|
|
258
|
+
): ElkNode {
|
|
259
|
+
const elkDirection = this.toElkDirection(options.direction)
|
|
227
260
|
|
|
228
261
|
// Build subgraph map
|
|
229
262
|
const subgraphMap = new Map<string, Subgraph>()
|
|
@@ -233,289 +266,379 @@ export class HierarchicalLayout {
|
|
|
233
266
|
}
|
|
234
267
|
}
|
|
235
268
|
|
|
236
|
-
// Build node
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
269
|
+
// Build HA container map: node ID -> container ID
|
|
270
|
+
const nodeToHAContainer = new Map<string, string>()
|
|
271
|
+
const haPairMap = new Map<string, { nodeA: string; nodeB: string }>()
|
|
272
|
+
haPairs.forEach((pair, idx) => {
|
|
273
|
+
const containerId = `__ha_container_${idx}`
|
|
274
|
+
nodeToHAContainer.set(pair.nodeA, containerId)
|
|
275
|
+
nodeToHAContainer.set(pair.nodeB, containerId)
|
|
276
|
+
haPairMap.set(containerId, pair)
|
|
277
|
+
})
|
|
243
278
|
|
|
244
|
-
//
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (sg.parent && subgraphMap.has(sg.parent)) {
|
|
251
|
-
subgraphChildren.get(sg.parent)!.push(sg.id)
|
|
252
|
-
}
|
|
253
|
-
}
|
|
279
|
+
// Create ELK node
|
|
280
|
+
const createElkNode = (node: Node): ElkNode => {
|
|
281
|
+
const portInfo = nodePorts.get(node.id)
|
|
282
|
+
const portCount = portInfo?.all.size || 0
|
|
283
|
+
const height = this.calculateNodeHeight(node, portCount)
|
|
284
|
+
const width = this.calculateNodeWidth(node, portInfo)
|
|
254
285
|
|
|
255
|
-
// Create ELK node for regular nodes
|
|
256
|
-
const createElkNode_ = (node: Node): ElkNode => {
|
|
257
|
-
const height = this.calculateNodeHeight(node)
|
|
258
286
|
const elkNode: ElkNode = {
|
|
259
287
|
id: node.id,
|
|
260
|
-
width
|
|
288
|
+
width,
|
|
261
289
|
height,
|
|
262
290
|
labels: [{ text: Array.isArray(node.label) ? node.label.join('\n') : node.label }],
|
|
263
291
|
}
|
|
264
292
|
|
|
265
|
-
// Add ports
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
elkNode.ports = Array.from(ports).map(portName => ({
|
|
269
|
-
id: `${node.id}:${portName}`,
|
|
270
|
-
width: PORT_WIDTH,
|
|
271
|
-
height: PORT_HEIGHT,
|
|
272
|
-
labels: [{ text: portName }],
|
|
273
|
-
}))
|
|
274
|
-
elkNode.layoutOptions = { 'elk.portConstraints': 'FREE' }
|
|
275
|
-
}
|
|
293
|
+
// Add ports
|
|
294
|
+
if (portInfo && portInfo.all.size > 0) {
|
|
295
|
+
elkNode.ports = []
|
|
276
296
|
|
|
277
|
-
|
|
278
|
-
|
|
297
|
+
// Calculate port spacing based on label width
|
|
298
|
+
const portSpacing = this.calculatePortSpacing(portInfo.all)
|
|
299
|
+
|
|
300
|
+
// Helper to calculate port positions centered in the node
|
|
301
|
+
const calcPortPositions = (count: number, totalWidth: number): number[] => {
|
|
302
|
+
if (count === 0) return []
|
|
303
|
+
if (count === 1) return [totalWidth / 2]
|
|
304
|
+
const totalSpan = (count - 1) * portSpacing
|
|
305
|
+
const startX = (totalWidth - totalSpan) / 2
|
|
306
|
+
return Array.from({ length: count }, (_, i) => startX + i * portSpacing)
|
|
307
|
+
}
|
|
279
308
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const portsA = nodePorts.get(info.nodeA.id)
|
|
287
|
-
if (portsA) {
|
|
288
|
-
for (const portName of portsA) {
|
|
289
|
-
ports.push({
|
|
290
|
-
id: `${info.virtualId}:A:${portName}`,
|
|
309
|
+
// Top ports (incoming)
|
|
310
|
+
const topPorts = Array.from(portInfo.top)
|
|
311
|
+
const topPositions = calcPortPositions(topPorts.length, width)
|
|
312
|
+
topPorts.forEach((portName, i) => {
|
|
313
|
+
elkNode.ports!.push({
|
|
314
|
+
id: `${node.id}:${portName}`,
|
|
291
315
|
width: PORT_WIDTH,
|
|
292
316
|
height: PORT_HEIGHT,
|
|
317
|
+
x: topPositions[i] - PORT_WIDTH / 2,
|
|
318
|
+
y: 0,
|
|
293
319
|
labels: [{ text: portName }],
|
|
320
|
+
layoutOptions: { 'elk.port.side': 'NORTH' },
|
|
294
321
|
})
|
|
295
|
-
}
|
|
296
|
-
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// Bottom ports (outgoing)
|
|
325
|
+
const bottomPorts = Array.from(portInfo.bottom)
|
|
326
|
+
const bottomPositions = calcPortPositions(bottomPorts.length, width)
|
|
327
|
+
bottomPorts.forEach((portName, i) => {
|
|
328
|
+
elkNode.ports!.push({
|
|
329
|
+
id: `${node.id}:${portName}`,
|
|
330
|
+
width: PORT_WIDTH,
|
|
331
|
+
height: PORT_HEIGHT,
|
|
332
|
+
x: bottomPositions[i] - PORT_WIDTH / 2,
|
|
333
|
+
y: height - PORT_HEIGHT,
|
|
334
|
+
labels: [{ text: portName }],
|
|
335
|
+
layoutOptions: { 'elk.port.side': 'SOUTH' },
|
|
336
|
+
})
|
|
337
|
+
})
|
|
297
338
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
ports
|
|
303
|
-
id: `${
|
|
339
|
+
// Left ports (HA)
|
|
340
|
+
const leftPorts = Array.from(portInfo.left)
|
|
341
|
+
const leftPositions = calcPortPositions(leftPorts.length, height)
|
|
342
|
+
leftPorts.forEach((portName, i) => {
|
|
343
|
+
elkNode.ports!.push({
|
|
344
|
+
id: `${node.id}:${portName}`,
|
|
304
345
|
width: PORT_WIDTH,
|
|
305
346
|
height: PORT_HEIGHT,
|
|
347
|
+
x: 0,
|
|
348
|
+
y: leftPositions[i] - PORT_HEIGHT / 2,
|
|
306
349
|
labels: [{ text: portName }],
|
|
350
|
+
layoutOptions: { 'elk.port.side': 'WEST' },
|
|
307
351
|
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// Right ports (HA)
|
|
355
|
+
const rightPorts = Array.from(portInfo.right)
|
|
356
|
+
const rightPositions = calcPortPositions(rightPorts.length, height)
|
|
357
|
+
rightPorts.forEach((portName, i) => {
|
|
358
|
+
elkNode.ports!.push({
|
|
359
|
+
id: `${node.id}:${portName}`,
|
|
360
|
+
width: PORT_WIDTH,
|
|
361
|
+
height: PORT_HEIGHT,
|
|
362
|
+
x: width - PORT_WIDTH,
|
|
363
|
+
y: rightPositions[i] - PORT_HEIGHT / 2,
|
|
364
|
+
labels: [{ text: portName }],
|
|
365
|
+
layoutOptions: { 'elk.port.side': 'EAST' },
|
|
366
|
+
})
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
elkNode.layoutOptions = {
|
|
370
|
+
'elk.portConstraints': 'FIXED_POS',
|
|
371
|
+
'elk.spacing.portPort': String(MIN_PORT_SPACING),
|
|
308
372
|
}
|
|
309
373
|
}
|
|
310
374
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
375
|
+
return elkNode
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Create HA container node
|
|
379
|
+
const createHAContainerNode = (containerId: string, pair: { nodeA: string; nodeB: string }): ElkNode | null => {
|
|
380
|
+
const nodeA = graph.nodes.find(n => n.id === pair.nodeA)
|
|
381
|
+
const nodeB = graph.nodes.find(n => n.id === pair.nodeB)
|
|
382
|
+
if (!nodeA || !nodeB) return null
|
|
383
|
+
|
|
384
|
+
const childA = createElkNode(nodeA)
|
|
385
|
+
const childB = createElkNode(nodeB)
|
|
386
|
+
|
|
387
|
+
// Find HA link
|
|
388
|
+
const haLink = graph.links.find(link => {
|
|
389
|
+
if (!link.redundancy) return false
|
|
390
|
+
const from = toEndpoint(link.from)
|
|
391
|
+
const to = toEndpoint(link.to)
|
|
392
|
+
const key = [from.node, to.node].sort().join(':')
|
|
393
|
+
const pairKey = [pair.nodeA, pair.nodeB].sort().join(':')
|
|
394
|
+
return key === pairKey
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
// Create internal HA edge
|
|
398
|
+
const haEdges: ElkExtendedEdge[] = []
|
|
399
|
+
if (haLink) {
|
|
400
|
+
const from = toEndpoint(haLink.from)
|
|
401
|
+
const to = toEndpoint(haLink.to)
|
|
402
|
+
const fromPortName = from.port || 'ha'
|
|
403
|
+
const toPortName = to.port || 'ha'
|
|
404
|
+
haEdges.push({
|
|
405
|
+
id: haLink.id || `ha-edge-${containerId}`,
|
|
406
|
+
sources: [`${from.node}:${fromPortName}`],
|
|
407
|
+
targets: [`${to.node}:${toPortName}`],
|
|
408
|
+
})
|
|
317
409
|
}
|
|
318
410
|
|
|
319
|
-
|
|
320
|
-
id:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
411
|
+
return {
|
|
412
|
+
id: containerId,
|
|
413
|
+
children: [childA, childB],
|
|
414
|
+
edges: haEdges,
|
|
415
|
+
layoutOptions: {
|
|
416
|
+
'elk.algorithm': 'layered',
|
|
417
|
+
'elk.direction': 'RIGHT',
|
|
418
|
+
'elk.spacing.nodeNode': '40',
|
|
419
|
+
'elk.padding': '[top=0,left=0,bottom=0,right=0]',
|
|
420
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
421
|
+
'elk.edgeRouting': 'POLYLINE',
|
|
422
|
+
'org.eclipse.elk.json.edgeCoords': 'ROOT',
|
|
423
|
+
'org.eclipse.elk.json.shapeCoords': 'ROOT',
|
|
424
|
+
},
|
|
326
425
|
}
|
|
327
|
-
return elkNode
|
|
328
426
|
}
|
|
329
427
|
|
|
330
|
-
//
|
|
331
|
-
const
|
|
332
|
-
for (const [virtualId, info] of haVirtualNodes) {
|
|
333
|
-
// Use nodeA's parent (both should be in same subgraph for HA to make sense)
|
|
334
|
-
virtualNodeParent.set(virtualId, info.nodeA.parent)
|
|
335
|
-
}
|
|
428
|
+
// Track added HA containers
|
|
429
|
+
const addedHAContainers = new Set<string>()
|
|
336
430
|
|
|
337
|
-
// Create ELK
|
|
338
|
-
const createSubgraphNode = (subgraph: Subgraph): ElkNode => {
|
|
431
|
+
// Create ELK subgraph node recursively
|
|
432
|
+
const createSubgraphNode = (subgraph: Subgraph, edgesByContainer: Map<string, ElkExtendedEdge[]>): ElkNode => {
|
|
339
433
|
const childNodes: ElkNode[] = []
|
|
340
434
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const childSg = subgraphMap.get(childId)
|
|
345
|
-
if (childSg) {
|
|
346
|
-
childNodes.push(createSubgraphNode(childSg))
|
|
435
|
+
for (const childSg of subgraphMap.values()) {
|
|
436
|
+
if (childSg.parent === subgraph.id) {
|
|
437
|
+
childNodes.push(createSubgraphNode(childSg, edgesByContainer))
|
|
347
438
|
}
|
|
348
439
|
}
|
|
349
440
|
|
|
350
|
-
// Add HA virtual nodes that belong to this subgraph
|
|
351
|
-
for (const [virtualId, info] of haVirtualNodes) {
|
|
352
|
-
if (virtualNodeParent.get(virtualId) === subgraph.id) {
|
|
353
|
-
childNodes.push(createHAVirtualElkNode(info))
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Add regular nodes in this subgraph (skip nodes in HA pairs)
|
|
358
441
|
for (const node of graph.nodes) {
|
|
359
|
-
if (node.parent === subgraph.id
|
|
360
|
-
|
|
442
|
+
if (node.parent === subgraph.id) {
|
|
443
|
+
const containerId = nodeToHAContainer.get(node.id)
|
|
444
|
+
if (containerId) {
|
|
445
|
+
if (!addedHAContainers.has(containerId)) {
|
|
446
|
+
addedHAContainers.add(containerId)
|
|
447
|
+
const pair = haPairMap.get(containerId)
|
|
448
|
+
if (pair) {
|
|
449
|
+
const containerNode = createHAContainerNode(containerId, pair)
|
|
450
|
+
if (containerNode) childNodes.push(containerNode)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
childNodes.push(createElkNode(node))
|
|
455
|
+
}
|
|
361
456
|
}
|
|
362
457
|
}
|
|
363
458
|
|
|
364
459
|
const sgPadding = subgraph.style?.padding ?? options.subgraphPadding
|
|
365
|
-
const
|
|
366
|
-
const sgRankSpacing = subgraph.style?.rankSpacing ?? options.rankSpacing
|
|
460
|
+
const sgEdges = edgesByContainer.get(subgraph.id) || []
|
|
367
461
|
|
|
368
462
|
return {
|
|
369
463
|
id: subgraph.id,
|
|
370
464
|
labels: [{ text: subgraph.label }],
|
|
371
465
|
children: childNodes,
|
|
466
|
+
edges: sgEdges,
|
|
372
467
|
layoutOptions: {
|
|
373
468
|
'elk.padding': `[top=${sgPadding + options.subgraphLabelHeight},left=${sgPadding},bottom=${sgPadding},right=${sgPadding}]`,
|
|
374
|
-
'elk.spacing.nodeNode': String(sgNodeSpacing),
|
|
375
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': String(sgRankSpacing),
|
|
376
469
|
},
|
|
377
470
|
}
|
|
378
471
|
}
|
|
379
472
|
|
|
380
473
|
// Build root children
|
|
381
|
-
const
|
|
474
|
+
const buildRootChildren = (edgesByContainer: Map<string, ElkExtendedEdge[]>): ElkNode[] => {
|
|
475
|
+
const children: ElkNode[] = []
|
|
382
476
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
477
|
+
for (const sg of subgraphMap.values()) {
|
|
478
|
+
if (!sg.parent || !subgraphMap.has(sg.parent)) {
|
|
479
|
+
children.push(createSubgraphNode(sg, edgesByContainer))
|
|
480
|
+
}
|
|
387
481
|
}
|
|
388
|
-
}
|
|
389
482
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
483
|
+
for (const node of graph.nodes) {
|
|
484
|
+
if (!node.parent || !subgraphMap.has(node.parent)) {
|
|
485
|
+
const containerId = nodeToHAContainer.get(node.id)
|
|
486
|
+
if (containerId) {
|
|
487
|
+
if (!addedHAContainers.has(containerId)) {
|
|
488
|
+
addedHAContainers.add(containerId)
|
|
489
|
+
const pair = haPairMap.get(containerId)
|
|
490
|
+
if (pair) {
|
|
491
|
+
const containerNode = createHAContainerNode(containerId, pair)
|
|
492
|
+
if (containerNode) children.push(containerNode)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
children.push(createElkNode(node))
|
|
497
|
+
}
|
|
498
|
+
}
|
|
394
499
|
}
|
|
500
|
+
|
|
501
|
+
return children
|
|
395
502
|
}
|
|
396
503
|
|
|
397
|
-
//
|
|
504
|
+
// Build node to parent map
|
|
505
|
+
const nodeParentMap = new Map<string, string | undefined>()
|
|
398
506
|
for (const node of graph.nodes) {
|
|
399
|
-
|
|
400
|
-
if (!node.parent || !subgraphMap.has(node.parent)) {
|
|
401
|
-
rootChildren.push(createElkNode_(node))
|
|
402
|
-
}
|
|
507
|
+
nodeParentMap.set(node.id, node.parent)
|
|
403
508
|
}
|
|
404
509
|
|
|
405
|
-
//
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
510
|
+
// Find LCA (Lowest Common Ancestor) of two nodes
|
|
511
|
+
const findLCA = (nodeA: string, nodeB: string): string | undefined => {
|
|
512
|
+
const ancestorsA = new Set<string | undefined>()
|
|
513
|
+
let current: string | undefined = nodeA
|
|
514
|
+
while (current) {
|
|
515
|
+
ancestorsA.add(current)
|
|
516
|
+
current = nodeParentMap.get(current)
|
|
517
|
+
}
|
|
518
|
+
ancestorsA.add(undefined) // root
|
|
519
|
+
|
|
520
|
+
current = nodeB
|
|
521
|
+
while (current !== undefined) {
|
|
522
|
+
if (ancestorsA.has(current)) {
|
|
523
|
+
return current
|
|
416
524
|
}
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const toEndpoint_ = toEndpoint(link.to)
|
|
525
|
+
current = nodeParentMap.get(current)
|
|
526
|
+
}
|
|
527
|
+
return undefined // root
|
|
528
|
+
}
|
|
422
529
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
530
|
+
// Build HA pair set for quick lookup
|
|
531
|
+
const haPairSet = new Set<string>()
|
|
532
|
+
for (const pair of haPairs) {
|
|
533
|
+
haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
|
|
534
|
+
}
|
|
426
535
|
|
|
427
|
-
|
|
428
|
-
|
|
536
|
+
const isHALink = (fromNode: string, toNode: string): boolean => {
|
|
537
|
+
const key = [fromNode, toNode].sort().join(':')
|
|
538
|
+
return haPairSet.has(key)
|
|
539
|
+
}
|
|
429
540
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
sourceId = `${fromRedirect.virtualId}:${fromRedirect.side}:${fromEndpoint.port}`
|
|
434
|
-
} else {
|
|
435
|
-
sourceId = `${fromRedirect.virtualId}:${fromRedirect.side}`
|
|
436
|
-
}
|
|
437
|
-
} else if (fromEndpoint.port) {
|
|
438
|
-
sourceId = `${fromEndpoint.node}:${fromEndpoint.port}`
|
|
439
|
-
} else {
|
|
440
|
-
sourceId = fromEndpoint.node
|
|
441
|
-
}
|
|
541
|
+
// Group edges by their LCA container (skip HA links - they're in containers)
|
|
542
|
+
const edgesByContainer = new Map<string, ElkExtendedEdge[]>()
|
|
543
|
+
edgesByContainer.set('root', [])
|
|
442
544
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
targetId = `${toRedirect.virtualId}:${toRedirect.side}:${toEndpoint_.port}`
|
|
447
|
-
} else {
|
|
448
|
-
targetId = `${toRedirect.virtualId}:${toRedirect.side}`
|
|
449
|
-
}
|
|
450
|
-
} else if (toEndpoint_.port) {
|
|
451
|
-
targetId = `${toEndpoint_.node}:${toEndpoint_.port}`
|
|
452
|
-
} else {
|
|
453
|
-
targetId = toEndpoint_.node
|
|
454
|
-
}
|
|
545
|
+
for (const sg of subgraphMap.values()) {
|
|
546
|
+
edgesByContainer.set(sg.id, [])
|
|
547
|
+
}
|
|
455
548
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
targets: [targetId],
|
|
460
|
-
}
|
|
549
|
+
graph.links.forEach((link, index) => {
|
|
550
|
+
const from = toEndpoint(link.from)
|
|
551
|
+
const to = toEndpoint(link.to)
|
|
461
552
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
if (fromEndpoint.ip) labelParts.push(fromEndpoint.ip)
|
|
468
|
-
if (toEndpoint_.ip) labelParts.push(toEndpoint_.ip)
|
|
469
|
-
|
|
470
|
-
if (labelParts.length > 0) {
|
|
471
|
-
edge.labels = [{
|
|
472
|
-
text: labelParts.join('\n'),
|
|
473
|
-
layoutOptions: { 'elk.edgeLabels.placement': 'CENTER' }
|
|
474
|
-
}]
|
|
475
|
-
}
|
|
553
|
+
// Skip HA links (they're inside HA containers)
|
|
554
|
+
if (link.redundancy && isHALink(from.node, to.node)) {
|
|
555
|
+
return
|
|
556
|
+
}
|
|
476
557
|
|
|
477
|
-
|
|
478
|
-
}
|
|
558
|
+
const sourceId = from.port ? `${from.node}:${from.port}` : from.node
|
|
559
|
+
const targetId = to.port ? `${to.node}:${to.port}` : to.node
|
|
560
|
+
|
|
561
|
+
const edge: ElkExtendedEdge = {
|
|
562
|
+
id: link.id || `edge-${index}`,
|
|
563
|
+
sources: [sourceId],
|
|
564
|
+
targets: [targetId],
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Add label
|
|
568
|
+
const labelParts: string[] = []
|
|
569
|
+
if (link.label) {
|
|
570
|
+
labelParts.push(Array.isArray(link.label) ? link.label.join(' / ') : link.label)
|
|
571
|
+
}
|
|
572
|
+
if (from.ip) labelParts.push(from.ip)
|
|
573
|
+
if (to.ip) labelParts.push(to.ip)
|
|
574
|
+
|
|
575
|
+
if (labelParts.length > 0) {
|
|
576
|
+
edge.labels = [{
|
|
577
|
+
text: labelParts.join('\n'),
|
|
578
|
+
layoutOptions: { 'elk.edgeLabels.placement': 'CENTER' }
|
|
579
|
+
}]
|
|
580
|
+
}
|
|
479
581
|
|
|
480
|
-
|
|
582
|
+
// Find LCA and place edge in appropriate container
|
|
583
|
+
const lca = findLCA(from.node, to.node)
|
|
584
|
+
let container = lca
|
|
585
|
+
if (container === from.node || container === to.node) {
|
|
586
|
+
container = nodeParentMap.get(container)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const containerId = container && subgraphMap.has(container) ? container : 'root'
|
|
590
|
+
edgesByContainer.get(containerId)!.push(edge)
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
// Dynamic edge spacing
|
|
594
|
+
const edgeNodeSpacing = Math.max(10, Math.round(options.nodeSpacing * 0.4))
|
|
595
|
+
const edgeEdgeSpacing = Math.max(8, Math.round(options.nodeSpacing * 0.25))
|
|
596
|
+
|
|
597
|
+
// Root layout options
|
|
481
598
|
const rootLayoutOptions: LayoutOptions = {
|
|
482
599
|
'elk.algorithm': 'layered',
|
|
483
600
|
'elk.direction': elkDirection,
|
|
484
601
|
'elk.spacing.nodeNode': String(options.nodeSpacing),
|
|
485
602
|
'elk.layered.spacing.nodeNodeBetweenLayers': String(options.rankSpacing),
|
|
486
|
-
'elk.spacing.edgeNode':
|
|
487
|
-
'elk.spacing.edgeEdge':
|
|
603
|
+
'elk.spacing.edgeNode': String(edgeNodeSpacing),
|
|
604
|
+
'elk.spacing.edgeEdge': String(edgeEdgeSpacing),
|
|
605
|
+
'elk.layered.compaction.postCompaction.strategy': 'EDGE_LENGTH',
|
|
606
|
+
'elk.layered.compaction.connectedComponents': 'true',
|
|
488
607
|
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
489
608
|
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
490
|
-
'elk.edgeRouting': '
|
|
609
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
491
610
|
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
|
611
|
+
// Use ROOT coordinate system
|
|
612
|
+
'org.eclipse.elk.json.edgeCoords': 'ROOT',
|
|
613
|
+
'org.eclipse.elk.json.shapeCoords': 'ROOT',
|
|
492
614
|
}
|
|
493
615
|
|
|
616
|
+
// Build the graph with edges in correct containers
|
|
617
|
+
const rootChildren = buildRootChildren(edgesByContainer)
|
|
618
|
+
const rootEdges = edgesByContainer.get('root') || []
|
|
619
|
+
|
|
494
620
|
return {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
layoutOptions: rootLayoutOptions,
|
|
500
|
-
},
|
|
501
|
-
haVirtualNodes,
|
|
621
|
+
id: 'root',
|
|
622
|
+
children: rootChildren,
|
|
623
|
+
edges: rootEdges,
|
|
624
|
+
layoutOptions: rootLayoutOptions,
|
|
502
625
|
}
|
|
503
626
|
}
|
|
504
627
|
|
|
505
628
|
/**
|
|
506
|
-
* Extract layout result
|
|
629
|
+
* Extract layout result from ELK output - uses ELK's edge routing directly
|
|
507
630
|
*/
|
|
508
|
-
private
|
|
631
|
+
private extractLayoutResult(
|
|
509
632
|
graph: NetworkGraph,
|
|
510
633
|
elkGraph: ElkNode,
|
|
511
|
-
|
|
634
|
+
nodePorts: Map<string, NodePortInfo>,
|
|
512
635
|
_options: Required<HierarchicalLayoutOptions>
|
|
513
636
|
): LayoutResult {
|
|
514
637
|
const layoutNodes = new Map<string, LayoutNode>()
|
|
515
638
|
const layoutSubgraphs = new Map<string, LayoutSubgraph>()
|
|
516
639
|
const layoutLinks = new Map<string, LayoutLink>()
|
|
517
640
|
|
|
518
|
-
// Build
|
|
641
|
+
// Build maps
|
|
519
642
|
const subgraphMap = new Map<string, Subgraph>()
|
|
520
643
|
if (graph.subgraphs) {
|
|
521
644
|
for (const sg of graph.subgraphs) {
|
|
@@ -523,286 +646,254 @@ export class HierarchicalLayout {
|
|
|
523
646
|
}
|
|
524
647
|
}
|
|
525
648
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
649
|
+
const nodeMap = new Map<string, Node>()
|
|
650
|
+
for (const node of graph.nodes) {
|
|
651
|
+
nodeMap.set(node.id, node)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Process ELK nodes recursively
|
|
655
|
+
// With shapeCoords=ROOT, all coordinates are absolute (no offset needed)
|
|
656
|
+
const processElkNode = (elkNode: ElkNode) => {
|
|
657
|
+
const x = elkNode.x || 0
|
|
658
|
+
const y = elkNode.y || 0
|
|
530
659
|
const width = elkNode.width || 0
|
|
531
660
|
const height = elkNode.height || 0
|
|
532
661
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const nodeAX = x + haInfo.widthA / 2
|
|
542
|
-
const nodeBX = x + haInfo.widthA + haInfo.gap + haInfo.widthB / 2
|
|
543
|
-
const centerY = y + height / 2
|
|
544
|
-
|
|
545
|
-
// Create layout nodes for A and B
|
|
546
|
-
const layoutNodeA: LayoutNode = {
|
|
547
|
-
id: haInfo.nodeA.id,
|
|
548
|
-
position: { x: nodeAX, y: centerY },
|
|
549
|
-
size: { width: haInfo.widthA, height: heightA },
|
|
550
|
-
node: haInfo.nodeA,
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const layoutNodeB: LayoutNode = {
|
|
554
|
-
id: haInfo.nodeB.id,
|
|
555
|
-
position: { x: nodeBX, y: centerY },
|
|
556
|
-
size: { width: haInfo.widthB, height: heightB },
|
|
557
|
-
node: haInfo.nodeB,
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// For HA nodes, calculate port positions ourselves
|
|
561
|
-
// We need to determine which ports are for incoming vs outgoing edges
|
|
562
|
-
// Build maps of ports for each node (A and B), grouped by edge direction
|
|
563
|
-
const portsForATop: { name: string; portW: number; portH: number }[] = []
|
|
564
|
-
const portsForABottom: { name: string; portW: number; portH: number }[] = []
|
|
565
|
-
const portsForARight: { name: string; portW: number; portH: number }[] = [] // For HA link (facing B)
|
|
566
|
-
const portsForBTop: { name: string; portW: number; portH: number }[] = []
|
|
567
|
-
const portsForBBottom: { name: string; portW: number; portH: number }[] = []
|
|
568
|
-
const portsForBLeft: { name: string; portW: number; portH: number }[] = [] // For HA link (facing A)
|
|
569
|
-
|
|
570
|
-
// Analyze links to determine port direction
|
|
571
|
-
for (const link of graph.links) {
|
|
572
|
-
const fromEndpoint = toEndpoint(link.from)
|
|
573
|
-
const toEndpoint_ = toEndpoint(link.to)
|
|
574
|
-
|
|
575
|
-
// HA internal link - ports go on inner sides (A's right, B's left)
|
|
576
|
-
if (link.redundancy) {
|
|
577
|
-
if (fromEndpoint.node === haInfo.nodeA.id && fromEndpoint.port) {
|
|
578
|
-
portsForARight.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
579
|
-
}
|
|
580
|
-
if (toEndpoint_.node === haInfo.nodeA.id && toEndpoint_.port) {
|
|
581
|
-
portsForARight.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
582
|
-
}
|
|
583
|
-
if (fromEndpoint.node === haInfo.nodeB.id && fromEndpoint.port) {
|
|
584
|
-
portsForBLeft.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
585
|
-
}
|
|
586
|
-
if (toEndpoint_.node === haInfo.nodeB.id && toEndpoint_.port) {
|
|
587
|
-
portsForBLeft.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
588
|
-
}
|
|
589
|
-
continue
|
|
590
|
-
}
|
|
662
|
+
if (subgraphMap.has(elkNode.id)) {
|
|
663
|
+
// Subgraph
|
|
664
|
+
const sg = subgraphMap.get(elkNode.id)!
|
|
665
|
+
layoutSubgraphs.set(elkNode.id, {
|
|
666
|
+
id: elkNode.id,
|
|
667
|
+
bounds: { x, y, width, height },
|
|
668
|
+
subgraph: sg,
|
|
669
|
+
})
|
|
591
670
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
portsForABottom.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
671
|
+
if (elkNode.children) {
|
|
672
|
+
for (const child of elkNode.children) {
|
|
673
|
+
processElkNode(child)
|
|
596
674
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
675
|
+
}
|
|
676
|
+
} else if (elkNode.id.startsWith('__ha_container_')) {
|
|
677
|
+
// HA container - process children
|
|
678
|
+
if (elkNode.children) {
|
|
679
|
+
for (const child of elkNode.children) {
|
|
680
|
+
processElkNode(child)
|
|
600
681
|
}
|
|
682
|
+
}
|
|
683
|
+
} else if (nodeMap.has(elkNode.id)) {
|
|
684
|
+
// Regular node
|
|
685
|
+
const node = nodeMap.get(elkNode.id)!
|
|
686
|
+
const portInfo = nodePorts.get(node.id)
|
|
687
|
+
const nodeHeight = this.calculateNodeHeight(node, portInfo?.all.size || 0)
|
|
601
688
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (toEndpoint_.node === haInfo.nodeB.id && toEndpoint_.port) {
|
|
608
|
-
// Incoming to B → top
|
|
609
|
-
portsForBTop.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
610
|
-
}
|
|
689
|
+
const layoutNode: LayoutNode = {
|
|
690
|
+
id: elkNode.id,
|
|
691
|
+
position: { x: x + width / 2, y: y + nodeHeight / 2 },
|
|
692
|
+
size: { width, height: nodeHeight },
|
|
693
|
+
node,
|
|
611
694
|
}
|
|
612
695
|
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
nodeHeight
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
696
|
+
// Extract port positions from ELK
|
|
697
|
+
if (elkNode.ports && elkNode.ports.length > 0) {
|
|
698
|
+
layoutNode.ports = new Map()
|
|
699
|
+
const nodeCenterX = x + width / 2
|
|
700
|
+
const nodeCenterY = y + nodeHeight / 2
|
|
701
|
+
|
|
702
|
+
for (const elkPort of elkNode.ports) {
|
|
703
|
+
const portX = elkPort.x ?? 0
|
|
704
|
+
const portY = elkPort.y ?? 0
|
|
705
|
+
const portW = elkPort.width ?? PORT_WIDTH
|
|
706
|
+
const portH = elkPort.height ?? PORT_HEIGHT
|
|
707
|
+
|
|
708
|
+
const portCenterX = portX + portW / 2
|
|
709
|
+
const portCenterY = portY + portH / 2
|
|
710
|
+
|
|
711
|
+
const relX = portCenterX - nodeCenterX
|
|
712
|
+
const relY = portCenterY - nodeCenterY
|
|
713
|
+
|
|
714
|
+
// Determine side based on which edge the port is closest to
|
|
715
|
+
// Use node boundaries, not relative distance from center
|
|
716
|
+
const distToTop = Math.abs(portCenterY - y)
|
|
717
|
+
const distToBottom = Math.abs(portCenterY - (y + nodeHeight))
|
|
718
|
+
const distToLeft = Math.abs(portCenterX - x)
|
|
719
|
+
const distToRight = Math.abs(portCenterX - (x + width))
|
|
720
|
+
|
|
721
|
+
const minDist = Math.min(distToTop, distToBottom, distToLeft, distToRight)
|
|
722
|
+
let side: 'top' | 'bottom' | 'left' | 'right' = 'bottom'
|
|
723
|
+
if (minDist === distToTop) {
|
|
724
|
+
side = 'top'
|
|
725
|
+
} else if (minDist === distToBottom) {
|
|
726
|
+
side = 'bottom'
|
|
727
|
+
} else if (minDist === distToLeft) {
|
|
728
|
+
side = 'left'
|
|
636
729
|
} else {
|
|
637
|
-
|
|
638
|
-
const portSpacing = nodeHeight / (portsList.length + 1)
|
|
639
|
-
relY = portSpacing * (i + 1) - nodeHeight / 2
|
|
640
|
-
// Place port completely outside the node
|
|
641
|
-
relX = side === 'left'
|
|
642
|
-
? -nodeWidth / 2 - p.portW / 2 // Left of the node
|
|
643
|
-
: nodeWidth / 2 + p.portW / 2 // Right of the node
|
|
730
|
+
side = 'right'
|
|
644
731
|
}
|
|
645
732
|
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
733
|
+
const portName = elkPort.id.includes(':') ? elkPort.id.split(':').slice(1).join(':') : elkPort.id
|
|
734
|
+
|
|
735
|
+
layoutNode.ports.set(elkPort.id, {
|
|
736
|
+
id: elkPort.id,
|
|
737
|
+
label: portName,
|
|
650
738
|
position: { x: relX, y: relY },
|
|
651
|
-
size: { width:
|
|
739
|
+
size: { width: portW, height: portH },
|
|
652
740
|
side,
|
|
653
741
|
})
|
|
654
|
-
}
|
|
655
|
-
return result
|
|
742
|
+
}
|
|
656
743
|
}
|
|
657
744
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
// This reduces line crossings when connecting to centered nodes below/above
|
|
662
|
-
const portsMapA = new Map<string, { id: string; label: string; position: Position; size: { width: number; height: number }; side: 'top' | 'bottom' | 'left' | 'right' }>()
|
|
663
|
-
// For nodeA: reverse the port order so inner ports (right side) come first
|
|
664
|
-
const topPortsA = distributePortsOnEdge([...portsForATop].reverse(), haInfo.widthA, heightA, haInfo.nodeA.id, 'top')
|
|
665
|
-
const bottomPortsA = distributePortsOnEdge([...portsForABottom].reverse(), haInfo.widthA, heightA, haInfo.nodeA.id, 'bottom')
|
|
666
|
-
const rightPortsA = distributePortsOnEdge(portsForARight, haInfo.widthA, heightA, haInfo.nodeA.id, 'right')
|
|
667
|
-
for (const [k, v] of topPortsA) portsMapA.set(k, v)
|
|
668
|
-
for (const [k, v] of bottomPortsA) portsMapA.set(k, v)
|
|
669
|
-
for (const [k, v] of rightPortsA) portsMapA.set(k, v)
|
|
670
|
-
|
|
671
|
-
const portsMapB = new Map<string, { id: string; label: string; position: Position; size: { width: number; height: number }; side: 'top' | 'bottom' | 'left' | 'right' }>()
|
|
672
|
-
// For nodeB: keep normal order so inner ports (left side) come first
|
|
673
|
-
const topPortsB = distributePortsOnEdge(portsForBTop, haInfo.widthB, heightB, haInfo.nodeB.id, 'top')
|
|
674
|
-
const bottomPortsB = distributePortsOnEdge(portsForBBottom, haInfo.widthB, heightB, haInfo.nodeB.id, 'bottom')
|
|
675
|
-
const leftPortsB = distributePortsOnEdge(portsForBLeft, haInfo.widthB, heightB, haInfo.nodeB.id, 'left')
|
|
676
|
-
for (const [k, v] of topPortsB) portsMapB.set(k, v)
|
|
677
|
-
for (const [k, v] of bottomPortsB) portsMapB.set(k, v)
|
|
678
|
-
for (const [k, v] of leftPortsB) portsMapB.set(k, v)
|
|
679
|
-
|
|
680
|
-
if (portsMapA.size > 0) layoutNodeA.ports = portsMapA
|
|
681
|
-
if (portsMapB.size > 0) layoutNodeB.ports = portsMapB
|
|
682
|
-
|
|
683
|
-
layoutNodes.set(haInfo.nodeA.id, layoutNodeA)
|
|
684
|
-
layoutNodes.set(haInfo.nodeB.id, layoutNodeB)
|
|
685
|
-
} else if (subgraphMap.has(elkNode.id)) {
|
|
686
|
-
// This is a subgraph
|
|
687
|
-
const sg = subgraphMap.get(elkNode.id)!
|
|
688
|
-
layoutSubgraphs.set(elkNode.id, {
|
|
689
|
-
id: elkNode.id,
|
|
690
|
-
bounds: { x, y, width, height },
|
|
691
|
-
subgraph: sg,
|
|
692
|
-
})
|
|
745
|
+
layoutNodes.set(elkNode.id, layoutNode)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
693
748
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
} else {
|
|
701
|
-
// This is a regular node
|
|
702
|
-
const node = graph.nodes.find(n => n.id === elkNode.id)
|
|
703
|
-
if (node) {
|
|
704
|
-
const layoutNode: LayoutNode = {
|
|
705
|
-
id: elkNode.id,
|
|
706
|
-
position: { x: x + width / 2, y: y + height / 2 },
|
|
707
|
-
size: { width, height },
|
|
708
|
-
node,
|
|
709
|
-
}
|
|
749
|
+
// Process root children (coordinates are absolute with shapeCoords=ROOT)
|
|
750
|
+
if (elkGraph.children) {
|
|
751
|
+
for (const child of elkGraph.children) {
|
|
752
|
+
processElkNode(child)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
710
755
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
756
|
+
// Build link map for ID matching
|
|
757
|
+
const linkById = new Map<string, { link: typeof graph.links[0]; index: number }>()
|
|
758
|
+
graph.links.forEach((link, index) => {
|
|
759
|
+
linkById.set(link.id || `edge-${index}`, { link, index })
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
// Track processed edges to prevent duplicates
|
|
763
|
+
const processedEdgeIds = new Set<string>()
|
|
764
|
+
|
|
765
|
+
// Check if container is an HA container
|
|
766
|
+
const isHAContainer = (id: string) => id.startsWith('__ha_container_')
|
|
767
|
+
|
|
768
|
+
// Process edges from a container
|
|
769
|
+
// With edgeCoords=ROOT, all edge coordinates are absolute (no offset needed)
|
|
770
|
+
const processEdgesInContainer = (container: ElkNode) => {
|
|
771
|
+
const elkEdges = container.edges as ElkEdgeWithSections[] | undefined
|
|
772
|
+
if (elkEdges) {
|
|
773
|
+
for (const elkEdge of elkEdges) {
|
|
774
|
+
// Skip if already processed
|
|
775
|
+
if (processedEdgeIds.has(elkEdge.id)) continue
|
|
776
|
+
processedEdgeIds.add(elkEdge.id)
|
|
777
|
+
|
|
778
|
+
const entry = linkById.get(elkEdge.id)
|
|
779
|
+
if (!entry) continue
|
|
780
|
+
|
|
781
|
+
const { link, index } = entry
|
|
782
|
+
const id = link.id || `link-${index}`
|
|
783
|
+
const fromEndpoint = toEndpoint(link.from)
|
|
784
|
+
const toEndpoint_ = toEndpoint(link.to)
|
|
785
|
+
|
|
786
|
+
const fromNode = layoutNodes.get(fromEndpoint.node)
|
|
787
|
+
const toNode = layoutNodes.get(toEndpoint_.node)
|
|
788
|
+
if (!fromNode || !toNode) continue
|
|
789
|
+
|
|
790
|
+
let points: Position[] = []
|
|
791
|
+
|
|
792
|
+
// HA edges inside HA containers: use ELK's edge routing directly
|
|
793
|
+
if (isHAContainer(container.id) && elkEdge.sections && elkEdge.sections.length > 0) {
|
|
794
|
+
const section = elkEdge.sections[0]
|
|
795
|
+
points.push({ x: section.startPoint.x, y: section.startPoint.y })
|
|
796
|
+
if (section.bendPoints) {
|
|
797
|
+
for (const bp of section.bendPoints) {
|
|
798
|
+
points.push({ x: bp.x, y: bp.y })
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
points.push({ x: section.endPoint.x, y: section.endPoint.y })
|
|
802
|
+
} else if (!isHAContainer(container.id)) {
|
|
803
|
+
// Normal vertical edges
|
|
804
|
+
const fromBottomY = fromNode.position.y + fromNode.size.height / 2
|
|
805
|
+
const toTopY = toNode.position.y - toNode.size.height / 2
|
|
806
|
+
|
|
807
|
+
if (elkEdge.sections && elkEdge.sections.length > 0) {
|
|
808
|
+
const section = elkEdge.sections[0]
|
|
809
|
+
|
|
810
|
+
points.push({
|
|
811
|
+
x: section.startPoint.x,
|
|
812
|
+
y: fromBottomY,
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
if (section.bendPoints) {
|
|
816
|
+
for (const bp of section.bendPoints) {
|
|
817
|
+
points.push({ x: bp.x, y: bp.y })
|
|
818
|
+
}
|
|
747
819
|
}
|
|
748
820
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
position: { x: relX, y: relY },
|
|
753
|
-
size: { width: portW, height: portH },
|
|
754
|
-
side,
|
|
821
|
+
points.push({
|
|
822
|
+
x: section.endPoint.x,
|
|
823
|
+
y: toTopY,
|
|
755
824
|
})
|
|
825
|
+
} else {
|
|
826
|
+
points = this.generateOrthogonalPath(
|
|
827
|
+
{ x: fromNode.position.x, y: fromBottomY },
|
|
828
|
+
{ x: toNode.position.x, y: toTopY }
|
|
829
|
+
)
|
|
756
830
|
}
|
|
831
|
+
} else {
|
|
832
|
+
// HA edge fallback: simple horizontal line
|
|
833
|
+
const leftNode = fromNode.position.x < toNode.position.x ? fromNode : toNode
|
|
834
|
+
const rightNode = fromNode.position.x < toNode.position.x ? toNode : fromNode
|
|
835
|
+
const y = (leftNode.position.y + rightNode.position.y) / 2
|
|
836
|
+
points = [
|
|
837
|
+
{ x: leftNode.position.x + leftNode.size.width / 2, y },
|
|
838
|
+
{ x: rightNode.position.x - rightNode.size.width / 2, y },
|
|
839
|
+
]
|
|
757
840
|
}
|
|
758
841
|
|
|
759
|
-
|
|
842
|
+
layoutLinks.set(id, {
|
|
843
|
+
id,
|
|
844
|
+
from: fromEndpoint.node,
|
|
845
|
+
to: toEndpoint_.node,
|
|
846
|
+
fromEndpoint,
|
|
847
|
+
toEndpoint: toEndpoint_,
|
|
848
|
+
points,
|
|
849
|
+
link,
|
|
850
|
+
})
|
|
760
851
|
}
|
|
761
852
|
}
|
|
762
|
-
}
|
|
763
853
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
854
|
+
// Recursively process child containers (subgraphs and HA containers)
|
|
855
|
+
if (container.children) {
|
|
856
|
+
for (const child of container.children) {
|
|
857
|
+
if (subgraphMap.has(child.id) || child.id.startsWith('__ha_container_')) {
|
|
858
|
+
processEdgesInContainer(child)
|
|
859
|
+
}
|
|
860
|
+
}
|
|
768
861
|
}
|
|
769
862
|
}
|
|
770
863
|
|
|
771
|
-
//
|
|
772
|
-
|
|
864
|
+
// Process all edges (coordinates are absolute with edgeCoords=ROOT)
|
|
865
|
+
processEdgesInContainer(elkGraph)
|
|
773
866
|
|
|
774
|
-
//
|
|
867
|
+
// Fallback for any missing links
|
|
775
868
|
graph.links.forEach((link, index) => {
|
|
776
869
|
const id = link.id || `link-${index}`
|
|
777
|
-
|
|
778
|
-
const toEndpoint_ = toEndpoint(link.to)
|
|
779
|
-
const fromId = fromEndpoint_.node
|
|
780
|
-
const toId = toEndpoint_.node
|
|
781
|
-
const fromNode = layoutNodes.get(fromId)
|
|
782
|
-
const toNode = layoutNodes.get(toId)
|
|
870
|
+
if (layoutLinks.has(id)) return
|
|
783
871
|
|
|
872
|
+
const fromEndpoint = toEndpoint(link.from)
|
|
873
|
+
const toEndpoint_ = toEndpoint(link.to)
|
|
874
|
+
const fromNode = layoutNodes.get(fromEndpoint.node)
|
|
875
|
+
const toNode = layoutNodes.get(toEndpoint_.node)
|
|
784
876
|
if (!fromNode || !toNode) return
|
|
785
877
|
|
|
786
|
-
|
|
787
|
-
const
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const points = this.calculateEdgePathWithPorts(fromNode, toNode, fromPort, toPort)
|
|
878
|
+
const startY = fromNode.position.y + fromNode.size.height / 2
|
|
879
|
+
const endY = toNode.position.y - toNode.size.height / 2
|
|
880
|
+
const points = this.generateOrthogonalPath(
|
|
881
|
+
{ x: fromNode.position.x, y: startY },
|
|
882
|
+
{ x: toNode.position.x, y: endY }
|
|
883
|
+
)
|
|
793
884
|
|
|
794
885
|
layoutLinks.set(id, {
|
|
795
886
|
id,
|
|
796
|
-
from:
|
|
797
|
-
to:
|
|
798
|
-
fromEndpoint
|
|
887
|
+
from: fromEndpoint.node,
|
|
888
|
+
to: toEndpoint_.node,
|
|
889
|
+
fromEndpoint,
|
|
799
890
|
toEndpoint: toEndpoint_,
|
|
800
891
|
points,
|
|
801
892
|
link,
|
|
802
893
|
})
|
|
803
894
|
})
|
|
804
895
|
|
|
805
|
-
// Calculate
|
|
896
|
+
// Calculate bounds
|
|
806
897
|
const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs)
|
|
807
898
|
|
|
808
899
|
return {
|
|
@@ -813,128 +904,15 @@ export class HierarchicalLayout {
|
|
|
813
904
|
}
|
|
814
905
|
}
|
|
815
906
|
|
|
816
|
-
|
|
817
|
-
* Reorder ports on each node based on connected node positions to minimize edge crossings
|
|
818
|
-
*/
|
|
819
|
-
private reorderPortsByConnectedNodePositions(
|
|
820
|
-
graph: NetworkGraph,
|
|
821
|
-
layoutNodes: Map<string, LayoutNode>
|
|
822
|
-
): void {
|
|
823
|
-
// Build a map of port -> connected node position
|
|
824
|
-
// For each port, find the node it connects to and get that node's position
|
|
825
|
-
const portConnections = new Map<string, { connectedNodeId: string; isOutgoing: boolean }>()
|
|
826
|
-
|
|
827
|
-
for (const link of graph.links) {
|
|
828
|
-
const fromEndpoint = toEndpoint(link.from)
|
|
829
|
-
const toEndpoint_ = toEndpoint(link.to)
|
|
830
|
-
|
|
831
|
-
// From port connects to "to" node
|
|
832
|
-
if (fromEndpoint.port) {
|
|
833
|
-
const portId = `${fromEndpoint.node}:${fromEndpoint.port}`
|
|
834
|
-
portConnections.set(portId, { connectedNodeId: toEndpoint_.node, isOutgoing: true })
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// To port connects to "from" node
|
|
838
|
-
if (toEndpoint_.port) {
|
|
839
|
-
const portId = `${toEndpoint_.node}:${toEndpoint_.port}`
|
|
840
|
-
portConnections.set(portId, { connectedNodeId: fromEndpoint.node, isOutgoing: false })
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// For each node, reorder its ports based on connected node positions
|
|
845
|
-
layoutNodes.forEach((layoutNode) => {
|
|
846
|
-
if (!layoutNode.ports || layoutNode.ports.size === 0) return
|
|
847
|
-
|
|
848
|
-
// Group ports by side
|
|
849
|
-
const portsBySide = new Map<string, { portId: string; port: typeof layoutNode.ports extends Map<string, infer V> ? V : never }[]>()
|
|
850
|
-
|
|
851
|
-
layoutNode.ports.forEach((port, portId) => {
|
|
852
|
-
const side = port.side
|
|
853
|
-
if (!portsBySide.has(side)) {
|
|
854
|
-
portsBySide.set(side, [])
|
|
855
|
-
}
|
|
856
|
-
portsBySide.get(side)!.push({ portId, port })
|
|
857
|
-
})
|
|
858
|
-
|
|
859
|
-
// Sort and redistribute ports on each side
|
|
860
|
-
portsBySide.forEach((ports, side) => {
|
|
861
|
-
if (ports.length <= 1) return // No need to reorder single port
|
|
862
|
-
|
|
863
|
-
// Sort ports by connected node position
|
|
864
|
-
ports.sort((a, b) => {
|
|
865
|
-
const connA = portConnections.get(a.portId)
|
|
866
|
-
const connB = portConnections.get(b.portId)
|
|
867
|
-
if (!connA || !connB) return 0
|
|
868
|
-
|
|
869
|
-
const nodeA = layoutNodes.get(connA.connectedNodeId)
|
|
870
|
-
const nodeB = layoutNodes.get(connB.connectedNodeId)
|
|
871
|
-
if (!nodeA || !nodeB) return 0
|
|
872
|
-
|
|
873
|
-
// For top/bottom sides, sort by X position (left to right)
|
|
874
|
-
if (side === 'top' || side === 'bottom') {
|
|
875
|
-
return nodeA.position.x - nodeB.position.x
|
|
876
|
-
}
|
|
877
|
-
// For left/right sides, sort by Y position (top to bottom)
|
|
878
|
-
return nodeA.position.y - nodeB.position.y
|
|
879
|
-
})
|
|
880
|
-
|
|
881
|
-
// Redistribute port positions along the edge (ports placed OUTSIDE the node)
|
|
882
|
-
const nodeWidth = layoutNode.size.width
|
|
883
|
-
const nodeHeight = layoutNode.size.height
|
|
884
|
-
|
|
885
|
-
if (side === 'top' || side === 'bottom') {
|
|
886
|
-
const portSpacing = nodeWidth / (ports.length + 1)
|
|
887
|
-
ports.forEach((p, i) => {
|
|
888
|
-
const relX = portSpacing * (i + 1) - nodeWidth / 2
|
|
889
|
-
// Place port completely outside the node edge
|
|
890
|
-
const relY = side === 'top'
|
|
891
|
-
? -nodeHeight / 2 - p.port.size.height / 2 // Above the node
|
|
892
|
-
: nodeHeight / 2 + p.port.size.height / 2 // Below the node
|
|
893
|
-
p.port.position = { x: relX, y: relY }
|
|
894
|
-
})
|
|
895
|
-
} else {
|
|
896
|
-
const portSpacing = nodeHeight / (ports.length + 1)
|
|
897
|
-
ports.forEach((p, i) => {
|
|
898
|
-
const relY = portSpacing * (i + 1) - nodeHeight / 2
|
|
899
|
-
// Place port completely outside the node edge
|
|
900
|
-
const relX = side === 'left'
|
|
901
|
-
? -nodeWidth / 2 - p.port.size.width / 2 // Left of the node
|
|
902
|
-
: nodeWidth / 2 + p.port.size.width / 2 // Right of the node
|
|
903
|
-
p.port.position = { x: relX, y: relY }
|
|
904
|
-
})
|
|
905
|
-
}
|
|
906
|
-
})
|
|
907
|
-
})
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
// Synchronous wrapper that runs async internally
|
|
907
|
+
// Synchronous wrapper
|
|
911
908
|
layout(graph: NetworkGraph): LayoutResult {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
const effectiveOptions = this.getEffectiveOptions(graph)
|
|
915
|
-
const direction = effectiveOptions.direction
|
|
916
|
-
const haPairs = this.detectHAPairs(graph)
|
|
909
|
+
const options = this.getEffectiveOptions(graph)
|
|
910
|
+
const result = this.calculateFallbackLayout(graph, options.direction)
|
|
917
911
|
|
|
918
|
-
//
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
)
|
|
922
|
-
|
|
923
|
-
// Run synchronous layout using elk's sync API
|
|
924
|
-
let result: LayoutResult
|
|
925
|
-
|
|
926
|
-
// Use a workaround: pre-calculate positions based on structure
|
|
927
|
-
result = this.calculateFallbackLayout(graph, direction)
|
|
928
|
-
|
|
929
|
-
// Start async layout and update when done (for future renders)
|
|
930
|
-
this.elk.layout(elkGraph).then((layoutedGraph: ElkNode) => {
|
|
931
|
-
// This will be available for next render
|
|
932
|
-
Object.assign(result, this.extractLayoutResultWithHAExpand(
|
|
933
|
-
graph, layoutedGraph, haVirtualNodes, effectiveOptions
|
|
934
|
-
))
|
|
935
|
-
}).catch(() => {
|
|
936
|
-
// Keep fallback layout
|
|
937
|
-
})
|
|
912
|
+
// Start async layout
|
|
913
|
+
this.layoutAsync(graph).then((asyncResult) => {
|
|
914
|
+
Object.assign(result, asyncResult)
|
|
915
|
+
}).catch(() => {})
|
|
938
916
|
|
|
939
917
|
return result
|
|
940
918
|
}
|
|
@@ -949,189 +927,209 @@ export class HierarchicalLayout {
|
|
|
949
927
|
}
|
|
950
928
|
}
|
|
951
929
|
|
|
952
|
-
private calculateNodeHeight(node: Node): number {
|
|
930
|
+
private calculateNodeHeight(node: Node, portCount: number = 0): number {
|
|
953
931
|
const lines = Array.isArray(node.label) ? node.label.length : 1
|
|
954
|
-
const
|
|
955
|
-
|
|
932
|
+
const labelHeight = lines * LABEL_LINE_HEIGHT
|
|
933
|
+
|
|
934
|
+
const labels = Array.isArray(node.label) ? node.label : [node.label]
|
|
935
|
+
const maxLabelLength = Math.max(...labels.map(l => l.length))
|
|
936
|
+
const labelWidth = maxLabelLength * ESTIMATED_CHAR_WIDTH
|
|
937
|
+
const portWidth = portCount > 0 ? (portCount + 1) * MIN_PORT_SPACING : 0
|
|
938
|
+
const baseContentWidth = Math.max(labelWidth, portWidth)
|
|
939
|
+
const baseNodeWidth = Math.max(this.options.nodeWidth, baseContentWidth + NODE_HORIZONTAL_PADDING)
|
|
940
|
+
const maxIconWidth = Math.round(baseNodeWidth * MAX_ICON_WIDTH_RATIO)
|
|
956
941
|
|
|
957
|
-
// Calculate actual icon height based on vendor icon or default
|
|
958
942
|
let iconHeight = 0
|
|
959
943
|
const iconKey = node.service || node.model
|
|
960
944
|
if (node.vendor && iconKey) {
|
|
961
945
|
const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
|
|
962
946
|
if (iconEntry) {
|
|
963
|
-
const defaultIconSize = 40
|
|
964
|
-
const iconPadding = 16
|
|
965
|
-
const maxIconWidth = this.options.nodeWidth - iconPadding
|
|
966
|
-
|
|
967
947
|
const vendorIcon = iconEntry.default
|
|
968
948
|
const viewBox = iconEntry.viewBox || '0 0 48 48'
|
|
969
949
|
|
|
970
|
-
// Check for PNG-based icons with embedded viewBox
|
|
971
950
|
if (vendorIcon.startsWith('<svg')) {
|
|
972
951
|
const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
|
|
973
952
|
if (viewBoxMatch) {
|
|
974
953
|
const vbWidth = parseInt(viewBoxMatch[1])
|
|
975
954
|
const vbHeight = parseInt(viewBoxMatch[2])
|
|
976
955
|
const aspectRatio = vbWidth / vbHeight
|
|
977
|
-
let
|
|
978
|
-
|
|
979
|
-
|
|
956
|
+
let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
|
|
957
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
958
|
+
if (iconWidth > maxIconWidth) {
|
|
959
|
+
iconHeight = Math.round(maxIconWidth / aspectRatio)
|
|
980
960
|
}
|
|
981
|
-
iconHeight = calcHeight
|
|
982
961
|
} else {
|
|
983
|
-
iconHeight =
|
|
962
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
984
963
|
}
|
|
985
964
|
} else {
|
|
986
|
-
// Parse viewBox for aspect ratio
|
|
987
965
|
const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
|
|
988
966
|
if (vbMatch) {
|
|
989
967
|
const vbWidth = parseInt(vbMatch[3])
|
|
990
968
|
const vbHeight = parseInt(vbMatch[4])
|
|
991
969
|
const aspectRatio = vbWidth / vbHeight
|
|
992
|
-
let
|
|
993
|
-
|
|
994
|
-
|
|
970
|
+
let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
|
|
971
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
972
|
+
if (iconWidth > maxIconWidth) {
|
|
973
|
+
iconHeight = Math.round(maxIconWidth / aspectRatio)
|
|
995
974
|
}
|
|
996
|
-
iconHeight = calcHeight
|
|
997
975
|
} else {
|
|
998
|
-
iconHeight =
|
|
976
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
999
977
|
}
|
|
1000
978
|
}
|
|
1001
979
|
}
|
|
1002
|
-
} else if (node.type) {
|
|
1003
|
-
// Default device type icon (fixed size)
|
|
1004
|
-
iconHeight = 36
|
|
1005
980
|
}
|
|
1006
981
|
|
|
1007
|
-
|
|
982
|
+
if (iconHeight === 0 && node.type && getDeviceIcon(node.type)) {
|
|
983
|
+
iconHeight = DEFAULT_ICON_SIZE
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
|
|
987
|
+
const contentHeight = iconHeight + gap + labelHeight
|
|
988
|
+
return Math.max(this.options.nodeHeight, contentHeight + NODE_VERTICAL_PADDING)
|
|
1008
989
|
}
|
|
1009
990
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
toNode: LayoutNode,
|
|
1017
|
-
fromPort?: { position: Position; side: string },
|
|
1018
|
-
toPort?: { position: Position; side: string }
|
|
1019
|
-
): Position[] {
|
|
1020
|
-
// Use port positions from ELK (lines come FROM ports)
|
|
1021
|
-
let fromPoint: Position
|
|
1022
|
-
let fromSide: string
|
|
1023
|
-
|
|
1024
|
-
if (fromPort) {
|
|
1025
|
-
// Line starts from port position
|
|
1026
|
-
fromPoint = {
|
|
1027
|
-
x: fromNode.position.x + fromPort.position.x,
|
|
1028
|
-
y: fromNode.position.y + fromPort.position.y,
|
|
1029
|
-
}
|
|
1030
|
-
fromSide = fromPort.side
|
|
1031
|
-
} else {
|
|
1032
|
-
// No port - calculate edge position based on target node direction
|
|
1033
|
-
const dx = toNode.position.x - fromNode.position.x
|
|
1034
|
-
const dy = toNode.position.y - fromNode.position.y
|
|
1035
|
-
const halfW = fromNode.size.width / 2
|
|
1036
|
-
const halfH = fromNode.size.height / 2
|
|
1037
|
-
|
|
1038
|
-
if (Math.abs(dx) > Math.abs(dy) * 0.5) {
|
|
1039
|
-
fromSide = dx > 0 ? 'right' : 'left'
|
|
1040
|
-
fromPoint = {
|
|
1041
|
-
x: fromNode.position.x + (dx > 0 ? halfW : -halfW),
|
|
1042
|
-
y: fromNode.position.y,
|
|
1043
|
-
}
|
|
1044
|
-
} else {
|
|
1045
|
-
fromSide = dy > 0 ? 'bottom' : 'top'
|
|
1046
|
-
fromPoint = {
|
|
1047
|
-
x: fromNode.position.x,
|
|
1048
|
-
y: fromNode.position.y + (dy > 0 ? halfH : -halfH),
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
991
|
+
private calculatePortSpacing(portNames: Set<string> | undefined): number {
|
|
992
|
+
if (!portNames || portNames.size === 0) return MIN_PORT_SPACING
|
|
993
|
+
|
|
994
|
+
let maxLabelLength = 0
|
|
995
|
+
for (const name of portNames) {
|
|
996
|
+
maxLabelLength = Math.max(maxLabelLength, name.length)
|
|
1051
997
|
}
|
|
1052
998
|
|
|
1053
|
-
|
|
1054
|
-
|
|
999
|
+
const charWidth = PORT_LABEL_FONT_SIZE * CHAR_WIDTH_RATIO
|
|
1000
|
+
const maxLabelWidth = maxLabelLength * charWidth
|
|
1001
|
+
const spacingFromLabel = maxLabelWidth + PORT_LABEL_PADDING
|
|
1002
|
+
return Math.max(MIN_PORT_SPACING, spacingFromLabel)
|
|
1003
|
+
}
|
|
1055
1004
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1005
|
+
private calculateNodeWidth(node: Node, portInfo: NodePortInfo | undefined): number {
|
|
1006
|
+
const labels = Array.isArray(node.label) ? node.label : [node.label]
|
|
1007
|
+
const maxLabelLength = Math.max(...labels.map(l => l.length))
|
|
1008
|
+
const labelWidth = maxLabelLength * ESTIMATED_CHAR_WIDTH
|
|
1009
|
+
|
|
1010
|
+
const topCount = portInfo?.top.size || 0
|
|
1011
|
+
const bottomCount = portInfo?.bottom.size || 0
|
|
1012
|
+
const maxPortsPerSide = Math.max(topCount, bottomCount)
|
|
1013
|
+
|
|
1014
|
+
const portSpacing = this.calculatePortSpacing(portInfo?.all)
|
|
1015
|
+
const edgeMargin = Math.round(MIN_PORT_SPACING / 2)
|
|
1016
|
+
const portWidth = maxPortsPerSide > 0 ? (maxPortsPerSide - 1) * portSpacing + edgeMargin * 2 : 0
|
|
1017
|
+
|
|
1018
|
+
const paddedContentWidth = Math.max(labelWidth, 0) + NODE_HORIZONTAL_PADDING
|
|
1019
|
+
const baseNodeWidth = Math.max(paddedContentWidth, portWidth)
|
|
1020
|
+
|
|
1021
|
+
const maxIconWidth = Math.round(baseNodeWidth * MAX_ICON_WIDTH_RATIO)
|
|
1022
|
+
let iconWidth = DEFAULT_ICON_SIZE
|
|
1023
|
+
const iconKey = node.service || node.model
|
|
1024
|
+
if (node.vendor && iconKey) {
|
|
1025
|
+
const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
|
|
1026
|
+
if (iconEntry) {
|
|
1027
|
+
const vendorIcon = iconEntry.default
|
|
1028
|
+
const viewBox = iconEntry.viewBox || '0 0 48 48'
|
|
1029
|
+
|
|
1030
|
+
if (vendorIcon.startsWith('<svg')) {
|
|
1031
|
+
const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
|
|
1032
|
+
if (viewBoxMatch) {
|
|
1033
|
+
const vbWidth = parseInt(viewBoxMatch[1])
|
|
1034
|
+
const vbHeight = parseInt(viewBoxMatch[2])
|
|
1035
|
+
const aspectRatio = vbWidth / vbHeight
|
|
1036
|
+
iconWidth = Math.min(Math.round(DEFAULT_ICON_SIZE * aspectRatio), maxIconWidth)
|
|
1037
|
+
}
|
|
1038
|
+
} else {
|
|
1039
|
+
const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
|
|
1040
|
+
if (vbMatch) {
|
|
1041
|
+
const vbWidth = parseInt(vbMatch[3])
|
|
1042
|
+
const vbHeight = parseInt(vbMatch[4])
|
|
1043
|
+
const aspectRatio = vbWidth / vbHeight
|
|
1044
|
+
iconWidth = Math.min(Math.round(DEFAULT_ICON_SIZE * aspectRatio), maxIconWidth)
|
|
1045
|
+
}
|
|
1081
1046
|
}
|
|
1082
1047
|
}
|
|
1083
1048
|
}
|
|
1084
1049
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
1089
|
-
const controlDist = Math.min(dist * 0.4, 80)
|
|
1050
|
+
const paddedIconLabelWidth = Math.max(iconWidth, labelWidth) + NODE_HORIZONTAL_PADDING
|
|
1051
|
+
return Math.max(paddedIconLabelWidth, portWidth)
|
|
1052
|
+
}
|
|
1090
1053
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1054
|
+
private calculateTotalBounds(
|
|
1055
|
+
nodes: Map<string, LayoutNode>,
|
|
1056
|
+
subgraphs: Map<string, LayoutSubgraph>
|
|
1057
|
+
): Bounds {
|
|
1058
|
+
let minX = Infinity, minY = Infinity
|
|
1059
|
+
let maxX = -Infinity, maxY = -Infinity
|
|
1093
1060
|
|
|
1094
|
-
|
|
1095
|
-
|
|
1061
|
+
nodes.forEach((node) => {
|
|
1062
|
+
let left = node.position.x - node.size.width / 2
|
|
1063
|
+
let right = node.position.x + node.size.width / 2
|
|
1064
|
+
let top = node.position.y - node.size.height / 2
|
|
1065
|
+
let bottom = node.position.y + node.size.height / 2
|
|
1066
|
+
|
|
1067
|
+
if (node.ports) {
|
|
1068
|
+
node.ports.forEach((port) => {
|
|
1069
|
+
const portX = node.position.x + port.position.x
|
|
1070
|
+
const portY = node.position.y + port.position.y
|
|
1071
|
+
left = Math.min(left, portX - port.size.width / 2)
|
|
1072
|
+
right = Math.max(right, portX + port.size.width / 2)
|
|
1073
|
+
top = Math.min(top, portY - port.size.height / 2)
|
|
1074
|
+
bottom = Math.max(bottom, portY + port.size.height / 2)
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1096
1077
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1078
|
+
minX = Math.min(minX, left)
|
|
1079
|
+
minY = Math.min(minY, top)
|
|
1080
|
+
maxX = Math.max(maxX, right)
|
|
1081
|
+
maxY = Math.max(maxY, bottom)
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
subgraphs.forEach((sg) => {
|
|
1085
|
+
minX = Math.min(minX, sg.bounds.x)
|
|
1086
|
+
minY = Math.min(minY, sg.bounds.y)
|
|
1087
|
+
maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width)
|
|
1088
|
+
maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height)
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
const padding = 50
|
|
1092
|
+
|
|
1093
|
+
if (minX === Infinity) {
|
|
1094
|
+
return { x: 0, y: 0, width: 400, height: 300 }
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return {
|
|
1098
|
+
x: minX - padding,
|
|
1099
|
+
y: minY - padding,
|
|
1100
|
+
width: maxX - minX + padding * 2,
|
|
1101
|
+
height: maxY - minY + padding * 2,
|
|
1112
1102
|
}
|
|
1113
1103
|
}
|
|
1114
1104
|
|
|
1115
1105
|
private calculateFallbackLayout(graph: NetworkGraph, _direction: LayoutDirection): LayoutResult {
|
|
1116
|
-
// Simple fallback layout when async isn't available
|
|
1117
1106
|
const layoutNodes = new Map<string, LayoutNode>()
|
|
1118
1107
|
const layoutSubgraphs = new Map<string, LayoutSubgraph>()
|
|
1119
1108
|
const layoutLinks = new Map<string, LayoutLink>()
|
|
1120
1109
|
|
|
1121
|
-
//
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1110
|
+
// Detect HA pairs for port assignment
|
|
1111
|
+
const haPairs = this.detectHAPairs(graph)
|
|
1112
|
+
const haPairSet = new Set<string>()
|
|
1113
|
+
for (const pair of haPairs) {
|
|
1114
|
+
haPairSet.add([pair.nodeA, pair.nodeB].sort().join(':'))
|
|
1115
|
+
}
|
|
1116
|
+
const nodePorts = collectNodePorts(graph, haPairSet)
|
|
1117
|
+
|
|
1118
|
+
let x = 100, y = 100, col = 0
|
|
1127
1119
|
const maxCols = 4
|
|
1120
|
+
const rowHeight = this.options.nodeHeight + this.options.rankSpacing
|
|
1128
1121
|
|
|
1129
1122
|
for (const node of graph.nodes) {
|
|
1130
|
-
const
|
|
1123
|
+
const portInfo = nodePorts.get(node.id)
|
|
1124
|
+
const portCount = portInfo?.all.size || 0
|
|
1125
|
+
const height = this.calculateNodeHeight(node, portCount)
|
|
1126
|
+
const width = this.calculateNodeWidth(node, portInfo)
|
|
1127
|
+
const colWidth = width + this.options.nodeSpacing
|
|
1128
|
+
|
|
1131
1129
|
layoutNodes.set(node.id, {
|
|
1132
1130
|
id: node.id,
|
|
1133
|
-
position: { x: x +
|
|
1134
|
-
size: { width
|
|
1131
|
+
position: { x: x + width / 2, y: y + height / 2 },
|
|
1132
|
+
size: { width, height },
|
|
1135
1133
|
node,
|
|
1136
1134
|
})
|
|
1137
1135
|
|
|
@@ -1145,7 +1143,6 @@ export class HierarchicalLayout {
|
|
|
1145
1143
|
}
|
|
1146
1144
|
}
|
|
1147
1145
|
|
|
1148
|
-
// Simple links
|
|
1149
1146
|
graph.links.forEach((link, index) => {
|
|
1150
1147
|
const fromId = getNodeId(link.from)
|
|
1151
1148
|
const toId = getNodeId(link.to)
|
|
@@ -1175,368 +1172,49 @@ export class HierarchicalLayout {
|
|
|
1175
1172
|
}
|
|
1176
1173
|
}
|
|
1177
1174
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
let minX = Infinity, minY = Infinity
|
|
1183
|
-
let maxX = -Infinity, maxY = -Infinity
|
|
1184
|
-
|
|
1185
|
-
nodes.forEach((node) => {
|
|
1186
|
-
minX = Math.min(minX, node.position.x - node.size.width / 2)
|
|
1187
|
-
minY = Math.min(minY, node.position.y - node.size.height / 2)
|
|
1188
|
-
maxX = Math.max(maxX, node.position.x + node.size.width / 2)
|
|
1189
|
-
maxY = Math.max(maxY, node.position.y + node.size.height / 2)
|
|
1190
|
-
})
|
|
1191
|
-
|
|
1192
|
-
subgraphs.forEach((sg) => {
|
|
1193
|
-
minX = Math.min(minX, sg.bounds.x)
|
|
1194
|
-
minY = Math.min(minY, sg.bounds.y)
|
|
1195
|
-
maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width)
|
|
1196
|
-
maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height)
|
|
1197
|
-
})
|
|
1198
|
-
|
|
1199
|
-
const padding = 50
|
|
1200
|
-
|
|
1201
|
-
if (minX === Infinity) {
|
|
1202
|
-
return { x: 0, y: 0, width: 400, height: 300 }
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
return {
|
|
1206
|
-
x: minX - padding,
|
|
1207
|
-
y: minY - padding,
|
|
1208
|
-
width: maxX - minX + padding * 2,
|
|
1209
|
-
height: maxY - minY + padding * 2,
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
/**
|
|
1214
|
-
* Detect redundancy pairs based on link.redundancy property
|
|
1215
|
-
* Returns array of [nodeA, nodeB] pairs that should be placed on the same layer
|
|
1216
|
-
*/
|
|
1217
|
-
private detectHAPairs(graph: NetworkGraph): { nodeA: string; nodeB: string; minLength?: number }[] {
|
|
1218
|
-
const pairs: { nodeA: string; nodeB: string; minLength?: number }[] = []
|
|
1219
|
-
const processedPairs = new Set<string>()
|
|
1175
|
+
/** Detect HA pairs from redundancy links */
|
|
1176
|
+
private detectHAPairs(graph: NetworkGraph): { nodeA: string; nodeB: string }[] {
|
|
1177
|
+
const pairs: { nodeA: string; nodeB: string }[] = []
|
|
1178
|
+
const processed = new Set<string>()
|
|
1220
1179
|
|
|
1221
1180
|
for (const link of graph.links) {
|
|
1222
|
-
// Only process links with redundancy property set
|
|
1223
1181
|
if (!link.redundancy) continue
|
|
1224
1182
|
|
|
1225
1183
|
const fromId = getNodeId(link.from)
|
|
1226
1184
|
const toId = getNodeId(link.to)
|
|
1227
|
-
const
|
|
1228
|
-
if (
|
|
1185
|
+
const key = [fromId, toId].sort().join(':')
|
|
1186
|
+
if (processed.has(key)) continue
|
|
1229
1187
|
|
|
1230
|
-
pairs.push({
|
|
1231
|
-
|
|
1232
|
-
nodeB: toId,
|
|
1233
|
-
minLength: link.style?.minLength,
|
|
1234
|
-
})
|
|
1235
|
-
processedPairs.add(pairKey)
|
|
1188
|
+
pairs.push({ nodeA: fromId, nodeB: toId })
|
|
1189
|
+
processed.add(key)
|
|
1236
1190
|
}
|
|
1237
1191
|
|
|
1238
1192
|
return pairs
|
|
1239
1193
|
}
|
|
1240
1194
|
|
|
1241
|
-
/**
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
result: LayoutResult,
|
|
1246
|
-
graph: NetworkGraph,
|
|
1247
|
-
direction: LayoutDirection
|
|
1248
|
-
): void {
|
|
1249
|
-
const isVertical = direction === 'TB' || direction === 'BT'
|
|
1250
|
-
let adjusted = false
|
|
1251
|
-
|
|
1252
|
-
for (const link of graph.links) {
|
|
1253
|
-
const minLength = link.style?.minLength
|
|
1254
|
-
if (!minLength) continue
|
|
1255
|
-
|
|
1256
|
-
// Skip HA links (already handled by two-pass layout with layerChoiceConstraint)
|
|
1257
|
-
if (link.redundancy) continue
|
|
1258
|
-
|
|
1259
|
-
const fromId = getNodeId(link.from)
|
|
1260
|
-
const toId = getNodeId(link.to)
|
|
1261
|
-
const fromNode = result.nodes.get(fromId)
|
|
1262
|
-
const toNode = result.nodes.get(toId)
|
|
1263
|
-
|
|
1264
|
-
if (!fromNode || !toNode) continue
|
|
1265
|
-
|
|
1266
|
-
// Calculate current distance
|
|
1267
|
-
const dx = toNode.position.x - fromNode.position.x
|
|
1268
|
-
const dy = toNode.position.y - fromNode.position.y
|
|
1269
|
-
const currentDist = Math.sqrt(dx * dx + dy * dy)
|
|
1195
|
+
/** Generate orthogonal path between two points */
|
|
1196
|
+
private generateOrthogonalPath(start: Position, end: Position): Position[] {
|
|
1197
|
+
const dx = end.x - start.x
|
|
1198
|
+
const dy = end.y - start.y
|
|
1270
1199
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const scale = minLength / currentDist
|
|
1275
|
-
|
|
1276
|
-
if (isVertical) {
|
|
1277
|
-
// For TB/BT layout, primarily adjust Y (move target node down/up)
|
|
1278
|
-
const newDy = dy * scale
|
|
1279
|
-
const deltaY = newDy - dy
|
|
1280
|
-
toNode.position.y += deltaY
|
|
1281
|
-
} else {
|
|
1282
|
-
// For LR/RL layout, primarily adjust X
|
|
1283
|
-
const newDx = dx * scale
|
|
1284
|
-
const deltaX = newDx - dx
|
|
1285
|
-
toNode.position.x += deltaX
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
adjusted = true
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
if (adjusted) {
|
|
1292
|
-
this.recalculateAllEdges(result)
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
/**
|
|
1297
|
-
* Calculate edge path with offset for multiple connections
|
|
1298
|
-
*/
|
|
1299
|
-
private calculateEdgePathWithOffset(
|
|
1300
|
-
fromNode: LayoutNode,
|
|
1301
|
-
toNode: LayoutNode,
|
|
1302
|
-
outIndex: number,
|
|
1303
|
-
outTotal: number,
|
|
1304
|
-
inIndex: number,
|
|
1305
|
-
inTotal: number
|
|
1306
|
-
): Position[] {
|
|
1307
|
-
const fromPos = fromNode.position
|
|
1308
|
-
const toPos = toNode.position
|
|
1309
|
-
const fromSize = fromNode.size
|
|
1310
|
-
const toSize = toNode.size
|
|
1311
|
-
|
|
1312
|
-
// Calculate direction from source to target
|
|
1313
|
-
const dx = toPos.x - fromPos.x
|
|
1314
|
-
const dy = toPos.y - fromPos.y
|
|
1315
|
-
|
|
1316
|
-
// Calculate offset for distributing multiple connections
|
|
1317
|
-
const portSpacing = 15 // pixels between port connections
|
|
1318
|
-
|
|
1319
|
-
let fromPoint: Position
|
|
1320
|
-
let toPoint: Position
|
|
1321
|
-
let controlDist: number
|
|
1322
|
-
|
|
1323
|
-
// For vertical layouts (TB), prefer top/bottom connections
|
|
1324
|
-
if (Math.abs(dy) > Math.abs(dx) * 0.3) {
|
|
1325
|
-
// Primarily vertical
|
|
1326
|
-
// Calculate horizontal offset for multiple outgoing/incoming links
|
|
1327
|
-
const outOffset = outTotal > 1 ? (outIndex - (outTotal - 1) / 2) * portSpacing : 0
|
|
1328
|
-
const inOffset = inTotal > 1 ? (inIndex - (inTotal - 1) / 2) * portSpacing : 0
|
|
1329
|
-
|
|
1330
|
-
if (dy > 0) {
|
|
1331
|
-
fromPoint = { x: fromPos.x + outOffset, y: fromPos.y + fromSize.height / 2 }
|
|
1332
|
-
toPoint = { x: toPos.x + inOffset, y: toPos.y - toSize.height / 2 }
|
|
1333
|
-
} else {
|
|
1334
|
-
fromPoint = { x: fromPos.x + outOffset, y: fromPos.y - fromSize.height / 2 }
|
|
1335
|
-
toPoint = { x: toPos.x + inOffset, y: toPos.y + toSize.height / 2 }
|
|
1336
|
-
}
|
|
1337
|
-
controlDist = Math.min(Math.abs(toPoint.y - fromPoint.y) * 0.4, 80)
|
|
1338
|
-
|
|
1339
|
-
return [
|
|
1340
|
-
fromPoint,
|
|
1341
|
-
{ x: fromPoint.x, y: fromPoint.y + Math.sign(dy) * controlDist },
|
|
1342
|
-
{ x: toPoint.x, y: toPoint.y - Math.sign(dy) * controlDist },
|
|
1343
|
-
toPoint,
|
|
1344
|
-
]
|
|
1345
|
-
} else {
|
|
1346
|
-
// Primarily horizontal
|
|
1347
|
-
// Calculate vertical offset for multiple outgoing/incoming links
|
|
1348
|
-
const outOffset = outTotal > 1 ? (outIndex - (outTotal - 1) / 2) * portSpacing : 0
|
|
1349
|
-
const inOffset = inTotal > 1 ? (inIndex - (inTotal - 1) / 2) * portSpacing : 0
|
|
1350
|
-
|
|
1351
|
-
if (dx > 0) {
|
|
1352
|
-
fromPoint = { x: fromPos.x + fromSize.width / 2, y: fromPos.y + outOffset }
|
|
1353
|
-
toPoint = { x: toPos.x - toSize.width / 2, y: toPos.y + inOffset }
|
|
1354
|
-
} else {
|
|
1355
|
-
fromPoint = { x: fromPos.x - fromSize.width / 2, y: fromPos.y + outOffset }
|
|
1356
|
-
toPoint = { x: toPos.x + toSize.width / 2, y: toPos.y + inOffset }
|
|
1357
|
-
}
|
|
1358
|
-
controlDist = Math.min(Math.abs(toPoint.x - fromPoint.x) * 0.4, 80)
|
|
1359
|
-
|
|
1360
|
-
return [
|
|
1361
|
-
fromPoint,
|
|
1362
|
-
{ x: fromPoint.x + Math.sign(dx) * controlDist, y: fromPoint.y },
|
|
1363
|
-
{ x: toPoint.x - Math.sign(dx) * controlDist, y: toPoint.y },
|
|
1364
|
-
toPoint,
|
|
1365
|
-
]
|
|
1200
|
+
// If points are nearly aligned, use direct line
|
|
1201
|
+
if (Math.abs(dx) < 5) {
|
|
1202
|
+
return [start, end]
|
|
1366
1203
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
/**
|
|
1370
|
-
* Recalculate all edge paths with port offset distribution
|
|
1371
|
-
*/
|
|
1372
|
-
private recalculateAllEdges(result: LayoutResult): void {
|
|
1373
|
-
// Group links by source and target nodes to calculate offsets
|
|
1374
|
-
const outgoingLinks = new Map<string, string[]>() // nodeId -> [linkIds]
|
|
1375
|
-
const incomingLinks = new Map<string, string[]>() // nodeId -> [linkIds]
|
|
1376
|
-
|
|
1377
|
-
result.links.forEach((layoutLink, linkId) => {
|
|
1378
|
-
// Outgoing from source
|
|
1379
|
-
if (!outgoingLinks.has(layoutLink.from)) {
|
|
1380
|
-
outgoingLinks.set(layoutLink.from, [])
|
|
1381
|
-
}
|
|
1382
|
-
outgoingLinks.get(layoutLink.from)!.push(linkId)
|
|
1383
|
-
|
|
1384
|
-
// Incoming to target
|
|
1385
|
-
if (!incomingLinks.has(layoutLink.to)) {
|
|
1386
|
-
incomingLinks.set(layoutLink.to, [])
|
|
1387
|
-
}
|
|
1388
|
-
incomingLinks.get(layoutLink.to)!.push(linkId)
|
|
1389
|
-
})
|
|
1390
|
-
|
|
1391
|
-
// Sort outgoing links by target X position to reduce crossings
|
|
1392
|
-
outgoingLinks.forEach((linkIds, _nodeId) => {
|
|
1393
|
-
linkIds.sort((a, b) => {
|
|
1394
|
-
const linkA = result.links.get(a)
|
|
1395
|
-
const linkB = result.links.get(b)
|
|
1396
|
-
if (!linkA || !linkB) return 0
|
|
1397
|
-
const targetA = result.nodes.get(linkA.to)
|
|
1398
|
-
const targetB = result.nodes.get(linkB.to)
|
|
1399
|
-
if (!targetA || !targetB) return 0
|
|
1400
|
-
return targetA.position.x - targetB.position.x
|
|
1401
|
-
})
|
|
1402
|
-
})
|
|
1403
|
-
|
|
1404
|
-
// Sort incoming links by source X position to reduce crossings
|
|
1405
|
-
incomingLinks.forEach((linkIds, _nodeId) => {
|
|
1406
|
-
linkIds.sort((a, b) => {
|
|
1407
|
-
const linkA = result.links.get(a)
|
|
1408
|
-
const linkB = result.links.get(b)
|
|
1409
|
-
if (!linkA || !linkB) return 0
|
|
1410
|
-
const sourceA = result.nodes.get(linkA.from)
|
|
1411
|
-
const sourceB = result.nodes.get(linkB.from)
|
|
1412
|
-
if (!sourceA || !sourceB) return 0
|
|
1413
|
-
return sourceA.position.x - sourceB.position.x
|
|
1414
|
-
})
|
|
1415
|
-
})
|
|
1416
|
-
|
|
1417
|
-
// Calculate edge paths with offset - prefer port positions when available
|
|
1418
|
-
result.links.forEach((layoutLink, linkId) => {
|
|
1419
|
-
const fromNode = result.nodes.get(layoutLink.from)
|
|
1420
|
-
const toNode = result.nodes.get(layoutLink.to)
|
|
1421
|
-
|
|
1422
|
-
if (fromNode && toNode) {
|
|
1423
|
-
// Check if this link uses ports
|
|
1424
|
-
const fromPortId = layoutLink.fromEndpoint.port
|
|
1425
|
-
? `${layoutLink.from}:${layoutLink.fromEndpoint.port}`
|
|
1426
|
-
: undefined
|
|
1427
|
-
const toPortId = layoutLink.toEndpoint.port
|
|
1428
|
-
? `${layoutLink.to}:${layoutLink.toEndpoint.port}`
|
|
1429
|
-
: undefined
|
|
1430
|
-
const fromPort = fromPortId ? fromNode.ports?.get(fromPortId) : undefined
|
|
1431
|
-
const toPort = toPortId ? toNode.ports?.get(toPortId) : undefined
|
|
1432
|
-
|
|
1433
|
-
if (fromPort || toPort) {
|
|
1434
|
-
// Use port-aware edge calculation
|
|
1435
|
-
layoutLink.points = this.calculateEdgePathWithPorts(fromNode, toNode, fromPort, toPort)
|
|
1436
|
-
} else {
|
|
1437
|
-
// No ports - use offset-based calculation
|
|
1438
|
-
const outLinks = outgoingLinks.get(layoutLink.from) || []
|
|
1439
|
-
const inLinks = incomingLinks.get(layoutLink.to) || []
|
|
1440
|
-
const outIndex = outLinks.indexOf(linkId)
|
|
1441
|
-
const inIndex = inLinks.indexOf(linkId)
|
|
1442
|
-
|
|
1443
|
-
layoutLink.points = this.calculateEdgePathWithOffset(
|
|
1444
|
-
fromNode, toNode,
|
|
1445
|
-
outIndex, outLinks.length,
|
|
1446
|
-
inIndex, inLinks.length
|
|
1447
|
-
)
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
})
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
/**
|
|
1454
|
-
* Recalculate subgraph bounds to contain all child nodes after adjustments
|
|
1455
|
-
*/
|
|
1456
|
-
private recalculateSubgraphBounds(result: LayoutResult, graph: NetworkGraph): void {
|
|
1457
|
-
if (!graph.subgraphs) return
|
|
1458
|
-
|
|
1459
|
-
const padding = this.options.subgraphPadding
|
|
1460
|
-
const labelHeight = this.options.subgraphLabelHeight
|
|
1461
|
-
|
|
1462
|
-
// Build node-to-subgraph mapping
|
|
1463
|
-
const nodeToSubgraph = new Map<string, string>()
|
|
1464
|
-
for (const node of graph.nodes) {
|
|
1465
|
-
if (node.parent) {
|
|
1466
|
-
nodeToSubgraph.set(node.id, node.parent)
|
|
1467
|
-
}
|
|
1204
|
+
if (Math.abs(dy) < 5) {
|
|
1205
|
+
return [start, end]
|
|
1468
1206
|
}
|
|
1469
1207
|
|
|
1470
|
-
//
|
|
1471
|
-
|
|
1472
|
-
const childNodes: LayoutNode[] = []
|
|
1208
|
+
// Use midpoint for orthogonal routing
|
|
1209
|
+
const midY = start.y + dy / 2
|
|
1473
1210
|
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
if (childNodes.length === 0) return
|
|
1482
|
-
|
|
1483
|
-
// Calculate bounding box of all child nodes
|
|
1484
|
-
let minX = Infinity, minY = Infinity
|
|
1485
|
-
let maxX = -Infinity, maxY = -Infinity
|
|
1486
|
-
|
|
1487
|
-
for (const node of childNodes) {
|
|
1488
|
-
const left = node.position.x - node.size.width / 2
|
|
1489
|
-
const right = node.position.x + node.size.width / 2
|
|
1490
|
-
const top = node.position.y - node.size.height / 2
|
|
1491
|
-
const bottom = node.position.y + node.size.height / 2
|
|
1492
|
-
|
|
1493
|
-
minX = Math.min(minX, left)
|
|
1494
|
-
minY = Math.min(minY, top)
|
|
1495
|
-
maxX = Math.max(maxX, right)
|
|
1496
|
-
maxY = Math.max(maxY, bottom)
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
// Apply padding
|
|
1500
|
-
layoutSubgraph.bounds = {
|
|
1501
|
-
x: minX - padding,
|
|
1502
|
-
y: minY - padding - labelHeight,
|
|
1503
|
-
width: (maxX - minX) + padding * 2,
|
|
1504
|
-
height: (maxY - minY) + padding * 2 + labelHeight,
|
|
1505
|
-
}
|
|
1506
|
-
})
|
|
1507
|
-
|
|
1508
|
-
// Update overall bounds
|
|
1509
|
-
let minX = Infinity, minY = Infinity
|
|
1510
|
-
let maxX = -Infinity, maxY = -Infinity
|
|
1511
|
-
|
|
1512
|
-
result.nodes.forEach((node) => {
|
|
1513
|
-
const left = node.position.x - node.size.width / 2
|
|
1514
|
-
const right = node.position.x + node.size.width / 2
|
|
1515
|
-
const top = node.position.y - node.size.height / 2
|
|
1516
|
-
const bottom = node.position.y + node.size.height / 2
|
|
1517
|
-
|
|
1518
|
-
minX = Math.min(minX, left)
|
|
1519
|
-
minY = Math.min(minY, top)
|
|
1520
|
-
maxX = Math.max(maxX, right)
|
|
1521
|
-
maxY = Math.max(maxY, bottom)
|
|
1522
|
-
})
|
|
1523
|
-
|
|
1524
|
-
result.subgraphs.forEach((sg) => {
|
|
1525
|
-
minX = Math.min(minX, sg.bounds.x)
|
|
1526
|
-
minY = Math.min(minY, sg.bounds.y)
|
|
1527
|
-
maxX = Math.max(maxX, sg.bounds.x + sg.bounds.width)
|
|
1528
|
-
maxY = Math.max(maxY, sg.bounds.y + sg.bounds.height)
|
|
1529
|
-
})
|
|
1530
|
-
|
|
1531
|
-
const margin = 40
|
|
1532
|
-
result.bounds = {
|
|
1533
|
-
x: minX - margin,
|
|
1534
|
-
y: minY - margin,
|
|
1535
|
-
width: (maxX - minX) + margin * 2,
|
|
1536
|
-
height: (maxY - minY) + margin * 2,
|
|
1537
|
-
}
|
|
1211
|
+
return [
|
|
1212
|
+
start,
|
|
1213
|
+
{ x: start.x, y: midY },
|
|
1214
|
+
{ x: end.x, y: midY },
|
|
1215
|
+
end,
|
|
1216
|
+
]
|
|
1538
1217
|
}
|
|
1539
|
-
|
|
1540
1218
|
}
|
|
1541
1219
|
|
|
1542
1220
|
// Default instance
|