@shumoku/core 0.2.0 → 0.2.3

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