@shumoku/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/icons/build-icons.d.ts +6 -0
- package/dist/icons/build-icons.d.ts.map +1 -0
- package/dist/icons/build-icons.js +163 -0
- package/dist/icons/build-icons.js.map +1 -0
- package/dist/icons/generated-icons.d.ts +32 -0
- package/dist/icons/generated-icons.d.ts.map +1 -0
- package/dist/icons/generated-icons.js +88 -0
- package/dist/icons/generated-icons.js.map +1 -0
- package/dist/icons/index.d.ts +2 -0
- package/dist/icons/index.d.ts.map +1 -0
- package/dist/icons/index.js +2 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/hierarchical.d.ts +73 -0
- package/dist/layout/hierarchical.d.ts.map +1 -0
- package/dist/layout/hierarchical.js +1320 -0
- package/dist/layout/hierarchical.js.map +1 -0
- package/dist/layout/index.d.ts +6 -0
- package/dist/layout/index.d.ts.map +1 -0
- package/dist/layout/index.js +5 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/models/index.d.ts +5 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/index.js +5 -0
- package/dist/models/index.js.map +1 -0
- package/dist/models/types.d.ts +381 -0
- package/dist/models/types.d.ts.map +1 -0
- package/dist/models/types.js +61 -0
- package/dist/models/types.js.map +1 -0
- package/dist/renderer/components/index.d.ts +8 -0
- package/dist/renderer/components/index.d.ts.map +1 -0
- package/dist/renderer/components/index.js +8 -0
- package/dist/renderer/components/index.js.map +1 -0
- package/dist/renderer/components/link-renderer.d.ts +11 -0
- package/dist/renderer/components/link-renderer.d.ts.map +1 -0
- package/dist/renderer/components/link-renderer.js +340 -0
- package/dist/renderer/components/link-renderer.js.map +1 -0
- package/dist/renderer/components/node-renderer.d.ts +14 -0
- package/dist/renderer/components/node-renderer.d.ts.map +1 -0
- package/dist/renderer/components/node-renderer.js +242 -0
- package/dist/renderer/components/node-renderer.js.map +1 -0
- package/dist/renderer/components/port-renderer.d.ts +8 -0
- package/dist/renderer/components/port-renderer.d.ts.map +1 -0
- package/dist/renderer/components/port-renderer.js +85 -0
- package/dist/renderer/components/port-renderer.js.map +1 -0
- package/dist/renderer/components/subgraph-renderer.d.ts +13 -0
- package/dist/renderer/components/subgraph-renderer.d.ts.map +1 -0
- package/dist/renderer/components/subgraph-renderer.js +85 -0
- package/dist/renderer/components/subgraph-renderer.js.map +1 -0
- package/dist/renderer/icon-registry/index.d.ts +6 -0
- package/dist/renderer/icon-registry/index.d.ts.map +1 -0
- package/dist/renderer/icon-registry/index.js +5 -0
- package/dist/renderer/icon-registry/index.js.map +1 -0
- package/dist/renderer/icon-registry/registry.d.ts +25 -0
- package/dist/renderer/icon-registry/registry.d.ts.map +1 -0
- package/dist/renderer/icon-registry/registry.js +85 -0
- package/dist/renderer/icon-registry/registry.js.map +1 -0
- package/dist/renderer/icon-registry/types.d.ts +44 -0
- package/dist/renderer/icon-registry/types.d.ts.map +1 -0
- package/dist/renderer/icon-registry/types.js +5 -0
- package/dist/renderer/icon-registry/types.js.map +1 -0
- package/dist/renderer/index.d.ts +6 -0
- package/dist/renderer/index.d.ts.map +1 -0
- package/dist/renderer/index.js +5 -0
- package/dist/renderer/index.js.map +1 -0
- package/dist/renderer/render-model/builder.d.ts +43 -0
- package/dist/renderer/render-model/builder.d.ts.map +1 -0
- package/dist/renderer/render-model/builder.js +646 -0
- package/dist/renderer/render-model/builder.js.map +1 -0
- package/dist/renderer/render-model/index.d.ts +6 -0
- package/dist/renderer/render-model/index.d.ts.map +1 -0
- package/dist/renderer/render-model/index.js +5 -0
- package/dist/renderer/render-model/index.js.map +1 -0
- package/dist/renderer/render-model/types.d.ts +216 -0
- package/dist/renderer/render-model/types.d.ts.map +1 -0
- package/dist/renderer/render-model/types.js +6 -0
- package/dist/renderer/render-model/types.js.map +1 -0
- package/dist/renderer/renderer-types.d.ts +55 -0
- package/dist/renderer/renderer-types.d.ts.map +1 -0
- package/dist/renderer/renderer-types.js +5 -0
- package/dist/renderer/renderer-types.js.map +1 -0
- package/dist/renderer/svg-builder.d.ts +152 -0
- package/dist/renderer/svg-builder.d.ts.map +1 -0
- package/dist/renderer/svg-builder.js +176 -0
- package/dist/renderer/svg-builder.js.map +1 -0
- package/dist/renderer/svg-dom/builders/defs.d.ts +10 -0
- package/dist/renderer/svg-dom/builders/defs.d.ts.map +1 -0
- package/dist/renderer/svg-dom/builders/defs.js +82 -0
- package/dist/renderer/svg-dom/builders/defs.js.map +1 -0
- package/dist/renderer/svg-dom/builders/index.d.ts +9 -0
- package/dist/renderer/svg-dom/builders/index.d.ts.map +1 -0
- package/dist/renderer/svg-dom/builders/index.js +9 -0
- package/dist/renderer/svg-dom/builders/index.js.map +1 -0
- package/dist/renderer/svg-dom/builders/link.d.ts +18 -0
- package/dist/renderer/svg-dom/builders/link.d.ts.map +1 -0
- package/dist/renderer/svg-dom/builders/link.js +188 -0
- package/dist/renderer/svg-dom/builders/link.js.map +1 -0
- package/dist/renderer/svg-dom/builders/node.d.ts +15 -0
- package/dist/renderer/svg-dom/builders/node.d.ts.map +1 -0
- package/dist/renderer/svg-dom/builders/node.js +262 -0
- package/dist/renderer/svg-dom/builders/node.js.map +1 -0
- package/dist/renderer/svg-dom/builders/subgraph.d.ts +14 -0
- package/dist/renderer/svg-dom/builders/subgraph.d.ts.map +1 -0
- package/dist/renderer/svg-dom/builders/subgraph.js +63 -0
- package/dist/renderer/svg-dom/builders/subgraph.js.map +1 -0
- package/dist/renderer/svg-dom/builders/utils.d.ts +40 -0
- package/dist/renderer/svg-dom/builders/utils.d.ts.map +1 -0
- package/dist/renderer/svg-dom/builders/utils.js +79 -0
- package/dist/renderer/svg-dom/builders/utils.js.map +1 -0
- package/dist/renderer/svg-dom/index.d.ts +9 -0
- package/dist/renderer/svg-dom/index.d.ts.map +1 -0
- package/dist/renderer/svg-dom/index.js +7 -0
- package/dist/renderer/svg-dom/index.js.map +1 -0
- package/dist/renderer/svg-dom/interaction.d.ts +69 -0
- package/dist/renderer/svg-dom/interaction.d.ts.map +1 -0
- package/dist/renderer/svg-dom/interaction.js +296 -0
- package/dist/renderer/svg-dom/interaction.js.map +1 -0
- package/dist/renderer/svg-dom/renderer.d.ts +47 -0
- package/dist/renderer/svg-dom/renderer.d.ts.map +1 -0
- package/dist/renderer/svg-dom/renderer.js +188 -0
- package/dist/renderer/svg-dom/renderer.js.map +1 -0
- package/dist/renderer/svg-string/builders/defs.d.ts +10 -0
- package/dist/renderer/svg-string/builders/defs.d.ts.map +1 -0
- package/dist/renderer/svg-string/builders/defs.js +43 -0
- package/dist/renderer/svg-string/builders/defs.js.map +1 -0
- package/dist/renderer/svg-string/builders/link.d.ts +10 -0
- package/dist/renderer/svg-string/builders/link.d.ts.map +1 -0
- package/dist/renderer/svg-string/builders/link.js +149 -0
- package/dist/renderer/svg-string/builders/link.js.map +1 -0
- package/dist/renderer/svg-string/builders/node.d.ts +10 -0
- package/dist/renderer/svg-string/builders/node.d.ts.map +1 -0
- package/dist/renderer/svg-string/builders/node.js +134 -0
- package/dist/renderer/svg-string/builders/node.js.map +1 -0
- package/dist/renderer/svg-string/builders/subgraph.d.ts +10 -0
- package/dist/renderer/svg-string/builders/subgraph.d.ts.map +1 -0
- package/dist/renderer/svg-string/builders/subgraph.js +59 -0
- package/dist/renderer/svg-string/builders/subgraph.js.map +1 -0
- package/dist/renderer/svg-string/index.d.ts +5 -0
- package/dist/renderer/svg-string/index.d.ts.map +1 -0
- package/dist/renderer/svg-string/index.js +5 -0
- package/dist/renderer/svg-string/index.js.map +1 -0
- package/dist/renderer/svg-string/renderer.d.ts +17 -0
- package/dist/renderer/svg-string/renderer.d.ts.map +1 -0
- package/dist/renderer/svg-string/renderer.js +53 -0
- package/dist/renderer/svg-string/renderer.js.map +1 -0
- package/dist/renderer/svg.d.ts +105 -0
- package/dist/renderer/svg.d.ts.map +1 -0
- package/dist/renderer/svg.js +804 -0
- package/dist/renderer/svg.js.map +1 -0
- package/dist/renderer/text-measurer/browser-measurer.d.ts +25 -0
- package/dist/renderer/text-measurer/browser-measurer.d.ts.map +1 -0
- package/dist/renderer/text-measurer/browser-measurer.js +85 -0
- package/dist/renderer/text-measurer/browser-measurer.js.map +1 -0
- package/dist/renderer/text-measurer/fallback-measurer.d.ts +22 -0
- package/dist/renderer/text-measurer/fallback-measurer.d.ts.map +1 -0
- package/dist/renderer/text-measurer/fallback-measurer.js +113 -0
- package/dist/renderer/text-measurer/fallback-measurer.js.map +1 -0
- package/dist/renderer/text-measurer/index.d.ts +13 -0
- package/dist/renderer/text-measurer/index.d.ts.map +1 -0
- package/dist/renderer/text-measurer/index.js +35 -0
- package/dist/renderer/text-measurer/index.js.map +1 -0
- package/dist/renderer/text-measurer/types.d.ts +30 -0
- package/dist/renderer/text-measurer/types.d.ts.map +1 -0
- package/dist/renderer/text-measurer/types.js +5 -0
- package/dist/renderer/text-measurer/types.js.map +1 -0
- package/dist/renderer/theme.d.ts +29 -0
- package/dist/renderer/theme.d.ts.map +1 -0
- package/dist/renderer/theme.js +80 -0
- package/dist/renderer/theme.js.map +1 -0
- package/dist/themes/dark.d.ts +6 -0
- package/dist/themes/dark.d.ts.map +1 -0
- package/dist/themes/dark.js +96 -0
- package/dist/themes/dark.js.map +1 -0
- package/dist/themes/index.d.ts +13 -0
- package/dist/themes/index.d.ts.map +1 -0
- package/dist/themes/index.js +15 -0
- package/dist/themes/index.js.map +1 -0
- package/dist/themes/modern.d.ts +6 -0
- package/dist/themes/modern.d.ts.map +1 -0
- package/dist/themes/modern.js +164 -0
- package/dist/themes/modern.js.map +1 -0
- package/dist/themes/types.d.ts +234 -0
- package/dist/themes/types.d.ts.map +1 -0
- package/dist/themes/types.js +5 -0
- package/dist/themes/types.js.map +1 -0
- package/dist/themes/utils.d.ts +21 -0
- package/dist/themes/utils.d.ts.map +1 -0
- package/dist/themes/utils.js +124 -0
- package/dist/themes/utils.js.map +1 -0
- package/package.json +92 -0
- package/src/icons/build-icons.ts +189 -0
- package/src/icons/default/access-point.svg +3 -0
- package/src/icons/default/cloud.svg +3 -0
- package/src/icons/default/database.svg +3 -0
- package/src/icons/default/firewall.svg +4 -0
- package/src/icons/default/generic.svg +3 -0
- package/src/icons/default/internet.svg +3 -0
- package/src/icons/default/l2-switch.svg +3 -0
- package/src/icons/default/l3-switch.svg +3 -0
- package/src/icons/default/load-balancer.svg +3 -0
- package/src/icons/default/router.svg +3 -0
- package/src/icons/default/server.svg +3 -0
- package/src/icons/default/vpn.svg +3 -0
- package/src/icons/generated-icons.ts +111 -0
- package/src/icons/index.ts +1 -0
- package/src/index.ts +21 -0
- package/src/layout/hierarchical.ts +1543 -0
- package/src/layout/index.ts +6 -0
- package/src/models/index.ts +5 -0
- package/src/models/types.ts +528 -0
- package/src/renderer/index.ts +6 -0
- package/src/renderer/svg.ts +997 -0
- package/src/themes/dark.ts +110 -0
- package/src/themes/index.ts +24 -0
- package/src/themes/modern.ts +186 -0
- package/src/themes/types.ts +262 -0
- package/src/themes/utils.ts +143 -0
|
@@ -0,0 +1,1543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hierarchical Layout Engine
|
|
3
|
+
* Uses ELK.js for advanced graph layout with proper edge routing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import ELK, { type ElkNode, type ElkExtendedEdge, type LayoutOptions } from 'elkjs/lib/elk.bundled.js'
|
|
7
|
+
import {
|
|
8
|
+
type NetworkGraph,
|
|
9
|
+
type Node,
|
|
10
|
+
type Subgraph,
|
|
11
|
+
type LayoutResult,
|
|
12
|
+
type LayoutNode,
|
|
13
|
+
type LayoutLink,
|
|
14
|
+
type LayoutSubgraph,
|
|
15
|
+
type Position,
|
|
16
|
+
type Bounds,
|
|
17
|
+
type LayoutDirection,
|
|
18
|
+
type LinkEndpoint,
|
|
19
|
+
getNodeId,
|
|
20
|
+
} from '../models/index.js'
|
|
21
|
+
import { getVendorIconEntry } from '../icons/index.js'
|
|
22
|
+
|
|
23
|
+
// ============================================
|
|
24
|
+
// Helper Functions
|
|
25
|
+
// ============================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Convert endpoint to full LinkEndpoint object
|
|
29
|
+
*/
|
|
30
|
+
function toEndpoint(endpoint: string | LinkEndpoint): LinkEndpoint {
|
|
31
|
+
if (typeof endpoint === 'string') {
|
|
32
|
+
return { node: endpoint }
|
|
33
|
+
}
|
|
34
|
+
return endpoint
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
*/
|
|
41
|
+
function collectNodePorts(
|
|
42
|
+
graph: NetworkGraph,
|
|
43
|
+
haNodePairs?: Set<string>
|
|
44
|
+
): Map<string, Set<string>> {
|
|
45
|
+
const nodePorts = new Map<string, Set<string>>()
|
|
46
|
+
|
|
47
|
+
for (const link of graph.links) {
|
|
48
|
+
const fromEndpoint = toEndpoint(link.from)
|
|
49
|
+
const toEndpoint_ = toEndpoint(link.to)
|
|
50
|
+
|
|
51
|
+
// Skip ports for HA links - they will be handled separately
|
|
52
|
+
const pairKey = `${fromEndpoint.node}:${toEndpoint_.node}`
|
|
53
|
+
if (haNodePairs?.has(pairKey)) {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (fromEndpoint.port) {
|
|
58
|
+
if (!nodePorts.has(fromEndpoint.node)) {
|
|
59
|
+
nodePorts.set(fromEndpoint.node, new Set())
|
|
60
|
+
}
|
|
61
|
+
nodePorts.get(fromEndpoint.node)!.add(fromEndpoint.port)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (toEndpoint_.port) {
|
|
65
|
+
if (!nodePorts.has(toEndpoint_.node)) {
|
|
66
|
+
nodePorts.set(toEndpoint_.node, new Set())
|
|
67
|
+
}
|
|
68
|
+
nodePorts.get(toEndpoint_.node)!.add(toEndpoint_.port)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return nodePorts
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Port size constants */
|
|
76
|
+
const PORT_WIDTH = 8
|
|
77
|
+
const PORT_HEIGHT = 8
|
|
78
|
+
|
|
79
|
+
// ============================================
|
|
80
|
+
// Layout Options
|
|
81
|
+
// ============================================
|
|
82
|
+
|
|
83
|
+
export interface HierarchicalLayoutOptions {
|
|
84
|
+
direction?: LayoutDirection
|
|
85
|
+
nodeWidth?: number
|
|
86
|
+
nodeHeight?: number
|
|
87
|
+
nodeSpacing?: number
|
|
88
|
+
rankSpacing?: number
|
|
89
|
+
subgraphPadding?: number
|
|
90
|
+
subgraphLabelHeight?: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const DEFAULT_OPTIONS: Required<HierarchicalLayoutOptions> = {
|
|
94
|
+
direction: 'TB',
|
|
95
|
+
nodeWidth: 180,
|
|
96
|
+
nodeHeight: 60,
|
|
97
|
+
nodeSpacing: 120, // Horizontal spacing between nodes
|
|
98
|
+
rankSpacing: 180, // Vertical spacing between layers
|
|
99
|
+
subgraphPadding: 60, // Padding inside subgraphs
|
|
100
|
+
subgraphLabelHeight: 28,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================
|
|
104
|
+
// Layout Engine
|
|
105
|
+
// ============================================
|
|
106
|
+
|
|
107
|
+
export class HierarchicalLayout {
|
|
108
|
+
private options: Required<HierarchicalLayoutOptions>
|
|
109
|
+
private elk: InstanceType<typeof ELK>
|
|
110
|
+
|
|
111
|
+
constructor(options?: HierarchicalLayoutOptions) {
|
|
112
|
+
this.options = { ...DEFAULT_OPTIONS, ...options }
|
|
113
|
+
this.elk = new ELK()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get effective options by merging graph settings with defaults
|
|
118
|
+
*/
|
|
119
|
+
private getEffectiveOptions(graph: NetworkGraph): Required<HierarchicalLayoutOptions> {
|
|
120
|
+
const settings = graph.settings
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...this.options,
|
|
124
|
+
direction: settings?.direction || this.options.direction,
|
|
125
|
+
nodeSpacing: settings?.nodeSpacing || this.options.nodeSpacing,
|
|
126
|
+
rankSpacing: settings?.rankSpacing || this.options.rankSpacing,
|
|
127
|
+
subgraphPadding: settings?.subgraphPadding || this.options.subgraphPadding,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async layoutAsync(graph: NetworkGraph): Promise<LayoutResult> {
|
|
132
|
+
const startTime = performance.now()
|
|
133
|
+
|
|
134
|
+
// Merge graph settings with default options
|
|
135
|
+
const effectiveOptions = this.getEffectiveOptions(graph)
|
|
136
|
+
const direction = effectiveOptions.direction
|
|
137
|
+
|
|
138
|
+
// Detect HA pairs first (before building ELK graph)
|
|
139
|
+
const haPairs = this.detectHAPairs(graph)
|
|
140
|
+
|
|
141
|
+
// Build ELK graph with HA pairs merged into virtual nodes
|
|
142
|
+
const { elkGraph, haVirtualNodes } = this.buildElkGraphWithHAMerge(
|
|
143
|
+
graph, direction, effectiveOptions, haPairs
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
// Run ELK layout
|
|
147
|
+
const layoutedGraph = await this.elk.layout(elkGraph)
|
|
148
|
+
|
|
149
|
+
// Extract results and expand HA virtual nodes back to original pairs
|
|
150
|
+
const result = this.extractLayoutResultWithHAExpand(
|
|
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)
|
|
159
|
+
|
|
160
|
+
result.metadata = {
|
|
161
|
+
algorithm: 'elk-layered-ha-merge',
|
|
162
|
+
duration: performance.now() - startTime,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return result
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build ELK graph with HA pairs merged into single virtual nodes
|
|
170
|
+
*/
|
|
171
|
+
private buildElkGraphWithHAMerge(
|
|
172
|
+
graph: NetworkGraph,
|
|
173
|
+
direction: LayoutDirection,
|
|
174
|
+
options: Required<HierarchicalLayoutOptions>,
|
|
175
|
+
haPairs: { nodeA: string; nodeB: string; minLength?: number }[]
|
|
176
|
+
): { elkGraph: ElkNode; haVirtualNodes: Map<string, { virtualId: string; nodeA: Node; nodeB: Node; gap: number; widthA: number; widthB: number; height: number }> } {
|
|
177
|
+
const elkDirection = this.toElkDirection(direction)
|
|
178
|
+
const haVirtualNodes = new Map<string, { virtualId: string; nodeA: Node; nodeB: Node; gap: number; widthA: number; widthB: number; height: number }>()
|
|
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)
|
|
227
|
+
|
|
228
|
+
// Build subgraph map
|
|
229
|
+
const subgraphMap = new Map<string, Subgraph>()
|
|
230
|
+
if (graph.subgraphs) {
|
|
231
|
+
for (const sg of graph.subgraphs) {
|
|
232
|
+
subgraphMap.set(sg.id, sg)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Build node to parent map
|
|
237
|
+
const nodeParent = new Map<string, string>()
|
|
238
|
+
for (const node of graph.nodes) {
|
|
239
|
+
if (node.parent) {
|
|
240
|
+
nodeParent.set(node.id, node.parent)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Build subgraph children map
|
|
245
|
+
const subgraphChildren = new Map<string, string[]>()
|
|
246
|
+
for (const sg of subgraphMap.values()) {
|
|
247
|
+
subgraphChildren.set(sg.id, [])
|
|
248
|
+
}
|
|
249
|
+
for (const sg of subgraphMap.values()) {
|
|
250
|
+
if (sg.parent && subgraphMap.has(sg.parent)) {
|
|
251
|
+
subgraphChildren.get(sg.parent)!.push(sg.id)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Create ELK node for regular nodes
|
|
256
|
+
const createElkNode_ = (node: Node): ElkNode => {
|
|
257
|
+
const height = this.calculateNodeHeight(node)
|
|
258
|
+
const elkNode: ElkNode = {
|
|
259
|
+
id: node.id,
|
|
260
|
+
width: options.nodeWidth,
|
|
261
|
+
height,
|
|
262
|
+
labels: [{ text: Array.isArray(node.label) ? node.label.join('\n') : node.label }],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Add ports if this node has any
|
|
266
|
+
const ports = nodePorts.get(node.id)
|
|
267
|
+
if (ports && ports.size > 0) {
|
|
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
|
+
}
|
|
276
|
+
|
|
277
|
+
return elkNode
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Create ELK node for HA virtual node with all ports from both nodes
|
|
281
|
+
const createHAVirtualElkNode = (info: { virtualId: string; nodeA: Node; nodeB: Node; gap: number; widthA: number; widthB: number; height: number }): ElkNode => {
|
|
282
|
+
const totalWidth = info.widthA + info.gap + info.widthB
|
|
283
|
+
const ports: { id: string; width: number; height: number; labels?: { text: string }[] }[] = []
|
|
284
|
+
|
|
285
|
+
// Add all ports from nodeA (with prefix to identify them)
|
|
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}`,
|
|
291
|
+
width: PORT_WIDTH,
|
|
292
|
+
height: PORT_HEIGHT,
|
|
293
|
+
labels: [{ text: portName }],
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Add all ports from nodeB (with prefix to identify them)
|
|
299
|
+
const portsB = nodePorts.get(info.nodeB.id)
|
|
300
|
+
if (portsB) {
|
|
301
|
+
for (const portName of portsB) {
|
|
302
|
+
ports.push({
|
|
303
|
+
id: `${info.virtualId}:B:${portName}`,
|
|
304
|
+
width: PORT_WIDTH,
|
|
305
|
+
height: PORT_HEIGHT,
|
|
306
|
+
labels: [{ text: portName }],
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Add fallback ports if no specific ports
|
|
312
|
+
if (ports.length === 0) {
|
|
313
|
+
ports.push(
|
|
314
|
+
{ id: `${info.virtualId}:A`, width: PORT_WIDTH, height: PORT_HEIGHT },
|
|
315
|
+
{ id: `${info.virtualId}:B`, width: PORT_WIDTH, height: PORT_HEIGHT },
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const elkNode: ElkNode = {
|
|
320
|
+
id: info.virtualId,
|
|
321
|
+
width: totalWidth,
|
|
322
|
+
height: info.height,
|
|
323
|
+
labels: [{ text: `${info.nodeA.id} + ${info.nodeB.id}` }],
|
|
324
|
+
ports,
|
|
325
|
+
layoutOptions: { 'elk.portConstraints': 'FREE' },
|
|
326
|
+
}
|
|
327
|
+
return elkNode
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Determine which subgraph each HA virtual node belongs to
|
|
331
|
+
const virtualNodeParent = new Map<string, string | undefined>()
|
|
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
|
+
}
|
|
336
|
+
|
|
337
|
+
// Create ELK nodes recursively for subgraphs
|
|
338
|
+
const createSubgraphNode = (subgraph: Subgraph): ElkNode => {
|
|
339
|
+
const childNodes: ElkNode[] = []
|
|
340
|
+
|
|
341
|
+
// Add child subgraphs
|
|
342
|
+
const childSgIds = subgraphChildren.get(subgraph.id) || []
|
|
343
|
+
for (const childId of childSgIds) {
|
|
344
|
+
const childSg = subgraphMap.get(childId)
|
|
345
|
+
if (childSg) {
|
|
346
|
+
childNodes.push(createSubgraphNode(childSg))
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
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
|
+
for (const node of graph.nodes) {
|
|
359
|
+
if (node.parent === subgraph.id && !nodesInHAPairs.has(node.id)) {
|
|
360
|
+
childNodes.push(createElkNode_(node))
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const sgPadding = subgraph.style?.padding ?? options.subgraphPadding
|
|
365
|
+
const sgNodeSpacing = subgraph.style?.nodeSpacing ?? options.nodeSpacing
|
|
366
|
+
const sgRankSpacing = subgraph.style?.rankSpacing ?? options.rankSpacing
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
id: subgraph.id,
|
|
370
|
+
labels: [{ text: subgraph.label }],
|
|
371
|
+
children: childNodes,
|
|
372
|
+
layoutOptions: {
|
|
373
|
+
'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
|
+
},
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Build root children
|
|
381
|
+
const rootChildren: ElkNode[] = []
|
|
382
|
+
|
|
383
|
+
// Add root-level subgraphs
|
|
384
|
+
for (const sg of subgraphMap.values()) {
|
|
385
|
+
if (!sg.parent || !subgraphMap.has(sg.parent)) {
|
|
386
|
+
rootChildren.push(createSubgraphNode(sg))
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Add root-level HA virtual nodes
|
|
391
|
+
for (const [virtualId, info] of haVirtualNodes) {
|
|
392
|
+
if (!virtualNodeParent.get(virtualId) || !subgraphMap.has(virtualNodeParent.get(virtualId)!)) {
|
|
393
|
+
rootChildren.push(createHAVirtualElkNode(info))
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Add root-level nodes (skip nodes in HA pairs)
|
|
398
|
+
for (const node of graph.nodes) {
|
|
399
|
+
if (nodesInHAPairs.has(node.id)) continue
|
|
400
|
+
if (!node.parent || !subgraphMap.has(node.parent)) {
|
|
401
|
+
rootChildren.push(createElkNode_(node))
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Build edges with redirects for HA nodes
|
|
406
|
+
const edges: ElkExtendedEdge[] = graph.links
|
|
407
|
+
.filter(link => {
|
|
408
|
+
// Skip HA internal links (the link connecting the HA pair nodes themselves)
|
|
409
|
+
const fromId = getNodeId(link.from)
|
|
410
|
+
const toId = getNodeId(link.to)
|
|
411
|
+
for (const pair of haPairs) {
|
|
412
|
+
if ((pair.nodeA === fromId && pair.nodeB === toId) ||
|
|
413
|
+
(pair.nodeA === toId && pair.nodeB === fromId)) {
|
|
414
|
+
return false
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return true
|
|
418
|
+
})
|
|
419
|
+
.map((link, index) => {
|
|
420
|
+
const fromEndpoint = toEndpoint(link.from)
|
|
421
|
+
const toEndpoint_ = toEndpoint(link.to)
|
|
422
|
+
|
|
423
|
+
// Redirect endpoints if they're in HA pairs (preserve port names)
|
|
424
|
+
let sourceId: string
|
|
425
|
+
let targetId: string
|
|
426
|
+
|
|
427
|
+
const fromRedirect = edgeRedirect.get(fromEndpoint.node)
|
|
428
|
+
const toRedirect = edgeRedirect.get(toEndpoint_.node)
|
|
429
|
+
|
|
430
|
+
if (fromRedirect) {
|
|
431
|
+
// Redirect to virtual node, preserving port name if any
|
|
432
|
+
if (fromEndpoint.port) {
|
|
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
|
+
}
|
|
442
|
+
|
|
443
|
+
if (toRedirect) {
|
|
444
|
+
// Redirect to virtual node, preserving port name if any
|
|
445
|
+
if (toEndpoint_.port) {
|
|
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
|
+
}
|
|
455
|
+
|
|
456
|
+
const edge: ElkExtendedEdge = {
|
|
457
|
+
id: link.id || `edge-${index}`,
|
|
458
|
+
sources: [sourceId],
|
|
459
|
+
targets: [targetId],
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Build label
|
|
463
|
+
const labelParts: string[] = []
|
|
464
|
+
if (link.label) {
|
|
465
|
+
labelParts.push(Array.isArray(link.label) ? link.label.join(' / ') : link.label)
|
|
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
|
+
}
|
|
476
|
+
|
|
477
|
+
return edge
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// Root graph
|
|
481
|
+
const rootLayoutOptions: LayoutOptions = {
|
|
482
|
+
'elk.algorithm': 'layered',
|
|
483
|
+
'elk.direction': elkDirection,
|
|
484
|
+
'elk.spacing.nodeNode': String(options.nodeSpacing),
|
|
485
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(options.rankSpacing),
|
|
486
|
+
'elk.spacing.edgeNode': '50',
|
|
487
|
+
'elk.spacing.edgeEdge': '20',
|
|
488
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
489
|
+
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
490
|
+
'elk.edgeRouting': 'SPLINES',
|
|
491
|
+
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
elkGraph: {
|
|
496
|
+
id: 'root',
|
|
497
|
+
children: rootChildren,
|
|
498
|
+
edges,
|
|
499
|
+
layoutOptions: rootLayoutOptions,
|
|
500
|
+
},
|
|
501
|
+
haVirtualNodes,
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Extract layout result and expand HA virtual nodes back to original pairs
|
|
507
|
+
*/
|
|
508
|
+
private extractLayoutResultWithHAExpand(
|
|
509
|
+
graph: NetworkGraph,
|
|
510
|
+
elkGraph: ElkNode,
|
|
511
|
+
haVirtualNodes: Map<string, { virtualId: string; nodeA: Node; nodeB: Node; gap: number; widthA: number; widthB: number; height: number }>,
|
|
512
|
+
_options: Required<HierarchicalLayoutOptions>
|
|
513
|
+
): LayoutResult {
|
|
514
|
+
const layoutNodes = new Map<string, LayoutNode>()
|
|
515
|
+
const layoutSubgraphs = new Map<string, LayoutSubgraph>()
|
|
516
|
+
const layoutLinks = new Map<string, LayoutLink>()
|
|
517
|
+
|
|
518
|
+
// Build subgraph map for reference
|
|
519
|
+
const subgraphMap = new Map<string, Subgraph>()
|
|
520
|
+
if (graph.subgraphs) {
|
|
521
|
+
for (const sg of graph.subgraphs) {
|
|
522
|
+
subgraphMap.set(sg.id, sg)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Extract node and subgraph positions recursively
|
|
527
|
+
const processElkNode = (elkNode: ElkNode, offsetX: number, offsetY: number) => {
|
|
528
|
+
const x = (elkNode.x || 0) + offsetX
|
|
529
|
+
const y = (elkNode.y || 0) + offsetY
|
|
530
|
+
const width = elkNode.width || 0
|
|
531
|
+
const height = elkNode.height || 0
|
|
532
|
+
|
|
533
|
+
// Check if this is an HA virtual node
|
|
534
|
+
const haInfo = haVirtualNodes.get(elkNode.id)
|
|
535
|
+
if (haInfo) {
|
|
536
|
+
// Expand virtual node back to original pair
|
|
537
|
+
// Position nodeA on the left, nodeB on the right
|
|
538
|
+
const heightA = this.calculateNodeHeight(haInfo.nodeA)
|
|
539
|
+
const heightB = this.calculateNodeHeight(haInfo.nodeB)
|
|
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
|
+
}
|
|
591
|
+
|
|
592
|
+
// Check if this link involves nodeA
|
|
593
|
+
if (fromEndpoint.node === haInfo.nodeA.id && fromEndpoint.port) {
|
|
594
|
+
// Outgoing from A → bottom
|
|
595
|
+
portsForABottom.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
596
|
+
}
|
|
597
|
+
if (toEndpoint_.node === haInfo.nodeA.id && toEndpoint_.port) {
|
|
598
|
+
// Incoming to A → top
|
|
599
|
+
portsForATop.push({ name: toEndpoint_.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Check if this link involves nodeB
|
|
603
|
+
if (fromEndpoint.node === haInfo.nodeB.id && fromEndpoint.port) {
|
|
604
|
+
// Outgoing from B → bottom
|
|
605
|
+
portsForBBottom.push({ name: fromEndpoint.port, portW: PORT_WIDTH, portH: PORT_HEIGHT })
|
|
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
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Distribute ports on an edge (top/bottom/left/right) - ports placed OUTSIDE the node
|
|
614
|
+
const distributePortsOnEdge = (
|
|
615
|
+
portsList: { name: string; portW: number; portH: number }[],
|
|
616
|
+
nodeWidth: number,
|
|
617
|
+
nodeHeight: number,
|
|
618
|
+
nodeId: string,
|
|
619
|
+
side: 'top' | 'bottom' | 'left' | 'right'
|
|
620
|
+
): Map<string, { id: string; label: string; position: Position; size: { width: number; height: number }; side: 'top' | 'bottom' | 'left' | 'right' }> => {
|
|
621
|
+
const result = new Map<string, { id: string; label: string; position: Position; size: { width: number; height: number }; side: 'top' | 'bottom' | 'left' | 'right' }>()
|
|
622
|
+
if (portsList.length === 0) return result
|
|
623
|
+
|
|
624
|
+
portsList.forEach((p, i) => {
|
|
625
|
+
let relX: number
|
|
626
|
+
let relY: number
|
|
627
|
+
|
|
628
|
+
if (side === 'top' || side === 'bottom') {
|
|
629
|
+
// Horizontal distribution along top/bottom edge
|
|
630
|
+
const portSpacing = nodeWidth / (portsList.length + 1)
|
|
631
|
+
relX = portSpacing * (i + 1) - nodeWidth / 2
|
|
632
|
+
// Place port completely outside the node
|
|
633
|
+
relY = side === 'top'
|
|
634
|
+
? -nodeHeight / 2 - p.portH / 2 // Above the node
|
|
635
|
+
: nodeHeight / 2 + p.portH / 2 // Below the node
|
|
636
|
+
} else {
|
|
637
|
+
// Vertical distribution along left/right edge
|
|
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
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const originalPortId = `${nodeId}:${p.name}`
|
|
647
|
+
result.set(originalPortId, {
|
|
648
|
+
id: originalPortId,
|
|
649
|
+
label: p.name,
|
|
650
|
+
position: { x: relX, y: relY },
|
|
651
|
+
size: { width: p.portW, height: p.portH },
|
|
652
|
+
side,
|
|
653
|
+
})
|
|
654
|
+
})
|
|
655
|
+
return result
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Create port maps for A and B
|
|
659
|
+
// For nodeA (left side of HA pair): ports should be positioned toward the inner side (right)
|
|
660
|
+
// For nodeB (right side of HA pair): ports should be positioned toward the inner side (left)
|
|
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
|
+
})
|
|
693
|
+
|
|
694
|
+
// Process children
|
|
695
|
+
if (elkNode.children) {
|
|
696
|
+
for (const child of elkNode.children) {
|
|
697
|
+
processElkNode(child, x, y)
|
|
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
|
+
}
|
|
710
|
+
|
|
711
|
+
// Extract port positions if any
|
|
712
|
+
// Note: Ports will be repositioned by reorderPortsByConnectedNodePositions later
|
|
713
|
+
if (elkNode.ports && elkNode.ports.length > 0) {
|
|
714
|
+
layoutNode.ports = new Map()
|
|
715
|
+
for (const elkPort of elkNode.ports) {
|
|
716
|
+
const portX = (elkPort.x ?? 0)
|
|
717
|
+
const portY = (elkPort.y ?? 0)
|
|
718
|
+
const portW = elkPort.width ?? PORT_WIDTH
|
|
719
|
+
const portH = elkPort.height ?? PORT_HEIGHT
|
|
720
|
+
|
|
721
|
+
// Determine which side based on ELK position
|
|
722
|
+
let side: 'top' | 'bottom' | 'left' | 'right' = 'bottom'
|
|
723
|
+
if (portY <= portH) side = 'top'
|
|
724
|
+
else if (portY >= height - portH * 2) side = 'bottom'
|
|
725
|
+
else if (portX <= portW) side = 'left'
|
|
726
|
+
else if (portX >= width - portW * 2) side = 'right'
|
|
727
|
+
|
|
728
|
+
const portName = elkPort.id.includes(':')
|
|
729
|
+
? elkPort.id.split(':').slice(1).join(':')
|
|
730
|
+
: elkPort.id
|
|
731
|
+
|
|
732
|
+
// Calculate position outside the node edge
|
|
733
|
+
let relX: number
|
|
734
|
+
let relY: number
|
|
735
|
+
if (side === 'top') {
|
|
736
|
+
relX = portX - width / 2 + portW / 2
|
|
737
|
+
relY = -height / 2 - portH / 2
|
|
738
|
+
} else if (side === 'bottom') {
|
|
739
|
+
relX = portX - width / 2 + portW / 2
|
|
740
|
+
relY = height / 2 + portH / 2
|
|
741
|
+
} else if (side === 'left') {
|
|
742
|
+
relX = -width / 2 - portW / 2
|
|
743
|
+
relY = portY - height / 2 + portH / 2
|
|
744
|
+
} else {
|
|
745
|
+
relX = width / 2 + portW / 2
|
|
746
|
+
relY = portY - height / 2 + portH / 2
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
layoutNode.ports.set(elkPort.id, {
|
|
750
|
+
id: elkPort.id,
|
|
751
|
+
label: portName,
|
|
752
|
+
position: { x: relX, y: relY },
|
|
753
|
+
size: { width: portW, height: portH },
|
|
754
|
+
side,
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
layoutNodes.set(elkNode.id, layoutNode)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Process root children
|
|
765
|
+
if (elkGraph.children) {
|
|
766
|
+
for (const child of elkGraph.children) {
|
|
767
|
+
processElkNode(child, 0, 0)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Post-process: reorder ports based on connected node positions to minimize crossings
|
|
772
|
+
this.reorderPortsByConnectedNodePositions(graph, layoutNodes)
|
|
773
|
+
|
|
774
|
+
// Calculate edges based on extracted node positions
|
|
775
|
+
graph.links.forEach((link, index) => {
|
|
776
|
+
const id = link.id || `link-${index}`
|
|
777
|
+
const fromEndpoint_ = toEndpoint(link.from)
|
|
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)
|
|
783
|
+
|
|
784
|
+
if (!fromNode || !toNode) return
|
|
785
|
+
|
|
786
|
+
// Get port positions if specified
|
|
787
|
+
const fromPortId = fromEndpoint_.port ? `${fromId}:${fromEndpoint_.port}` : undefined
|
|
788
|
+
const toPortId = toEndpoint_.port ? `${toId}:${toEndpoint_.port}` : undefined
|
|
789
|
+
const fromPort = fromPortId ? fromNode.ports?.get(fromPortId) : undefined
|
|
790
|
+
const toPort = toPortId ? toNode.ports?.get(toPortId) : undefined
|
|
791
|
+
|
|
792
|
+
const points = this.calculateEdgePathWithPorts(fromNode, toNode, fromPort, toPort)
|
|
793
|
+
|
|
794
|
+
layoutLinks.set(id, {
|
|
795
|
+
id,
|
|
796
|
+
from: fromId,
|
|
797
|
+
to: toId,
|
|
798
|
+
fromEndpoint: fromEndpoint_,
|
|
799
|
+
toEndpoint: toEndpoint_,
|
|
800
|
+
points,
|
|
801
|
+
link,
|
|
802
|
+
})
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
// Calculate total bounds
|
|
806
|
+
const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs)
|
|
807
|
+
|
|
808
|
+
return {
|
|
809
|
+
nodes: layoutNodes,
|
|
810
|
+
links: layoutLinks,
|
|
811
|
+
subgraphs: layoutSubgraphs,
|
|
812
|
+
bounds,
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
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
|
|
911
|
+
layout(graph: NetworkGraph): LayoutResult {
|
|
912
|
+
// For sync compatibility, we need to run layout synchronously
|
|
913
|
+
// This is a simplified version - ideally use layoutAsync for full HA support
|
|
914
|
+
const effectiveOptions = this.getEffectiveOptions(graph)
|
|
915
|
+
const direction = effectiveOptions.direction
|
|
916
|
+
const haPairs = this.detectHAPairs(graph)
|
|
917
|
+
|
|
918
|
+
// Build ELK graph with HA merge (same as async)
|
|
919
|
+
const { elkGraph, haVirtualNodes } = this.buildElkGraphWithHAMerge(
|
|
920
|
+
graph, direction, effectiveOptions, haPairs
|
|
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
|
+
})
|
|
938
|
+
|
|
939
|
+
return result
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private toElkDirection(direction: LayoutDirection): string {
|
|
943
|
+
switch (direction) {
|
|
944
|
+
case 'TB': return 'DOWN'
|
|
945
|
+
case 'BT': return 'UP'
|
|
946
|
+
case 'LR': return 'RIGHT'
|
|
947
|
+
case 'RL': return 'LEFT'
|
|
948
|
+
default: return 'DOWN'
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private calculateNodeHeight(node: Node): number {
|
|
953
|
+
const lines = Array.isArray(node.label) ? node.label.length : 1
|
|
954
|
+
const baseHeight = 40
|
|
955
|
+
const lineHeight = 16
|
|
956
|
+
|
|
957
|
+
// Calculate actual icon height based on vendor icon or default
|
|
958
|
+
let iconHeight = 0
|
|
959
|
+
const iconKey = node.service || node.model
|
|
960
|
+
if (node.vendor && iconKey) {
|
|
961
|
+
const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
|
|
962
|
+
if (iconEntry) {
|
|
963
|
+
const defaultIconSize = 40
|
|
964
|
+
const iconPadding = 16
|
|
965
|
+
const maxIconWidth = this.options.nodeWidth - iconPadding
|
|
966
|
+
|
|
967
|
+
const vendorIcon = iconEntry.default
|
|
968
|
+
const viewBox = iconEntry.viewBox || '0 0 48 48'
|
|
969
|
+
|
|
970
|
+
// Check for PNG-based icons with embedded viewBox
|
|
971
|
+
if (vendorIcon.startsWith('<svg')) {
|
|
972
|
+
const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
|
|
973
|
+
if (viewBoxMatch) {
|
|
974
|
+
const vbWidth = parseInt(viewBoxMatch[1])
|
|
975
|
+
const vbHeight = parseInt(viewBoxMatch[2])
|
|
976
|
+
const aspectRatio = vbWidth / vbHeight
|
|
977
|
+
let calcHeight = defaultIconSize
|
|
978
|
+
if (Math.round(defaultIconSize * aspectRatio) > maxIconWidth) {
|
|
979
|
+
calcHeight = Math.round(maxIconWidth / aspectRatio)
|
|
980
|
+
}
|
|
981
|
+
iconHeight = calcHeight
|
|
982
|
+
} else {
|
|
983
|
+
iconHeight = defaultIconSize
|
|
984
|
+
}
|
|
985
|
+
} else {
|
|
986
|
+
// Parse viewBox for aspect ratio
|
|
987
|
+
const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
|
|
988
|
+
if (vbMatch) {
|
|
989
|
+
const vbWidth = parseInt(vbMatch[3])
|
|
990
|
+
const vbHeight = parseInt(vbMatch[4])
|
|
991
|
+
const aspectRatio = vbWidth / vbHeight
|
|
992
|
+
let calcHeight = defaultIconSize
|
|
993
|
+
if (Math.round(defaultIconSize * aspectRatio) > maxIconWidth) {
|
|
994
|
+
calcHeight = Math.round(maxIconWidth / aspectRatio)
|
|
995
|
+
}
|
|
996
|
+
iconHeight = calcHeight
|
|
997
|
+
} else {
|
|
998
|
+
iconHeight = defaultIconSize
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
} else if (node.type) {
|
|
1003
|
+
// Default device type icon (fixed size)
|
|
1004
|
+
iconHeight = 36
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return Math.max(this.options.nodeHeight, baseHeight + (lines - 1) * lineHeight + iconHeight)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Calculate edge path using port positions when available
|
|
1012
|
+
* Port positions are from ELK - lines connect FROM the ports
|
|
1013
|
+
*/
|
|
1014
|
+
private calculateEdgePathWithPorts(
|
|
1015
|
+
fromNode: LayoutNode,
|
|
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
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
let toPoint: Position
|
|
1054
|
+
let toSide: string
|
|
1055
|
+
|
|
1056
|
+
if (toPort) {
|
|
1057
|
+
// Line ends at port position
|
|
1058
|
+
toPoint = {
|
|
1059
|
+
x: toNode.position.x + toPort.position.x,
|
|
1060
|
+
y: toNode.position.y + toPort.position.y,
|
|
1061
|
+
}
|
|
1062
|
+
toSide = toPort.side
|
|
1063
|
+
} else {
|
|
1064
|
+
// No port - calculate edge position based on source node direction
|
|
1065
|
+
const dx = fromNode.position.x - toNode.position.x
|
|
1066
|
+
const dy = fromNode.position.y - toNode.position.y
|
|
1067
|
+
const halfW = toNode.size.width / 2
|
|
1068
|
+
const halfH = toNode.size.height / 2
|
|
1069
|
+
|
|
1070
|
+
if (Math.abs(dx) > Math.abs(dy) * 0.5) {
|
|
1071
|
+
toSide = dx > 0 ? 'right' : 'left'
|
|
1072
|
+
toPoint = {
|
|
1073
|
+
x: toNode.position.x + (dx > 0 ? halfW : -halfW),
|
|
1074
|
+
y: toNode.position.y,
|
|
1075
|
+
}
|
|
1076
|
+
} else {
|
|
1077
|
+
toSide = dy > 0 ? 'bottom' : 'top'
|
|
1078
|
+
toPoint = {
|
|
1079
|
+
x: toNode.position.x,
|
|
1080
|
+
y: toNode.position.y + (dy > 0 ? halfH : -halfH),
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Calculate bezier control points based on port sides
|
|
1086
|
+
const dx = toPoint.x - fromPoint.x
|
|
1087
|
+
const dy = toPoint.y - fromPoint.y
|
|
1088
|
+
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
1089
|
+
const controlDist = Math.min(dist * 0.4, 80)
|
|
1090
|
+
|
|
1091
|
+
const fromControl = this.getControlPointForSide(fromPoint, fromSide, controlDist)
|
|
1092
|
+
const toControl = this.getControlPointForSide(toPoint, toSide, controlDist)
|
|
1093
|
+
|
|
1094
|
+
return [fromPoint, fromControl, toControl, toPoint]
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Get control point position based on port side
|
|
1099
|
+
*/
|
|
1100
|
+
private getControlPointForSide(point: Position, side: string, dist: number): Position {
|
|
1101
|
+
switch (side) {
|
|
1102
|
+
case 'top':
|
|
1103
|
+
return { x: point.x, y: point.y - Math.abs(dist) }
|
|
1104
|
+
case 'bottom':
|
|
1105
|
+
return { x: point.x, y: point.y + Math.abs(dist) }
|
|
1106
|
+
case 'left':
|
|
1107
|
+
return { x: point.x - Math.abs(dist), y: point.y }
|
|
1108
|
+
case 'right':
|
|
1109
|
+
return { x: point.x + Math.abs(dist), y: point.y }
|
|
1110
|
+
default:
|
|
1111
|
+
return { x: point.x, y: point.y + dist }
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private calculateFallbackLayout(graph: NetworkGraph, _direction: LayoutDirection): LayoutResult {
|
|
1116
|
+
// Simple fallback layout when async isn't available
|
|
1117
|
+
const layoutNodes = new Map<string, LayoutNode>()
|
|
1118
|
+
const layoutSubgraphs = new Map<string, LayoutSubgraph>()
|
|
1119
|
+
const layoutLinks = new Map<string, LayoutLink>()
|
|
1120
|
+
|
|
1121
|
+
// Simple grid layout for nodes
|
|
1122
|
+
let x = 100
|
|
1123
|
+
let y = 100
|
|
1124
|
+
const rowHeight = this.options.nodeHeight + this.options.rankSpacing
|
|
1125
|
+
const colWidth = this.options.nodeWidth + this.options.nodeSpacing
|
|
1126
|
+
let col = 0
|
|
1127
|
+
const maxCols = 4
|
|
1128
|
+
|
|
1129
|
+
for (const node of graph.nodes) {
|
|
1130
|
+
const height = this.calculateNodeHeight(node)
|
|
1131
|
+
layoutNodes.set(node.id, {
|
|
1132
|
+
id: node.id,
|
|
1133
|
+
position: { x: x + this.options.nodeWidth / 2, y: y + height / 2 },
|
|
1134
|
+
size: { width: this.options.nodeWidth, height },
|
|
1135
|
+
node,
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
col++
|
|
1139
|
+
if (col >= maxCols) {
|
|
1140
|
+
col = 0
|
|
1141
|
+
x = 100
|
|
1142
|
+
y += rowHeight
|
|
1143
|
+
} else {
|
|
1144
|
+
x += colWidth
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Simple links
|
|
1149
|
+
graph.links.forEach((link, index) => {
|
|
1150
|
+
const fromId = getNodeId(link.from)
|
|
1151
|
+
const toId = getNodeId(link.to)
|
|
1152
|
+
const from = layoutNodes.get(fromId)
|
|
1153
|
+
const to = layoutNodes.get(toId)
|
|
1154
|
+
if (from && to) {
|
|
1155
|
+
layoutLinks.set(link.id || `link-${index}`, {
|
|
1156
|
+
id: link.id || `link-${index}`,
|
|
1157
|
+
from: fromId,
|
|
1158
|
+
to: toId,
|
|
1159
|
+
fromEndpoint: toEndpoint(link.from),
|
|
1160
|
+
toEndpoint: toEndpoint(link.to),
|
|
1161
|
+
points: [from.position, to.position],
|
|
1162
|
+
link,
|
|
1163
|
+
})
|
|
1164
|
+
}
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
const bounds = this.calculateTotalBounds(layoutNodes, layoutSubgraphs)
|
|
1168
|
+
|
|
1169
|
+
return {
|
|
1170
|
+
nodes: layoutNodes,
|
|
1171
|
+
links: layoutLinks,
|
|
1172
|
+
subgraphs: layoutSubgraphs,
|
|
1173
|
+
bounds,
|
|
1174
|
+
metadata: { algorithm: 'fallback-grid', duration: 0 },
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
private calculateTotalBounds(
|
|
1179
|
+
nodes: Map<string, LayoutNode>,
|
|
1180
|
+
subgraphs: Map<string, LayoutSubgraph>
|
|
1181
|
+
): Bounds {
|
|
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>()
|
|
1220
|
+
|
|
1221
|
+
for (const link of graph.links) {
|
|
1222
|
+
// Only process links with redundancy property set
|
|
1223
|
+
if (!link.redundancy) continue
|
|
1224
|
+
|
|
1225
|
+
const fromId = getNodeId(link.from)
|
|
1226
|
+
const toId = getNodeId(link.to)
|
|
1227
|
+
const pairKey = [fromId, toId].sort().join(':')
|
|
1228
|
+
if (processedPairs.has(pairKey)) continue
|
|
1229
|
+
|
|
1230
|
+
pairs.push({
|
|
1231
|
+
nodeA: fromId,
|
|
1232
|
+
nodeB: toId,
|
|
1233
|
+
minLength: link.style?.minLength,
|
|
1234
|
+
})
|
|
1235
|
+
processedPairs.add(pairKey)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return pairs
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Adjust node distances based on link minLength property
|
|
1243
|
+
*/
|
|
1244
|
+
private adjustLinkDistances(
|
|
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)
|
|
1270
|
+
|
|
1271
|
+
if (currentDist >= minLength) continue
|
|
1272
|
+
|
|
1273
|
+
// Need to increase distance
|
|
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
|
+
]
|
|
1366
|
+
}
|
|
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
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Recalculate bounds for each subgraph
|
|
1471
|
+
result.subgraphs.forEach((layoutSubgraph, subgraphId) => {
|
|
1472
|
+
const childNodes: LayoutNode[] = []
|
|
1473
|
+
|
|
1474
|
+
// Find all nodes that belong to this subgraph
|
|
1475
|
+
result.nodes.forEach((layoutNode, nodeId) => {
|
|
1476
|
+
if (nodeToSubgraph.get(nodeId) === subgraphId) {
|
|
1477
|
+
childNodes.push(layoutNode)
|
|
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
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Default instance
|
|
1543
|
+
export const hierarchicalLayout = new HierarchicalLayout()
|