@shumoku/renderer 0.2.3 → 0.2.5

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/src/svg.ts CHANGED
@@ -1,1502 +1,1640 @@
1
- /**
2
- * SVG Renderer
3
- * Renders NetworkGraph to SVG
4
- */
5
-
6
- import type {
7
- IconThemeVariant,
8
- LayoutLink,
9
- LayoutNode,
10
- LayoutPort,
11
- LayoutResult,
12
- LayoutSubgraph,
13
- LegendSettings,
14
- LinkBandwidth,
15
- LinkType,
16
- NetworkGraph,
17
- Node,
18
- NodeShape,
19
- ThemeType,
20
- } from '@shumoku/core'
21
- import {
22
- DEFAULT_ICON_SIZE,
23
- getDeviceIcon,
24
- getVendorIconEntry,
25
- ICON_LABEL_GAP,
26
- LABEL_LINE_HEIGHT,
27
- MAX_ICON_WIDTH_RATIO,
28
- } from '@shumoku/core'
29
-
30
- import type { DataAttributeOptions, RenderMode } from './types.js'
31
-
32
- // ============================================
33
- // Theme Colors
34
- // ============================================
35
-
36
- interface ThemeColors {
37
- backgroundColor: string
38
- defaultNodeFill: string
39
- defaultNodeStroke: string
40
- defaultLinkStroke: string
41
- labelColor: string
42
- labelSecondaryColor: string
43
- subgraphFill: string
44
- subgraphStroke: string
45
- subgraphLabelColor: string
46
- portFill: string
47
- portStroke: string
48
- portLabelBg: string
49
- portLabelColor: string
50
- endpointLabelBg: string
51
- endpointLabelStroke: string
52
- }
53
-
54
- const LIGHT_THEME: ThemeColors = {
55
- backgroundColor: '#ffffff',
56
- defaultNodeFill: '#e2e8f0',
57
- defaultNodeStroke: '#64748b',
58
- defaultLinkStroke: '#94a3b8',
59
- labelColor: '#1e293b',
60
- labelSecondaryColor: '#64748b',
61
- subgraphFill: '#f8fafc',
62
- subgraphStroke: '#cbd5e1',
63
- subgraphLabelColor: '#374151',
64
- portFill: '#475569',
65
- portStroke: '#1e293b',
66
- portLabelBg: '#1e293b',
67
- portLabelColor: '#ffffff',
68
- endpointLabelBg: '#ffffff',
69
- endpointLabelStroke: '#cbd5e1',
70
- }
71
-
72
- const DARK_THEME: ThemeColors = {
73
- backgroundColor: '#1e293b',
74
- defaultNodeFill: '#334155',
75
- defaultNodeStroke: '#64748b',
76
- defaultLinkStroke: '#64748b',
77
- labelColor: '#f1f5f9',
78
- labelSecondaryColor: '#94a3b8',
79
- subgraphFill: '#0f172a',
80
- subgraphStroke: '#475569',
81
- subgraphLabelColor: '#e2e8f0',
82
- portFill: '#64748b',
83
- portStroke: '#94a3b8',
84
- portLabelBg: '#0f172a',
85
- portLabelColor: '#f1f5f9',
86
- endpointLabelBg: '#1e293b',
87
- endpointLabelStroke: '#475569',
88
- }
89
-
90
- // ============================================
91
- // Renderer Options
92
- // ============================================
93
-
94
- export interface SVGRendererOptions {
95
- /** Font family */
96
- fontFamily?: string
97
- /** Include interactive elements (deprecated, use renderMode) */
98
- interactive?: boolean
99
- /**
100
- * Render mode
101
- * - 'static': Pure SVG without interactive data attributes (default)
102
- * - 'interactive': SVG with data attributes for runtime interactivity
103
- */
104
- renderMode?: RenderMode
105
- /**
106
- * Data attributes to include in interactive mode
107
- * Only used when renderMode is 'interactive'
108
- */
109
- dataAttributes?: DataAttributeOptions
110
- }
111
-
112
- const DEFAULT_OPTIONS: Required<SVGRendererOptions> = {
113
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
114
- interactive: true,
115
- renderMode: 'static',
116
- dataAttributes: { device: true, link: true, metadata: true },
117
- }
118
-
119
- // ============================================
120
- // SVG Renderer
121
- // ============================================
122
-
123
- export class SVGRenderer {
124
- private options: Required<SVGRendererOptions>
125
- private themeColors: ThemeColors = LIGHT_THEME
126
- private iconTheme: IconThemeVariant = 'default'
127
-
128
- constructor(options?: SVGRendererOptions) {
129
- this.options = { ...DEFAULT_OPTIONS, ...options }
130
- }
131
-
132
- /** Check if interactive mode is enabled */
133
- private get isInteractive(): boolean {
134
- return this.options.renderMode === 'interactive'
135
- }
136
-
137
- /** Get data attribute options with defaults */
138
- private get dataAttrs(): Required<DataAttributeOptions> {
139
- return {
140
- device: this.options.dataAttributes?.device ?? true,
141
- link: this.options.dataAttributes?.link ?? true,
142
- metadata: this.options.dataAttributes?.metadata ?? true,
143
- }
144
- }
145
-
146
- /**
147
- * Get theme colors based on theme type
148
- */
149
- private getThemeColors(theme?: ThemeType): ThemeColors {
150
- return theme === 'dark' ? DARK_THEME : LIGHT_THEME
151
- }
152
-
153
- /**
154
- * Get icon theme variant based on theme type
155
- */
156
- private getIconTheme(theme?: ThemeType): IconThemeVariant {
157
- return theme === 'dark' ? 'dark' : 'light'
158
- }
159
-
160
- render(graph: NetworkGraph, layout: LayoutResult): string {
161
- const { bounds } = layout
162
-
163
- // Set theme colors based on graph settings
164
- const theme = graph.settings?.theme
165
- this.themeColors = this.getThemeColors(theme)
166
- this.iconTheme = this.getIconTheme(theme)
167
-
168
- // Calculate legend dimensions if enabled
169
- const legendSettings = this.getLegendSettings(graph.settings?.legend)
170
- let legendWidth = 0
171
- let legendHeight = 0
172
- if (legendSettings.enabled) {
173
- const legendDims = this.calculateLegendDimensions(graph, legendSettings)
174
- legendWidth = legendDims.width
175
- legendHeight = legendDims.height
176
- }
177
-
178
- // Expand bounds to include legend with padding
179
- const legendPadding = 20
180
- const expandedBounds = {
181
- x: bounds.x,
182
- y: bounds.y,
183
- width: bounds.width + (legendSettings.enabled && legendWidth > 0 ? legendPadding : 0),
184
- height: bounds.height + (legendSettings.enabled && legendHeight > 0 ? legendPadding : 0),
185
- }
186
-
187
- const parts: string[] = []
188
-
189
- // SVG header using expanded bounds
190
- const viewBox = `${expandedBounds.x} ${expandedBounds.y} ${expandedBounds.width} ${expandedBounds.height}`
191
- parts.push(this.renderHeader(expandedBounds.width, expandedBounds.height, viewBox))
192
-
193
- // Defs (markers, gradients)
194
- parts.push(this.renderDefs())
195
-
196
- // Styles
197
- parts.push(this.renderStyles())
198
-
199
- // Layer 1: Subgraphs (background)
200
- for (const sg of layout.subgraphs.values()) {
201
- parts.push(this.renderSubgraph(sg))
202
- }
203
-
204
- // Layer 2: Links (below nodes)
205
- for (const link of layout.links.values()) {
206
- parts.push(this.renderLink(link, layout.nodes))
207
- }
208
-
209
- // Layer 3: Nodes (bg + fg as one unit, without ports)
210
- for (const node of layout.nodes.values()) {
211
- parts.push(this.renderNode(node))
212
- }
213
-
214
- // Layer 4: Ports (separate layer on top of nodes)
215
- for (const node of layout.nodes.values()) {
216
- const portsRendered = this.renderPorts(node.id, node.position.x, node.position.y, node.ports)
217
- if (portsRendered) {
218
- parts.push(portsRendered)
219
- }
220
- }
221
-
222
- // Legend (if enabled) - use already calculated legendSettings
223
- if (legendSettings.enabled && legendWidth > 0) {
224
- parts.push(this.renderLegend(graph, layout, legendSettings))
225
- }
226
-
227
- // Close SVG
228
- parts.push('</svg>')
229
-
230
- return parts.join('\n')
231
- }
232
-
233
- /**
234
- * Calculate legend dimensions without rendering
235
- */
236
- private calculateLegendDimensions(
237
- graph: NetworkGraph,
238
- settings: LegendSettings,
239
- ): { width: number; height: number } {
240
- const lineHeight = 20
241
- const padding = 12
242
- const iconWidth = 30
243
- const maxLabelWidth = 100
244
-
245
- // Count items
246
- let itemCount = 0
247
-
248
- if (settings.showBandwidth) {
249
- const usedBandwidths = new Set<LinkBandwidth>()
250
- for (const link of graph.links) {
251
- if (link.bandwidth) usedBandwidths.add(link.bandwidth)
252
- }
253
- itemCount += usedBandwidths.size
254
- }
255
-
256
- if (itemCount === 0) {
257
- return { width: 0, height: 0 }
258
- }
259
-
260
- const width = iconWidth + maxLabelWidth + padding * 2
261
- const height = itemCount * lineHeight + padding * 2 + 20 // +20 for title
262
-
263
- return { width, height }
264
- }
265
-
266
- /**
267
- * Parse legend settings from various input formats
268
- */
269
- private getLegendSettings(
270
- legend?: boolean | LegendSettings,
271
- ): LegendSettings & { enabled: boolean } {
272
- if (legend === true) {
273
- return {
274
- enabled: true,
275
- position: 'top-right',
276
- showDeviceTypes: true,
277
- showBandwidth: true,
278
- showCableTypes: true,
279
- showVlans: false,
280
- }
281
- }
282
-
283
- if (legend && typeof legend === 'object') {
284
- return {
285
- enabled: legend.enabled !== false,
286
- position: legend.position ?? 'top-right',
287
- showDeviceTypes: legend.showDeviceTypes ?? true,
288
- showBandwidth: legend.showBandwidth ?? true,
289
- showCableTypes: legend.showCableTypes ?? true,
290
- showVlans: legend.showVlans ?? false,
291
- }
292
- }
293
-
294
- return { enabled: false, position: 'top-right' }
295
- }
296
-
297
- /**
298
- * Render legend showing visual elements used in the diagram
299
- */
300
- private renderLegend(
301
- graph: NetworkGraph,
302
- layout: LayoutResult,
303
- settings: LegendSettings,
304
- ): string {
305
- const items: { icon: string; label: string }[] = []
306
- const lineHeight = 20
307
- const padding = 12
308
- const iconWidth = 30
309
- const maxLabelWidth = 100
310
-
311
- // Collect used bandwidths
312
- const usedBandwidths = new Set<LinkBandwidth>()
313
- for (const link of graph.links) {
314
- if (link.bandwidth) usedBandwidths.add(link.bandwidth)
315
- }
316
-
317
- // Collect used device types
318
- const usedDeviceTypes = new Set<string>()
319
- for (const node of graph.nodes) {
320
- if (node.type) usedDeviceTypes.add(node.type)
321
- }
322
-
323
- // Build legend items
324
- if (settings.showBandwidth && usedBandwidths.size > 0) {
325
- const sortedBandwidths: LinkBandwidth[] = ['1G', '10G', '25G', '40G', '100G'].filter((b) =>
326
- usedBandwidths.has(b as LinkBandwidth),
327
- ) as LinkBandwidth[]
328
-
329
- for (const bw of sortedBandwidths) {
330
- const config = this.getBandwidthConfig(bw)
331
- items.push({
332
- icon: this.renderBandwidthLegendIcon(config.lineCount),
333
- label: bw,
334
- })
335
- }
336
- }
337
-
338
- if (items.length === 0) return ''
339
-
340
- // Calculate legend dimensions
341
- const legendWidth = iconWidth + maxLabelWidth + padding * 2
342
- const legendHeight = items.length * lineHeight + padding * 2 + 20 // +20 for title
343
-
344
- // Position based on settings
345
- const { bounds } = layout
346
- let legendX = bounds.x + bounds.width - legendWidth - 10
347
- let legendY = bounds.y + bounds.height - legendHeight - 10
348
-
349
- switch (settings.position) {
350
- case 'top-left':
351
- legendX = bounds.x + 10
352
- legendY = bounds.y + 10
353
- break
354
- case 'top-right':
355
- legendX = bounds.x + bounds.width - legendWidth - 10
356
- legendY = bounds.y + 10
357
- break
358
- case 'bottom-left':
359
- legendX = bounds.x + 10
360
- legendY = bounds.y + bounds.height - legendHeight - 10
361
- break
362
- }
363
-
364
- // Render legend box
365
- let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
366
- <rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
367
- fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
368
- <text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`
369
-
370
- // Render items
371
- for (const [index, item] of items.entries()) {
372
- const y = padding + 28 + index * lineHeight
373
- svg += `\n <g transform="translate(${padding}, ${y})">`
374
- svg += `\n ${item.icon}`
375
- svg += `\n <text x="${iconWidth + 4}" y="4" class="node-label" font-size="10">${this.escapeXml(item.label)}</text>`
376
- svg += '\n </g>'
377
- }
378
-
379
- svg += '\n</g>'
380
- return svg
381
- }
382
-
383
- /**
384
- * Render bandwidth indicator for legend
385
- */
386
- private renderBandwidthLegendIcon(lineCount: number): string {
387
- const lineSpacing = 3
388
- const lineWidth = 24
389
- const strokeWidth = 2
390
- const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
391
-
392
- const lines = offsets.map((offset) => {
393
- const y = offset
394
- return `<line x1="0" y1="${y}" x2="${lineWidth}" y2="${y}" stroke="${this.themeColors.defaultLinkStroke}" stroke-width="${strokeWidth}" />`
395
- })
396
-
397
- return `<g transform="translate(0, 0)">${lines.join('')}</g>`
398
- }
399
-
400
- private renderHeader(width: number, height: number, viewBox: string): string {
401
- return `<svg xmlns="http://www.w3.org/2000/svg"
402
- viewBox="${viewBox}"
403
- width="${width}"
404
- height="${height}"
405
- style="background: ${this.themeColors.backgroundColor}">`
406
- }
407
-
408
- private renderDefs(): string {
409
- return `<defs>
410
- <!-- Arrow marker -->
411
- <marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
412
- <polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
413
- </marker>
414
- <marker id="arrow-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
415
- <polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
416
- </marker>
417
-
418
- <!-- Filters -->
419
- <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
420
- <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
421
- </filter>
422
- </defs>`
423
- }
424
-
425
- private renderStyles(): string {
426
- // CSS variables for interactive runtime theming
427
- const cssVars = this.isInteractive
428
- ? `
429
- :root {
430
- --shumoku-bg: ${this.themeColors.backgroundColor};
431
- --shumoku-surface: ${this.themeColors.subgraphFill};
432
- --shumoku-text: ${this.themeColors.labelColor};
433
- --shumoku-text-secondary: ${this.themeColors.labelSecondaryColor};
434
- --shumoku-border: ${this.themeColors.subgraphStroke};
435
- --shumoku-node-fill: ${this.themeColors.defaultNodeFill};
436
- --shumoku-node-stroke: ${this.themeColors.defaultNodeStroke};
437
- --shumoku-link-stroke: ${this.themeColors.defaultLinkStroke};
438
- --shumoku-font: ${this.options.fontFamily};
439
- }`
440
- : ''
441
-
442
- return `<style>${cssVars}
443
- .node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
444
- .node-label-bold { font-weight: bold; }
445
- .node-icon { color: ${this.themeColors.labelSecondaryColor}; }
446
- .subgraph-icon { opacity: 0.9; }
447
- .subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
448
- .link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
449
- .endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
450
- </style>`
451
- }
452
-
453
- private renderSubgraph(sg: LayoutSubgraph): string {
454
- const { bounds, subgraph } = sg
455
- const style = subgraph.style || {}
456
-
457
- const fill = style.fill || this.themeColors.subgraphFill
458
- const stroke = style.stroke || this.themeColors.subgraphStroke
459
- const strokeWidth = style.strokeWidth || 1
460
- const strokeDasharray = style.strokeDasharray || ''
461
- const labelPos = style.labelPosition || 'top'
462
-
463
- const rx = 8 // Border radius
464
-
465
- // Check if subgraph has vendor icon (service for cloud, model for hardware)
466
- const iconKey = subgraph.service || subgraph.model
467
- const hasIcon = subgraph.vendor && iconKey
468
- const iconSize = 24
469
- const iconPadding = 8
470
-
471
- // Calculate icon position (top-left corner)
472
- const iconX = bounds.x + iconPadding
473
- const iconY = bounds.y + iconPadding
474
-
475
- // Label position - shift right if there's an icon
476
- let labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
477
- let labelY = bounds.y + 20
478
- const textAnchor = 'start'
479
-
480
- if (labelPos === 'top') {
481
- labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
482
- labelY = bounds.y + 20
483
- }
484
-
485
- // Render vendor icon if available
486
- let iconSvg = ''
487
- if (hasIcon) {
488
- const iconEntry = getVendorIconEntry(subgraph.vendor!, iconKey!, subgraph.resource)
489
- if (iconEntry) {
490
- const iconContent = iconEntry[this.iconTheme] || iconEntry.default
491
- const viewBox = iconEntry.viewBox || '0 0 48 48'
492
-
493
- // Check if icon is a nested SVG (PNG-based with custom viewBox in content)
494
- if (iconContent.startsWith('<svg')) {
495
- const viewBoxMatch = iconContent.match(/viewBox="0 0 (\d+) (\d+)"/)
496
- if (viewBoxMatch) {
497
- const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
498
- const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
499
- const aspectRatio = vbWidth / vbHeight
500
- const iconWidth = Math.round(iconSize * aspectRatio)
501
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
502
- <svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
503
- ${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
504
- </svg>
505
- </g>`
506
- }
507
- } else {
508
- // Use viewBox from entry
509
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
510
- <svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
511
- ${iconContent}
512
- </svg>
513
- </g>`
514
- }
515
- }
516
- }
517
-
518
- return `<g class="subgraph" data-id="${sg.id}">
519
- <rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
520
- rx="${rx}" ry="${rx}"
521
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
522
- ${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
523
- ${iconSvg}
524
- <text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
525
- </g>`
526
- }
527
-
528
- /** Render node background (shape only) */
529
- /** Render complete node (bg + fg as one unit) */
530
- private renderNode(layoutNode: LayoutNode): string {
531
- const { id, node } = layoutNode
532
- const dataAttrs = this.buildNodeDataAttributes(node)
533
- const bg = this.renderNodeBackground(layoutNode)
534
- const fg = this.renderNodeForeground(layoutNode)
535
-
536
- return `<g class="node" data-id="${id}"${dataAttrs}>
537
- ${bg}
538
- ${fg}
539
- </g>`
540
- }
541
-
542
- private renderNodeBackground(layoutNode: LayoutNode): string {
543
- const { id, position, size, node } = layoutNode
544
- const x = position.x
545
- const y = position.y
546
- const w = size.width
547
- const h = size.height
548
-
549
- const style = node.style || {}
550
- const fill = style.fill || this.themeColors.defaultNodeFill
551
- const stroke = style.stroke || this.themeColors.defaultNodeStroke
552
- const strokeWidth = style.strokeWidth || 1
553
- const strokeDasharray = style.strokeDasharray || ''
554
-
555
- const shape = this.renderNodeShape(
556
- node.shape,
557
- x,
558
- y,
559
- w,
560
- h,
561
- fill,
562
- stroke,
563
- strokeWidth,
564
- strokeDasharray,
565
- )
566
-
567
- // Build data attributes for interactive mode
568
- const dataAttrs = this.buildNodeDataAttributes(node)
569
-
570
- return `<g class="node-bg" data-id="${id}"${dataAttrs}>${shape}</g>`
571
- }
572
-
573
- /** Build data attributes for a node (interactive mode only) */
574
- private buildNodeDataAttributes(node: Node): string {
575
- if (!this.isInteractive || !this.dataAttrs.device) return ''
576
-
577
- const attrs: string[] = []
578
-
579
- if (node.type) attrs.push(`data-device-type="${this.escapeXml(node.type)}"`)
580
- if (node.vendor) attrs.push(`data-device-vendor="${this.escapeXml(node.vendor)}"`)
581
- if (node.model) attrs.push(`data-device-model="${this.escapeXml(node.model)}"`)
582
- if (node.service) attrs.push(`data-device-service="${this.escapeXml(node.service)}"`)
583
- if (node.resource) attrs.push(`data-device-resource="${this.escapeXml(node.resource)}"`)
584
-
585
- // Include full metadata as JSON
586
- if (this.dataAttrs.metadata) {
587
- const deviceInfo = {
588
- id: node.id,
589
- label: node.label,
590
- type: node.type,
591
- vendor: node.vendor,
592
- model: node.model,
593
- service: node.service,
594
- resource: node.resource,
595
- metadata: node.metadata,
596
- }
597
- attrs.push(`data-device-json="${this.escapeXml(JSON.stringify(deviceInfo))}"`)
598
- }
599
-
600
- return attrs.length > 0 ? ` ${attrs.join(' ')}` : ''
601
- }
602
-
603
- /** Render node foreground (content only, ports rendered separately) */
604
- private renderNodeForeground(layoutNode: LayoutNode): string {
605
- const { id, position, size, node } = layoutNode
606
- const x = position.x
607
- const y = position.y
608
- const w = size.width
609
-
610
- const content = this.renderNodeContent(node, x, y, w)
611
-
612
- // Include data attributes for interactive mode (same as node-bg)
613
- const dataAttrs = this.buildNodeDataAttributes(node)
614
-
615
- return `<g class="node-fg" data-id="${id}"${dataAttrs}>
616
- ${content}
617
- </g>`
618
- }
619
-
620
- /**
621
- * Render ports on a node (as separate groups)
622
- */
623
- private renderPorts(
624
- nodeId: string,
625
- nodeX: number,
626
- nodeY: number,
627
- ports?: Map<string, LayoutPort>,
628
- ): string {
629
- if (!ports || ports.size === 0) return ''
630
-
631
- const groups: string[] = []
632
-
633
- for (const port of ports.values()) {
634
- const px = nodeX + port.position.x
635
- const py = nodeY + port.position.y
636
- const pw = port.size.width
637
- const ph = port.size.height
638
-
639
- // Port data attribute for interactive mode
640
- const portDeviceAttr = this.isInteractive ? ` data-port-device="${nodeId}"` : ''
641
-
642
- const parts: string[] = []
643
-
644
- // Port box
645
- parts.push(`<rect class="port-box"
646
- x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
647
- fill="${this.themeColors.portFill}" stroke="${this.themeColors.portStroke}" stroke-width="1" rx="2" />`)
648
-
649
- // Port label - position based on side
650
- let labelX = px
651
- let labelY = py
652
- let textAnchor = 'middle'
653
- const labelOffset = 12
654
-
655
- switch (port.side) {
656
- case 'top':
657
- labelY = py - labelOffset
658
- break
659
- case 'bottom':
660
- labelY = py + labelOffset + 4
661
- break
662
- case 'left':
663
- labelX = px - labelOffset
664
- textAnchor = 'end'
665
- break
666
- case 'right':
667
- labelX = px + labelOffset
668
- textAnchor = 'start'
669
- break
670
- }
671
-
672
- // Port label with black background
673
- const labelText = this.escapeXml(port.label)
674
- const charWidth = 5.5
675
- const labelWidth = labelText.length * charWidth + 4
676
- const labelHeight = 12
677
-
678
- // Calculate background rect position based on text anchor
679
- let bgX = labelX - 2
680
- if (textAnchor === 'middle') {
681
- bgX = labelX - labelWidth / 2
682
- } else if (textAnchor === 'end') {
683
- bgX = labelX - labelWidth + 2
684
- }
685
- const bgY = labelY - labelHeight + 3
686
-
687
- parts.push(
688
- `<rect class="port-label-bg" x="${bgX}" y="${bgY}" width="${labelWidth}" height="${labelHeight}" rx="2" fill="${this.themeColors.portLabelBg}" />`,
689
- )
690
- parts.push(
691
- `<text class="port-label" x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="9" fill="${this.themeColors.portLabelColor}">${labelText}</text>`,
692
- )
693
-
694
- // Wrap in a group with data attributes
695
- groups.push(`<g class="port" data-port="${port.id}"${portDeviceAttr}>
696
- ${parts.join('\n ')}
697
- </g>`)
698
- }
699
-
700
- return groups.join('\n')
701
- }
702
-
703
- private renderNodeShape(
704
- shape: NodeShape,
705
- x: number,
706
- y: number,
707
- w: number,
708
- h: number,
709
- fill: string,
710
- stroke: string,
711
- strokeWidth: number,
712
- strokeDasharray: string,
713
- ): string {
714
- const dashAttr = strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''
715
- const halfW = w / 2
716
- const halfH = h / 2
717
-
718
- switch (shape) {
719
- case 'rect':
720
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
721
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
722
-
723
- case 'rounded':
724
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
725
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
726
-
727
- case 'circle': {
728
- const r = Math.min(halfW, halfH)
729
- return `<circle cx="${x}" cy="${y}" r="${r}"
730
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
731
- }
732
-
733
- case 'diamond':
734
- return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
735
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
736
-
737
- case 'hexagon': {
738
- const hx = halfW * 0.866
739
- return `<polygon points="${x - halfW},${y} ${x - hx},${y - halfH} ${x + hx},${y - halfH} ${x + halfW},${y} ${x + hx},${y + halfH} ${x - hx},${y + halfH}"
740
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
741
- }
742
-
743
- case 'cylinder': {
744
- const ellipseH = h * 0.15
745
- return `<g>
746
- <ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
747
- <rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
748
- <line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
749
- <line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
750
- <ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />
751
- </g>`
752
- }
753
-
754
- case 'stadium':
755
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
756
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
757
-
758
- case 'trapezoid': {
759
- const indent = w * 0.15
760
- return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
761
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
762
- }
763
-
764
- default:
765
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
766
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
767
- }
768
- }
769
-
770
- /**
771
- * Calculate icon dimensions for a node
772
- */
773
- private calculateIconInfo(
774
- node: Node,
775
- w: number,
776
- ): { width: number; height: number; svg: string } | null {
777
- // Cap icon width at MAX_ICON_WIDTH_RATIO of node width to leave room for ports
778
- const maxIconWidth = Math.round(w * MAX_ICON_WIDTH_RATIO)
779
-
780
- // Try vendor-specific icon first (service for cloud, model for hardware)
781
- const iconKey = node.service || node.model
782
- if (node.vendor && iconKey) {
783
- const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
784
- if (iconEntry) {
785
- const vendorIcon = iconEntry[this.iconTheme] || iconEntry.default
786
- const viewBox = iconEntry.viewBox || '0 0 48 48'
787
-
788
- // Check if icon is a nested SVG (PNG-based with custom viewBox in content)
789
- if (vendorIcon.startsWith('<svg')) {
790
- const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
791
- if (viewBoxMatch) {
792
- const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
793
- const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
794
- const aspectRatio = vbWidth / vbHeight
795
- let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
796
- let iconHeight = DEFAULT_ICON_SIZE
797
- if (iconWidth > maxIconWidth) {
798
- iconWidth = maxIconWidth
799
- iconHeight = Math.round(maxIconWidth / aspectRatio)
800
- }
801
- const innerSvg = vendorIcon.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
802
- return {
803
- width: iconWidth,
804
- height: iconHeight,
805
- svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="0 0 ${vbWidth} ${vbHeight}">${innerSvg}</svg>`,
806
- }
807
- }
808
- }
809
-
810
- // Parse viewBox for aspect ratio calculation
811
- const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
812
- if (vbMatch) {
813
- const vbWidth = Number.parseInt(vbMatch[3], 10)
814
- const vbHeight = Number.parseInt(vbMatch[4], 10)
815
- const aspectRatio = vbWidth / vbHeight
816
- let iconWidth =
817
- Math.abs(aspectRatio - 1) < 0.01
818
- ? DEFAULT_ICON_SIZE
819
- : Math.round(DEFAULT_ICON_SIZE * aspectRatio)
820
- let iconHeight = DEFAULT_ICON_SIZE
821
- if (iconWidth > maxIconWidth) {
822
- iconWidth = maxIconWidth
823
- iconHeight = Math.round(maxIconWidth / aspectRatio)
824
- }
825
- return {
826
- width: iconWidth,
827
- height: iconHeight,
828
- svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="${viewBox}">${vendorIcon}</svg>`,
829
- }
830
- }
831
-
832
- // Fallback: use viewBox directly
833
- return {
834
- width: DEFAULT_ICON_SIZE,
835
- height: DEFAULT_ICON_SIZE,
836
- svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="${viewBox}">${vendorIcon}</svg>`,
837
- }
838
- }
839
- }
840
-
841
- // Fall back to device type icon
842
- const iconPath = getDeviceIcon(node.type)
843
- if (!iconPath) return null
844
-
845
- return {
846
- width: DEFAULT_ICON_SIZE,
847
- height: DEFAULT_ICON_SIZE,
848
- svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
849
- }
850
- }
851
-
852
- /**
853
- * Render node content (icon + label) with dynamic vertical centering
854
- */
855
- private renderNodeContent(node: Node, x: number, y: number, w: number): string {
856
- const iconInfo = this.calculateIconInfo(node, w)
857
- const labels = Array.isArray(node.label) ? node.label : [node.label]
858
- const labelHeight = labels.length * LABEL_LINE_HEIGHT
859
-
860
- // Calculate total content height
861
- const iconHeight = iconInfo?.height || 0
862
- const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
863
- const totalContentHeight = iconHeight + gap + labelHeight
864
-
865
- // Center the content block vertically in the node
866
- const contentTop = y - totalContentHeight / 2
867
-
868
- const parts: string[] = []
869
-
870
- // Render icon at top of content block
871
- if (iconInfo) {
872
- const iconY = contentTop
873
- parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
874
- ${iconInfo.svg}
875
- </g>`)
876
- }
877
-
878
- // Render labels below icon
879
- const labelStartY = contentTop + iconHeight + gap + LABEL_LINE_HEIGHT * 0.7 // 0.7 for text baseline adjustment
880
- for (const [i, line] of labels.entries()) {
881
- const isBold = line.includes('<b>') || line.includes('<strong>')
882
- const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '')
883
- const className = isBold ? 'node-label node-label-bold' : 'node-label'
884
- parts.push(
885
- `<text x="${x}" y="${labelStartY + i * LABEL_LINE_HEIGHT}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`,
886
- )
887
- }
888
-
889
- return parts.join('\n ')
890
- }
891
-
892
- private renderLink(layoutLink: LayoutLink, nodes: Map<string, LayoutNode>): string {
893
- const { id, points, link, fromEndpoint, toEndpoint } = layoutLink
894
- const label = link.label
895
-
896
- // Auto-apply styles based on redundancy type
897
- const type = link.type || this.getDefaultLinkType(link.redundancy)
898
- const arrow = link.arrow ?? this.getDefaultArrowType(link.redundancy)
899
-
900
- const stroke =
901
- link.style?.stroke || this.getVlanStroke(link.vlan) || this.themeColors.defaultLinkStroke
902
- const dasharray = link.style?.strokeDasharray || this.getLinkDasharray(type)
903
- const markerEnd = arrow !== 'none' ? 'url(#arrow)' : ''
904
-
905
- // Get bandwidth rendering config
906
- const bandwidthConfig = this.getBandwidthConfig(link.bandwidth)
907
- const strokeWidth =
908
- link.style?.strokeWidth || bandwidthConfig.strokeWidth || this.getLinkStrokeWidth(type)
909
-
910
- // Render link lines based on bandwidth (single or multiple parallel lines)
911
- let result = this.renderBandwidthLines(
912
- id,
913
- points,
914
- stroke,
915
- strokeWidth,
916
- dasharray,
917
- markerEnd,
918
- bandwidthConfig,
919
- type,
920
- )
921
-
922
- // Center label and VLANs
923
- const midPoint = this.getMidPoint(points)
924
- let labelYOffset = -8
925
-
926
- if (label) {
927
- const labelText = Array.isArray(label) ? label.join(' / ') : label
928
- result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(labelText)}</text>`
929
- labelYOffset += 12
930
- }
931
-
932
- // VLANs (link-level, applies to both endpoints)
933
- if (link.vlan && link.vlan.length > 0) {
934
- const vlanText =
935
- link.vlan.length === 1 ? `VLAN ${link.vlan[0]}` : `VLAN ${link.vlan.join(', ')}`
936
- result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(vlanText)}</text>`
937
- }
938
-
939
- // Get node center positions for label placement
940
- const fromNode = nodes.get(fromEndpoint.node)
941
- const toNode = nodes.get(toEndpoint.node)
942
- const fromNodeCenterX = fromNode ? fromNode.position.x : points[0].x
943
- const toNodeCenterX = toNode ? toNode.position.x : points[points.length - 1].x
944
-
945
- // Endpoint labels (port/ip at both ends) - positioned along the line
946
- const fromLabels = this.formatEndpointLabels(fromEndpoint)
947
- const toLabels = this.formatEndpointLabels(toEndpoint)
948
-
949
- if (fromLabels.length > 0 && points.length > 1) {
950
- const portName = fromEndpoint.port || ''
951
- const labelPos = this.getEndpointLabelPosition(points, 'start', fromNodeCenterX, portName)
952
- result += this.renderEndpointLabels(fromLabels, labelPos.x, labelPos.y, labelPos.anchor)
953
- }
954
-
955
- if (toLabels.length > 0 && points.length > 1) {
956
- const portName = toEndpoint.port || ''
957
- const labelPos = this.getEndpointLabelPosition(points, 'end', toNodeCenterX, portName)
958
- result += this.renderEndpointLabels(toLabels, labelPos.x, labelPos.y, labelPos.anchor)
959
- }
960
-
961
- // Build data attributes for interactive mode
962
- const dataAttrs = this.buildLinkDataAttributes(layoutLink)
963
-
964
- return `<g class="link-group" data-link-id="${id}"${dataAttrs}>\n${result}\n</g>`
965
- }
966
-
967
- /** Build data attributes for a link (interactive mode only) */
968
- private buildLinkDataAttributes(layoutLink: LayoutLink): string {
969
- if (!this.isInteractive || !this.dataAttrs.link) return ''
970
-
971
- const { link, fromEndpoint, toEndpoint } = layoutLink
972
- const attrs: string[] = []
973
-
974
- // Basic link attributes
975
- if (link.bandwidth) attrs.push(`data-link-bandwidth="${this.escapeXml(link.bandwidth)}"`)
976
- if (link.vlan && link.vlan.length > 0) {
977
- attrs.push(`data-link-vlan="${link.vlan.join(',')}"`)
978
- }
979
- if (link.redundancy) attrs.push(`data-link-redundancy="${this.escapeXml(link.redundancy)}"`)
980
-
981
- // Endpoint info
982
- const fromStr = fromEndpoint.port
983
- ? `${fromEndpoint.node}:${fromEndpoint.port}`
984
- : fromEndpoint.node
985
- const toStr = toEndpoint.port ? `${toEndpoint.node}:${toEndpoint.port}` : toEndpoint.node
986
- attrs.push(`data-link-from="${this.escapeXml(fromStr)}"`)
987
- attrs.push(`data-link-to="${this.escapeXml(toStr)}"`)
988
-
989
- // Include full metadata as JSON
990
- if (this.dataAttrs.metadata) {
991
- const linkInfo = {
992
- id: layoutLink.id,
993
- from: fromEndpoint,
994
- to: toEndpoint,
995
- bandwidth: link.bandwidth,
996
- vlan: link.vlan,
997
- redundancy: link.redundancy,
998
- label: link.label,
999
- }
1000
- attrs.push(`data-link-json="${this.escapeXml(JSON.stringify(linkInfo))}"`)
1001
- }
1002
-
1003
- return attrs.length > 0 ? ` ${attrs.join(' ')}` : ''
1004
- }
1005
-
1006
- private formatEndpointLabels(endpoint: { node: string; port?: string; ip?: string }): string[] {
1007
- const parts: string[] = []
1008
- // Port is now rendered on the node itself, so don't include it here
1009
- if (endpoint.ip) parts.push(endpoint.ip)
1010
- return parts
1011
- }
1012
-
1013
- /**
1014
- * Calculate position for endpoint label near the port (not along the line)
1015
- * This avoids label clustering at the center of links
1016
- * Labels are placed based on port position relative to node center
1017
- */
1018
- private getEndpointLabelPosition(
1019
- points: { x: number; y: number }[],
1020
- which: 'start' | 'end',
1021
- nodeCenterX: number,
1022
- portName: string,
1023
- ): { x: number; y: number; anchor: string } {
1024
- // Get the endpoint position (port position)
1025
- const endpointIdx = which === 'start' ? 0 : points.length - 1
1026
- const endpoint = points[endpointIdx]
1027
-
1028
- // Get the next/prev point to determine line direction
1029
- const nextIdx = which === 'start' ? 1 : points.length - 2
1030
- const nextPoint = points[nextIdx]
1031
-
1032
- // Calculate direction from endpoint toward the line
1033
- const dx = nextPoint.x - endpoint.x
1034
- const dy = nextPoint.y - endpoint.y
1035
- const len = Math.sqrt(dx * dx + dy * dy)
1036
-
1037
- // Normalize direction
1038
- const nx = len > 0 ? dx / len : 0
1039
- const ny = len > 0 ? dy / len : 1
1040
-
1041
- const isVertical = Math.abs(dy) > Math.abs(dx)
1042
-
1043
- // Hash port name as fallback
1044
- const portHash = this.hashString(portName)
1045
- const hashDirection = portHash % 2 === 0 ? 1 : -1
1046
-
1047
- // Port position relative to node center determines label side
1048
- const portOffsetFromCenter = endpoint.x - nodeCenterX
1049
-
1050
- let sideMultiplier: number
1051
-
1052
- if (isVertical) {
1053
- if (Math.abs(portOffsetFromCenter) > 5) {
1054
- // Port is on one side of node - place label outward
1055
- sideMultiplier = portOffsetFromCenter > 0 ? 1 : -1
1056
- } else {
1057
- // Center port - use small hash-based offset to avoid overlap
1058
- sideMultiplier = hashDirection * 0.2
1059
- }
1060
- } else {
1061
- // Horizontal link: place label above/below based on which end
1062
- const isStart = which === 'start'
1063
- sideMultiplier = isStart ? -1 : 1
1064
- }
1065
-
1066
- const offsetDist = 30 // Distance along line direction
1067
- const perpDist = 20 // Perpendicular offset (fixed)
1068
-
1069
- // Position: offset along line direction + fixed horizontal offset for vertical links
1070
- let x: number
1071
- let y: number
1072
-
1073
- let anchor: string
1074
-
1075
- if (isVertical) {
1076
- // For vertical links, use fixed horizontal offset (simpler and consistent)
1077
- x = endpoint.x + perpDist * sideMultiplier
1078
- y = endpoint.y + ny * offsetDist
1079
-
1080
- // Text anchor based on final position relative to endpoint
1081
- anchor = 'middle'
1082
- const labelDx = x - endpoint.x
1083
- if (Math.abs(labelDx) > 8) {
1084
- anchor = labelDx > 0 ? 'start' : 'end'
1085
- }
1086
- } else {
1087
- // For horizontal links, position label near the port (not toward center)
1088
- // Keep x near the endpoint, offset y below the line
1089
- x = endpoint.x
1090
- y = endpoint.y + perpDist // Always below the line
1091
-
1092
- // Text anchor: extend toward the center of the link
1093
- // Start endpoint extends right (start), end endpoint extends left (end)
1094
- // Check direction: if nextPoint is to the left, we're on the right side
1095
- anchor = nx < 0 ? 'end' : 'start'
1096
- }
1097
-
1098
- return { x, y, anchor }
1099
- }
1100
-
1101
- /**
1102
- * Render endpoint labels (IP) with white background
1103
- */
1104
- private renderEndpointLabels(lines: string[], x: number, y: number, anchor: string): string {
1105
- if (lines.length === 0) return ''
1106
-
1107
- const lineHeight = 11
1108
- const paddingX = 2
1109
- const paddingY = 2
1110
- const charWidth = 4.8 // Approximate character width for 9px font
1111
-
1112
- // Calculate dimensions
1113
- const maxLen = Math.max(...lines.map((l) => l.length))
1114
- const rectWidth = maxLen * charWidth + paddingX * 2
1115
- const rectHeight = lines.length * lineHeight + paddingY * 2
1116
-
1117
- // Adjust rect position based on text anchor
1118
- let rectX = x - paddingX
1119
- if (anchor === 'middle') {
1120
- rectX = x - rectWidth / 2
1121
- } else if (anchor === 'end') {
1122
- rectX = x - rectWidth + paddingX
1123
- }
1124
-
1125
- const rectY = y - lineHeight + paddingY
1126
-
1127
- let result = `\n<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" rx="2" fill="${this.themeColors.endpointLabelBg}" stroke="${this.themeColors.endpointLabelStroke}" stroke-width="0.5" />`
1128
-
1129
- for (const [i, line] of lines.entries()) {
1130
- const textY = y + i * lineHeight
1131
- result += `\n<text x="${x}" y="${textY}" class="endpoint-label" text-anchor="${anchor}">${this.escapeXml(line)}</text>`
1132
- }
1133
-
1134
- return result
1135
- }
1136
-
1137
- private getLinkStrokeWidth(type: LinkType): number {
1138
- switch (type) {
1139
- case 'thick':
1140
- return 3
1141
- case 'double':
1142
- return 2
1143
- default:
1144
- return 2
1145
- }
1146
- }
1147
-
1148
- /**
1149
- * Bandwidth rendering configuration - line count represents speed
1150
- * 1G → 1 line
1151
- * 10G → 2 lines
1152
- * 25G → 3 lines
1153
- * 40G → 4 lines
1154
- * 100G 5 lines
1155
- */
1156
- private getBandwidthConfig(bandwidth?: string): { lineCount: number; strokeWidth: number } {
1157
- const strokeWidth = 2
1158
- switch (bandwidth) {
1159
- case '1G':
1160
- return { lineCount: 1, strokeWidth }
1161
- case '10G':
1162
- return { lineCount: 2, strokeWidth }
1163
- case '25G':
1164
- return { lineCount: 3, strokeWidth }
1165
- case '40G':
1166
- return { lineCount: 4, strokeWidth }
1167
- case '100G':
1168
- return { lineCount: 5, strokeWidth }
1169
- default:
1170
- return { lineCount: 1, strokeWidth }
1171
- }
1172
- }
1173
-
1174
- /**
1175
- * Render bandwidth lines (single or multiple parallel lines)
1176
- */
1177
- private renderBandwidthLines(
1178
- id: string,
1179
- points: { x: number; y: number }[],
1180
- stroke: string,
1181
- strokeWidth: number,
1182
- dasharray: string,
1183
- markerEnd: string,
1184
- config: { lineCount: number; strokeWidth: number },
1185
- type: LinkType,
1186
- ): string {
1187
- const { lineCount } = config
1188
- const lineSpacing = 3 // Space between parallel lines
1189
-
1190
- // Generate line paths
1191
- const lines: string[] = []
1192
- const basePath = this.generatePath(points)
1193
-
1194
- if (lineCount === 1) {
1195
- // Single line
1196
- let linePath = `<path class="link" data-id="${id}" d="${basePath}"
1197
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
1198
- ${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
1199
- ${markerEnd ? `marker-end="${markerEnd}"` : ''} pointer-events="none" />`
1200
-
1201
- // Double line effect for redundancy types
1202
- if (type === 'double') {
1203
- linePath = `<path class="link-double-outer" d="${basePath}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" pointer-events="none" />
1204
- <path class="link-double-inner" d="${basePath}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" pointer-events="none" />
1205
- ${linePath}`
1206
- }
1207
- lines.push(linePath)
1208
- } else {
1209
- // Multiple parallel lines
1210
- const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
1211
- for (const offset of offsets) {
1212
- const offsetPoints = this.offsetPoints(points, offset)
1213
- const path = this.generatePath(offsetPoints)
1214
- lines.push(`<path class="link" data-id="${id}" d="${path}"
1215
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
1216
- ${dasharray ? `stroke-dasharray="${dasharray}"` : ''} pointer-events="none" />`)
1217
- }
1218
- }
1219
-
1220
- // Calculate hit area width (same as actual lines, hidden underneath)
1221
- const hitWidth = lineCount === 1 ? strokeWidth : (lineCount - 1) * lineSpacing + strokeWidth
1222
-
1223
- // Wrap all lines in a group with transparent hit area on top
1224
- return `<g class="link-lines">
1225
- ${lines.join('\n')}
1226
- <path class="link-hit-area" d="${basePath}"
1227
- fill="none" stroke="${stroke}" stroke-width="${hitWidth}" opacity="0" />
1228
- </g>`
1229
- }
1230
-
1231
- /**
1232
- * Generate SVG path string from points with rounded corners
1233
- */
1234
- private generatePath(points: { x: number; y: number }[], cornerRadius = 8): string {
1235
- if (points.length < 2) return ''
1236
- if (points.length === 2) {
1237
- return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`
1238
- }
1239
-
1240
- const parts: string[] = [`M ${points[0].x} ${points[0].y}`]
1241
-
1242
- for (let i = 1; i < points.length - 1; i++) {
1243
- const prev = points[i - 1]
1244
- const curr = points[i]
1245
- const next = points[i + 1]
1246
-
1247
- // Calculate distances to prev and next points
1248
- const distPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y)
1249
- const distNext = Math.hypot(next.x - curr.x, next.y - curr.y)
1250
-
1251
- // Limit radius to half the shortest segment
1252
- const maxRadius = Math.min(distPrev, distNext) / 2
1253
- const radius = Math.min(cornerRadius, maxRadius)
1254
-
1255
- if (radius < 1) {
1256
- // Too small for rounding, just use straight line
1257
- parts.push(`L ${curr.x} ${curr.y}`)
1258
- continue
1259
- }
1260
-
1261
- // Calculate direction vectors
1262
- const dirPrev = { x: (curr.x - prev.x) / distPrev, y: (curr.y - prev.y) / distPrev }
1263
- const dirNext = { x: (next.x - curr.x) / distNext, y: (next.y - curr.y) / distNext }
1264
-
1265
- // Points where curve starts and ends
1266
- const startCurve = { x: curr.x - dirPrev.x * radius, y: curr.y - dirPrev.y * radius }
1267
- const endCurve = { x: curr.x + dirNext.x * radius, y: curr.y + dirNext.y * radius }
1268
-
1269
- // Line to start of curve, then quadratic bezier through corner
1270
- parts.push(`L ${startCurve.x} ${startCurve.y}`)
1271
- parts.push(`Q ${curr.x} ${curr.y} ${endCurve.x} ${endCurve.y}`)
1272
- }
1273
-
1274
- // Line to final point
1275
- const last = points[points.length - 1]
1276
- parts.push(`L ${last.x} ${last.y}`)
1277
-
1278
- return parts.join(' ')
1279
- }
1280
-
1281
- /**
1282
- * Calculate offsets for parallel lines (centered around 0)
1283
- */
1284
- private calculateLineOffsets(lineCount: number, spacing: number): number[] {
1285
- const offsets: number[] = []
1286
- const totalWidth = (lineCount - 1) * spacing
1287
- const startOffset = -totalWidth / 2
1288
-
1289
- for (let i = 0; i < lineCount; i++) {
1290
- offsets.push(startOffset + i * spacing)
1291
- }
1292
- return offsets
1293
- }
1294
-
1295
- /**
1296
- * Offset points perpendicular to line direction, handling each segment properly
1297
- * For orthogonal paths, this maintains parallel lines through bends
1298
- */
1299
- private offsetPoints(
1300
- points: { x: number; y: number }[],
1301
- offset: number,
1302
- ): { x: number; y: number }[] {
1303
- if (points.length < 2) return points
1304
-
1305
- const result: { x: number; y: number }[] = []
1306
-
1307
- for (let i = 0; i < points.length; i++) {
1308
- const p = points[i]
1309
-
1310
- if (i === 0) {
1311
- // First point: use direction to next point
1312
- const next = points[i + 1]
1313
- const perp = this.getPerpendicular(p, next)
1314
- result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1315
- } else if (i === points.length - 1) {
1316
- // Last point: use direction from previous point
1317
- const prev = points[i - 1]
1318
- const perp = this.getPerpendicular(prev, p)
1319
- result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1320
- } else {
1321
- // Middle point (bend): offset based on incoming segment direction
1322
- const prev = points[i - 1]
1323
- const perp = this.getPerpendicular(prev, p)
1324
- result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1325
-
1326
- // Also add a point for the outgoing segment if direction changes
1327
- const next = points[i + 1]
1328
- const perpNext = this.getPerpendicular(p, next)
1329
-
1330
- // Check if direction changed (bend point)
1331
- if (Math.abs(perp.x - perpNext.x) > 0.01 || Math.abs(perp.y - perpNext.y) > 0.01) {
1332
- result.push({ x: p.x + perpNext.x * offset, y: p.y + perpNext.y * offset })
1333
- }
1334
- }
1335
- }
1336
-
1337
- return result
1338
- }
1339
-
1340
- /**
1341
- * Get perpendicular unit vector for a line segment
1342
- */
1343
- private getPerpendicular(
1344
- from: { x: number; y: number },
1345
- to: { x: number; y: number },
1346
- ): { x: number; y: number } {
1347
- const dx = to.x - from.x
1348
- const dy = to.y - from.y
1349
- const len = Math.sqrt(dx * dx + dy * dy)
1350
-
1351
- if (len === 0) return { x: 0, y: 0 }
1352
-
1353
- // Perpendicular unit vector (rotate 90 degrees)
1354
- return { x: -dy / len, y: dx / len }
1355
- }
1356
-
1357
- /**
1358
- * Get default link type based on redundancy
1359
- */
1360
- private getDefaultLinkType(redundancy?: string): LinkType {
1361
- switch (redundancy) {
1362
- case 'ha':
1363
- case 'vc':
1364
- case 'vss':
1365
- case 'vpc':
1366
- case 'mlag':
1367
- return 'double'
1368
- case 'stack':
1369
- return 'thick'
1370
- default:
1371
- return 'solid'
1372
- }
1373
- }
1374
-
1375
- /**
1376
- * Get default arrow type based on redundancy
1377
- */
1378
- private getDefaultArrowType(_redundancy?: string): 'none' | 'forward' | 'back' | 'both' {
1379
- // Network diagrams typically show bidirectional connections, so no arrow by default
1380
- return 'none'
1381
- }
1382
-
1383
- /**
1384
- * VLAN color palette - distinct colors for different VLANs
1385
- */
1386
- private static readonly VLAN_COLORS = [
1387
- '#dc2626', // Red
1388
- '#ea580c', // Orange
1389
- '#ca8a04', // Yellow
1390
- '#16a34a', // Green
1391
- '#0891b2', // Cyan
1392
- '#2563eb', // Blue
1393
- '#7c3aed', // Violet
1394
- '#c026d3', // Magenta
1395
- '#db2777', // Pink
1396
- '#059669', // Emerald
1397
- '#0284c7', // Light Blue
1398
- '#4f46e5', // Indigo
1399
- ]
1400
-
1401
- /**
1402
- * Get stroke color based on VLANs
1403
- */
1404
- private getVlanStroke(vlan?: number[]): string | undefined {
1405
- if (!vlan || vlan.length === 0) {
1406
- return undefined
1407
- }
1408
-
1409
- if (vlan.length === 1) {
1410
- // Single VLAN: use color based on VLAN ID
1411
- const colorIndex = vlan[0] % SVGRenderer.VLAN_COLORS.length
1412
- return SVGRenderer.VLAN_COLORS[colorIndex]
1413
- }
1414
-
1415
- // Multiple VLANs (trunk): use a combined hash color
1416
- const hash = vlan.reduce((acc, v) => acc + v, 0)
1417
- const colorIndex = hash % SVGRenderer.VLAN_COLORS.length
1418
- return SVGRenderer.VLAN_COLORS[colorIndex]
1419
- }
1420
-
1421
- private getLinkDasharray(type: LinkType): string {
1422
- switch (type) {
1423
- case 'dashed':
1424
- return '5 3'
1425
- case 'invisible':
1426
- return '0'
1427
- default:
1428
- return ''
1429
- }
1430
- }
1431
-
1432
- private getMidPoint(points: { x: number; y: number }[]): { x: number; y: number } {
1433
- if (points.length === 4) {
1434
- // Cubic bezier curve midpoint at t=0.5
1435
- const t = 0.5
1436
- const mt = 1 - t
1437
- const x =
1438
- mt * mt * mt * points[0].x +
1439
- 3 * mt * mt * t * points[1].x +
1440
- 3 * mt * t * t * points[2].x +
1441
- t * t * t * points[3].x
1442
- const y =
1443
- mt * mt * mt * points[0].y +
1444
- 3 * mt * mt * t * points[1].y +
1445
- 3 * mt * t * t * points[2].y +
1446
- t * t * t * points[3].y
1447
- return { x, y }
1448
- }
1449
-
1450
- if (points.length === 2) {
1451
- // Simple midpoint between two points
1452
- return {
1453
- x: (points[0].x + points[1].x) / 2,
1454
- y: (points[0].y + points[1].y) / 2,
1455
- }
1456
- }
1457
-
1458
- // For polylines, find the middle segment and get its midpoint
1459
- const midIndex = Math.floor(points.length / 2)
1460
- if (midIndex > 0 && midIndex < points.length) {
1461
- return {
1462
- x: (points[midIndex - 1].x + points[midIndex].x) / 2,
1463
- y: (points[midIndex - 1].y + points[midIndex].y) / 2,
1464
- }
1465
- }
1466
-
1467
- return points[midIndex] || points[0]
1468
- }
1469
-
1470
- private escapeXml(str: string): string {
1471
- return str
1472
- .replace(/&/g, '&amp;')
1473
- .replace(/</g, '&lt;')
1474
- .replace(/>/g, '&gt;')
1475
- .replace(/"/g, '&quot;')
1476
- .replace(/'/g, '&#39;')
1477
- }
1478
-
1479
- /**
1480
- * Simple string hash for consistent but varied label placement
1481
- */
1482
- private hashString(str: string): number {
1483
- let hash = 0
1484
- for (let i = 0; i < str.length; i++) {
1485
- const char = str.charCodeAt(i)
1486
- hash = (hash << 5) - hash + char
1487
- hash = hash & hash // Convert to 32-bit integer
1488
- }
1489
- return Math.abs(hash)
1490
- }
1491
- }
1492
-
1493
- // Namespace-style API
1494
- export interface RenderOptions extends SVGRendererOptions {}
1495
-
1496
- /**
1497
- * Render NetworkGraph to SVG string
1498
- */
1499
- export function render(graph: NetworkGraph, layout: LayoutResult, options?: RenderOptions): string {
1500
- const renderer = new SVGRenderer(options)
1501
- return renderer.render(graph, layout)
1502
- }
1
+ /**
2
+ * SVG Renderer
3
+ * Renders NetworkGraph to SVG
4
+ */
5
+
6
+ import type {
7
+ IconThemeVariant,
8
+ LayoutLink,
9
+ LayoutNode,
10
+ LayoutPort,
11
+ LayoutResult,
12
+ LayoutSubgraph,
13
+ LegendSettings,
14
+ LinkBandwidth,
15
+ LinkType,
16
+ NetworkGraph,
17
+ Node,
18
+ NodeShape,
19
+ ThemeType,
20
+ } from '@shumoku/core'
21
+ import {
22
+ DEFAULT_ICON_SIZE,
23
+ getDeviceIcon,
24
+ getVendorIconEntry,
25
+ ICON_LABEL_GAP,
26
+ LABEL_LINE_HEIGHT,
27
+ MAX_ICON_WIDTH_RATIO,
28
+ } from '@shumoku/core'
29
+
30
+ import type { DataAttributeOptions, RenderMode } from './types.js'
31
+
32
+ // ============================================
33
+ // Theme Colors
34
+ // ============================================
35
+
36
+ interface ThemeColors {
37
+ backgroundColor: string
38
+ defaultNodeFill: string
39
+ defaultNodeStroke: string
40
+ defaultLinkStroke: string
41
+ labelColor: string
42
+ labelSecondaryColor: string
43
+ subgraphFill: string
44
+ subgraphStroke: string
45
+ subgraphLabelColor: string
46
+ portFill: string
47
+ portStroke: string
48
+ portLabelBg: string
49
+ portLabelColor: string
50
+ endpointLabelBg: string
51
+ endpointLabelStroke: string
52
+ }
53
+
54
+ const LIGHT_THEME: ThemeColors = {
55
+ backgroundColor: '#ffffff',
56
+ defaultNodeFill: '#e2e8f0',
57
+ defaultNodeStroke: '#64748b',
58
+ defaultLinkStroke: '#94a3b8',
59
+ labelColor: '#1e293b',
60
+ labelSecondaryColor: '#64748b',
61
+ subgraphFill: '#f8fafc',
62
+ subgraphStroke: '#cbd5e1',
63
+ subgraphLabelColor: '#374151',
64
+ portFill: '#475569',
65
+ portStroke: '#1e293b',
66
+ portLabelBg: '#1e293b',
67
+ portLabelColor: '#ffffff',
68
+ endpointLabelBg: '#ffffff',
69
+ endpointLabelStroke: '#cbd5e1',
70
+ }
71
+
72
+ const DARK_THEME: ThemeColors = {
73
+ backgroundColor: '#1e293b',
74
+ defaultNodeFill: '#334155',
75
+ defaultNodeStroke: '#64748b',
76
+ defaultLinkStroke: '#64748b',
77
+ labelColor: '#f1f5f9',
78
+ labelSecondaryColor: '#94a3b8',
79
+ subgraphFill: '#0f172a',
80
+ subgraphStroke: '#475569',
81
+ subgraphLabelColor: '#e2e8f0',
82
+ portFill: '#64748b',
83
+ portStroke: '#94a3b8',
84
+ portLabelBg: '#0f172a',
85
+ portLabelColor: '#f1f5f9',
86
+ endpointLabelBg: '#1e293b',
87
+ endpointLabelStroke: '#475569',
88
+ }
89
+
90
+ // ============================================
91
+ // Renderer Options
92
+ // ============================================
93
+
94
+ /** Embedded content for a subgraph */
95
+ export interface EmbeddedSubgraphContent {
96
+ /** SVG content to embed (inner content, without outer <svg> tag) */
97
+ svgContent: string
98
+ /** ViewBox of the embedded content */
99
+ viewBox: string
100
+ }
101
+
102
+ export interface SVGRendererOptions {
103
+ /** Font family */
104
+ fontFamily?: string
105
+ /** Include interactive elements (deprecated, use renderMode) */
106
+ interactive?: boolean
107
+ /**
108
+ * Render mode
109
+ * - 'static': Pure SVG without interactive data attributes (default)
110
+ * - 'interactive': SVG with data attributes for runtime interactivity
111
+ */
112
+ renderMode?: RenderMode
113
+ /**
114
+ * Data attributes to include in interactive mode
115
+ * Only used when renderMode is 'interactive'
116
+ */
117
+ dataAttributes?: DataAttributeOptions
118
+ /**
119
+ * Unique sheet ID for generating unique filter/marker IDs
120
+ * Required when multiple SVGs are embedded in the same HTML page
121
+ */
122
+ sheetId?: string
123
+ /**
124
+ * Embedded content for subgraphs (subgraphId -> content)
125
+ * Used to embed child SVG content into parent subgraph boxes
126
+ */
127
+ embeddedContent?: Map<string, EmbeddedSubgraphContent>
128
+ }
129
+
130
+ const DEFAULT_OPTIONS: Required<SVGRendererOptions> = {
131
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
132
+ interactive: true,
133
+ renderMode: 'static',
134
+ dataAttributes: { device: true, link: true, metadata: true },
135
+ sheetId: '',
136
+ embeddedContent: new Map(),
137
+ }
138
+
139
+ // ============================================
140
+ // SVG Renderer
141
+ // ============================================
142
+
143
+ export class SVGRenderer {
144
+ private options: Required<SVGRendererOptions>
145
+ private themeColors: ThemeColors = LIGHT_THEME
146
+ private iconTheme: IconThemeVariant = 'default'
147
+
148
+ constructor(options?: SVGRendererOptions) {
149
+ this.options = { ...DEFAULT_OPTIONS, ...options }
150
+ }
151
+
152
+ /** Check if interactive mode is enabled */
153
+ private get isInteractive(): boolean {
154
+ return this.options.renderMode === 'interactive'
155
+ }
156
+
157
+ /** Get data attribute options with defaults */
158
+ private get dataAttrs(): Required<DataAttributeOptions> {
159
+ return {
160
+ device: this.options.dataAttributes?.device ?? true,
161
+ link: this.options.dataAttributes?.link ?? true,
162
+ metadata: this.options.dataAttributes?.metadata ?? true,
163
+ }
164
+ }
165
+
166
+ /** Get unique ID suffix for this sheet */
167
+ private get idSuffix(): string {
168
+ return this.options.sheetId ? `-${this.options.sheetId}` : ''
169
+ }
170
+
171
+ /** Get shadow filter ID */
172
+ private get shadowId(): string {
173
+ return `shadow${this.idSuffix}`
174
+ }
175
+
176
+ /** Get arrow marker ID */
177
+ private get arrowId(): string {
178
+ return `arrow${this.idSuffix}`
179
+ }
180
+
181
+ /** Get red arrow marker ID */
182
+ private get arrowRedId(): string {
183
+ return `arrow-red${this.idSuffix}`
184
+ }
185
+
186
+ /**
187
+ * Get theme colors based on theme type
188
+ */
189
+ private getThemeColors(theme?: ThemeType): ThemeColors {
190
+ return theme === 'dark' ? DARK_THEME : LIGHT_THEME
191
+ }
192
+
193
+ /**
194
+ * Get icon theme variant based on theme type
195
+ */
196
+ private getIconTheme(theme?: ThemeType): IconThemeVariant {
197
+ return theme === 'dark' ? 'dark' : 'light'
198
+ }
199
+
200
+ render(graph: NetworkGraph, layout: LayoutResult): string {
201
+ const { bounds } = layout
202
+
203
+ // Set theme colors based on graph settings
204
+ const theme = graph.settings?.theme
205
+ this.themeColors = this.getThemeColors(theme)
206
+ this.iconTheme = this.getIconTheme(theme)
207
+
208
+ // Calculate legend dimensions if enabled
209
+ const legendSettings = this.getLegendSettings(graph.settings?.legend)
210
+ let legendWidth = 0
211
+ let legendHeight = 0
212
+ if (legendSettings.enabled) {
213
+ const legendDims = this.calculateLegendDimensions(graph, legendSettings)
214
+ legendWidth = legendDims.width
215
+ legendHeight = legendDims.height
216
+ }
217
+
218
+ // Expand bounds to include legend with padding
219
+ const legendPadding = 20
220
+ const expandedBounds = {
221
+ x: bounds.x,
222
+ y: bounds.y,
223
+ width: bounds.width + (legendSettings.enabled && legendWidth > 0 ? legendPadding : 0),
224
+ height: bounds.height + (legendSettings.enabled && legendHeight > 0 ? legendPadding : 0),
225
+ }
226
+
227
+ const parts: string[] = []
228
+
229
+ // SVG header using expanded bounds
230
+ const viewBox = `${expandedBounds.x} ${expandedBounds.y} ${expandedBounds.width} ${expandedBounds.height}`
231
+ parts.push(this.renderHeader(expandedBounds.width, expandedBounds.height, viewBox))
232
+
233
+ // Defs (markers, gradients)
234
+ parts.push(this.renderDefs())
235
+
236
+ // Styles
237
+ parts.push(this.renderStyles())
238
+
239
+ // Layer 1: Subgraphs (background)
240
+ for (const sg of layout.subgraphs.values()) {
241
+ parts.push(this.renderSubgraph(sg))
242
+ }
243
+
244
+ // Layer 2: Links (below nodes)
245
+ for (const link of layout.links.values()) {
246
+ parts.push(this.renderLink(link, layout.nodes))
247
+ }
248
+
249
+ // Layer 3: Nodes (bg + fg as one unit, without ports)
250
+ for (const node of layout.nodes.values()) {
251
+ parts.push(this.renderNode(node))
252
+ }
253
+
254
+ // Layer 4: Ports (separate layer on top of nodes)
255
+ for (const node of layout.nodes.values()) {
256
+ const portsRendered = this.renderPorts(node.id, node.position.x, node.position.y, node.ports)
257
+ if (portsRendered) {
258
+ parts.push(portsRendered)
259
+ }
260
+ }
261
+
262
+ // Legend (if enabled) - use already calculated legendSettings
263
+ if (legendSettings.enabled && legendWidth > 0) {
264
+ parts.push(this.renderLegend(graph, layout, legendSettings))
265
+ }
266
+
267
+ // Close SVG
268
+ parts.push('</svg>')
269
+
270
+ return parts.join('\n')
271
+ }
272
+
273
+ /**
274
+ * Calculate legend dimensions without rendering
275
+ */
276
+ private calculateLegendDimensions(
277
+ graph: NetworkGraph,
278
+ settings: LegendSettings,
279
+ ): { width: number; height: number } {
280
+ const lineHeight = 20
281
+ const padding = 12
282
+ const iconWidth = 30
283
+ const maxLabelWidth = 100
284
+
285
+ // Count items
286
+ let itemCount = 0
287
+
288
+ if (settings.showBandwidth) {
289
+ const usedBandwidths = new Set<LinkBandwidth>()
290
+ for (const link of graph.links) {
291
+ if (link.bandwidth) usedBandwidths.add(link.bandwidth)
292
+ }
293
+ itemCount += usedBandwidths.size
294
+ }
295
+
296
+ if (itemCount === 0) {
297
+ return { width: 0, height: 0 }
298
+ }
299
+
300
+ const width = iconWidth + maxLabelWidth + padding * 2
301
+ const height = itemCount * lineHeight + padding * 2 + 20 // +20 for title
302
+
303
+ return { width, height }
304
+ }
305
+
306
+ /**
307
+ * Parse legend settings from various input formats
308
+ */
309
+ private getLegendSettings(
310
+ legend?: boolean | LegendSettings,
311
+ ): LegendSettings & { enabled: boolean } {
312
+ if (legend === true) {
313
+ return {
314
+ enabled: true,
315
+ position: 'top-right',
316
+ showDeviceTypes: true,
317
+ showBandwidth: true,
318
+ showCableTypes: true,
319
+ showVlans: false,
320
+ }
321
+ }
322
+
323
+ if (legend && typeof legend === 'object') {
324
+ return {
325
+ enabled: legend.enabled !== false,
326
+ position: legend.position ?? 'top-right',
327
+ showDeviceTypes: legend.showDeviceTypes ?? true,
328
+ showBandwidth: legend.showBandwidth ?? true,
329
+ showCableTypes: legend.showCableTypes ?? true,
330
+ showVlans: legend.showVlans ?? false,
331
+ }
332
+ }
333
+
334
+ return { enabled: false, position: 'top-right' }
335
+ }
336
+
337
+ /**
338
+ * Render legend showing visual elements used in the diagram
339
+ */
340
+ private renderLegend(
341
+ graph: NetworkGraph,
342
+ layout: LayoutResult,
343
+ settings: LegendSettings,
344
+ ): string {
345
+ const items: { icon: string; label: string }[] = []
346
+ const lineHeight = 20
347
+ const padding = 12
348
+ const iconWidth = 30
349
+ const maxLabelWidth = 100
350
+
351
+ // Collect used bandwidths
352
+ const usedBandwidths = new Set<LinkBandwidth>()
353
+ for (const link of graph.links) {
354
+ if (link.bandwidth) usedBandwidths.add(link.bandwidth)
355
+ }
356
+
357
+ // Collect used device types
358
+ const usedDeviceTypes = new Set<string>()
359
+ for (const node of graph.nodes) {
360
+ if (node.type) usedDeviceTypes.add(node.type)
361
+ }
362
+
363
+ // Build legend items
364
+ if (settings.showBandwidth && usedBandwidths.size > 0) {
365
+ const sortedBandwidths: LinkBandwidth[] = ['1G', '10G', '25G', '40G', '100G'].filter((b) =>
366
+ usedBandwidths.has(b as LinkBandwidth),
367
+ ) as LinkBandwidth[]
368
+
369
+ for (const bw of sortedBandwidths) {
370
+ const config = this.getBandwidthConfig(bw)
371
+ items.push({
372
+ icon: this.renderBandwidthLegendIcon(config.lineCount),
373
+ label: bw,
374
+ })
375
+ }
376
+ }
377
+
378
+ if (items.length === 0) return ''
379
+
380
+ // Calculate legend dimensions
381
+ const legendWidth = iconWidth + maxLabelWidth + padding * 2
382
+ const legendHeight = items.length * lineHeight + padding * 2 + 20 // +20 for title
383
+
384
+ // Position based on settings
385
+ const { bounds } = layout
386
+ let legendX = bounds.x + bounds.width - legendWidth - 10
387
+ let legendY = bounds.y + bounds.height - legendHeight - 10
388
+
389
+ switch (settings.position) {
390
+ case 'top-left':
391
+ legendX = bounds.x + 10
392
+ legendY = bounds.y + 10
393
+ break
394
+ case 'top-right':
395
+ legendX = bounds.x + bounds.width - legendWidth - 10
396
+ legendY = bounds.y + 10
397
+ break
398
+ case 'bottom-left':
399
+ legendX = bounds.x + 10
400
+ legendY = bounds.y + bounds.height - legendHeight - 10
401
+ break
402
+ }
403
+
404
+ // Render legend box
405
+ let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
406
+ <rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
407
+ fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
408
+ <text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`
409
+
410
+ // Render items
411
+ for (const [index, item] of items.entries()) {
412
+ const y = padding + 28 + index * lineHeight
413
+ svg += `\n <g transform="translate(${padding}, ${y})">`
414
+ svg += `\n ${item.icon}`
415
+ svg += `\n <text x="${iconWidth + 4}" y="4" class="node-label" font-size="10">${this.escapeXml(item.label)}</text>`
416
+ svg += '\n </g>'
417
+ }
418
+
419
+ svg += '\n</g>'
420
+ return svg
421
+ }
422
+
423
+ /**
424
+ * Render bandwidth indicator for legend
425
+ */
426
+ private renderBandwidthLegendIcon(lineCount: number): string {
427
+ const lineSpacing = 3
428
+ const lineWidth = 24
429
+ const strokeWidth = 2
430
+ const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
431
+
432
+ const lines = offsets.map((offset) => {
433
+ const y = offset
434
+ return `<line x1="0" y1="${y}" x2="${lineWidth}" y2="${y}" stroke="${this.themeColors.defaultLinkStroke}" stroke-width="${strokeWidth}" />`
435
+ })
436
+
437
+ return `<g transform="translate(0, 0)">${lines.join('')}</g>`
438
+ }
439
+
440
+ private renderHeader(width: number, height: number, viewBox: string): string {
441
+ return `<svg xmlns="http://www.w3.org/2000/svg"
442
+ viewBox="${viewBox}"
443
+ width="${width}"
444
+ height="${height}"
445
+ style="background: ${this.themeColors.backgroundColor}">`
446
+ }
447
+
448
+ private renderDefs(): string {
449
+ return `<defs>
450
+ <!-- Arrow marker -->
451
+ <marker id="${this.arrowId}" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
452
+ <polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
453
+ </marker>
454
+ <marker id="${this.arrowRedId}" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
455
+ <polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
456
+ </marker>
457
+
458
+ <!-- Filters -->
459
+ <filter id="${this.shadowId}" x="-20%" y="-20%" width="140%" height="140%">
460
+ <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
461
+ </filter>
462
+ </defs>`
463
+ }
464
+
465
+ private renderStyles(): string {
466
+ // CSS variables for interactive runtime theming
467
+ const cssVars = this.isInteractive
468
+ ? `
469
+ :root {
470
+ --shumoku-bg: ${this.themeColors.backgroundColor};
471
+ --shumoku-surface: ${this.themeColors.subgraphFill};
472
+ --shumoku-text: ${this.themeColors.labelColor};
473
+ --shumoku-text-secondary: ${this.themeColors.labelSecondaryColor};
474
+ --shumoku-border: ${this.themeColors.subgraphStroke};
475
+ --shumoku-node-fill: ${this.themeColors.defaultNodeFill};
476
+ --shumoku-node-stroke: ${this.themeColors.defaultNodeStroke};
477
+ --shumoku-link-stroke: ${this.themeColors.defaultLinkStroke};
478
+ --shumoku-font: ${this.options.fontFamily};
479
+ }`
480
+ : ''
481
+
482
+ return `<style>${cssVars}
483
+ .node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
484
+ .node-label-bold { font-weight: bold; }
485
+ .node-icon { color: ${this.themeColors.labelSecondaryColor}; }
486
+ .subgraph-icon { opacity: 0.9; }
487
+ .subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
488
+ .link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
489
+ .endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
490
+ </style>`
491
+ }
492
+
493
+ private renderSubgraph(sg: LayoutSubgraph): string {
494
+ const { bounds, subgraph } = sg
495
+ const style = subgraph.style || {}
496
+
497
+ const fill = style.fill || this.themeColors.subgraphFill
498
+ const stroke = style.stroke || this.themeColors.subgraphStroke
499
+ const strokeWidth = style.strokeWidth || 1
500
+ const strokeDasharray = style.strokeDasharray || ''
501
+ const labelPos = style.labelPosition || 'top'
502
+
503
+ const rx = 8 // Border radius
504
+
505
+ // Check if subgraph has vendor icon (service for cloud, model for hardware)
506
+ const iconKey = subgraph.service || subgraph.model
507
+ const hasIcon = subgraph.vendor && iconKey
508
+ const iconSize = 24
509
+ const iconPadding = 8
510
+
511
+ // Calculate icon position (top-left corner)
512
+ const iconX = bounds.x + iconPadding
513
+ const iconY = bounds.y + iconPadding
514
+
515
+ // Label position - shift right if there's an icon
516
+ let labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
517
+ let labelY = bounds.y + 20
518
+ const textAnchor = 'start'
519
+
520
+ if (labelPos === 'top') {
521
+ labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
522
+ labelY = bounds.y + 20
523
+ }
524
+
525
+ // Render vendor icon if available
526
+ let iconSvg = ''
527
+ if (hasIcon) {
528
+ const iconEntry = getVendorIconEntry(subgraph.vendor!, iconKey!, subgraph.resource)
529
+ if (iconEntry) {
530
+ const iconContent = iconEntry[this.iconTheme] || iconEntry.default
531
+ const viewBox = iconEntry.viewBox || '0 0 48 48'
532
+
533
+ // Check if icon is a nested SVG (PNG-based with custom viewBox in content)
534
+ if (iconContent.startsWith('<svg')) {
535
+ const viewBoxMatch = iconContent.match(/viewBox="0 0 (\d+) (\d+)"/)
536
+ if (viewBoxMatch) {
537
+ const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
538
+ const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
539
+ const aspectRatio = vbWidth / vbHeight
540
+ const iconWidth = Math.round(iconSize * aspectRatio)
541
+ iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
542
+ <svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
543
+ ${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
544
+ </svg>
545
+ </g>`
546
+ }
547
+ } else {
548
+ // Use viewBox from entry
549
+ iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
550
+ <svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
551
+ ${iconContent}
552
+ </svg>
553
+ </g>`
554
+ }
555
+ }
556
+ }
557
+
558
+ // Hierarchical navigation attributes
559
+ const hasSheet = subgraph.file || (subgraph.pins && subgraph.pins.length > 0)
560
+ const sheetAttrs = hasSheet ? ` data-has-sheet="true" data-sheet-id="${sg.id}"` : ''
561
+
562
+ // Check for embedded content
563
+ const embeddedContent = this.options.embeddedContent?.get(sg.id)
564
+ let embeddedSvg = ''
565
+ if (embeddedContent) {
566
+ // Calculate content area (below label, with padding)
567
+ const labelHeight = 30 // Space for label
568
+ const padding = 10
569
+ const contentX = bounds.x + padding
570
+ const contentY = bounds.y + labelHeight
571
+ const contentWidth = bounds.width - padding * 2
572
+ const contentHeight = bounds.height - labelHeight - padding
573
+
574
+ // Embed child SVG with viewBox for automatic scaling
575
+ embeddedSvg = `
576
+ <svg x="${contentX}" y="${contentY}" width="${contentWidth}" height="${contentHeight}"
577
+ viewBox="${embeddedContent.viewBox}" preserveAspectRatio="xMidYMid meet">
578
+ ${embeddedContent.svgContent}
579
+ </svg>`
580
+ }
581
+
582
+ // Render boundary ports for hierarchical connections
583
+ let portsSvg = ''
584
+ if (sg.ports && sg.ports.size > 0) {
585
+ const portParts: string[] = []
586
+ const centerX = bounds.x + bounds.width / 2
587
+ const centerY = bounds.y + bounds.height / 2
588
+
589
+ for (const port of sg.ports.values()) {
590
+ const px = centerX + port.position.x
591
+ const py = centerY + port.position.y
592
+ const pw = port.size.width
593
+ const ph = port.size.height
594
+
595
+ // Port circle/diamond on boundary
596
+ portParts.push(`<circle class="subgraph-port" cx="${px}" cy="${py}" r="${Math.max(pw, ph) / 2 + 2}"
597
+ fill="#3b82f6" stroke="#1d4ed8" stroke-width="2" />`)
598
+
599
+ // Port label
600
+ let labelX = px
601
+ let labelY = py
602
+ let anchor = 'middle'
603
+ const labelOffset = 16
604
+
605
+ switch (port.side) {
606
+ case 'top':
607
+ labelY = py - labelOffset
608
+ break
609
+ case 'bottom':
610
+ labelY = py + labelOffset + 4
611
+ break
612
+ case 'left':
613
+ labelX = px - labelOffset
614
+ anchor = 'end'
615
+ break
616
+ case 'right':
617
+ labelX = px + labelOffset
618
+ anchor = 'start'
619
+ break
620
+ }
621
+
622
+ portParts.push(`<text x="${labelX}" y="${labelY}" class="port-label" text-anchor="${anchor}"
623
+ fill="#3b82f6" font-size="10" font-weight="500">${this.escapeXml(port.label)}</text>`)
624
+ }
625
+ portsSvg = portParts.join('\n ')
626
+ }
627
+
628
+ return `<g class="subgraph" data-id="${sg.id}"${sheetAttrs}>
629
+ <rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
630
+ rx="${rx}" ry="${rx}"
631
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
632
+ ${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
633
+ ${iconSvg}
634
+ <text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>${embeddedSvg}
635
+ ${portsSvg}
636
+ </g>`
637
+ }
638
+
639
+ /** Render node background (shape only) */
640
+ /** Render complete node (bg + fg as one unit) */
641
+ private renderNode(layoutNode: LayoutNode): string {
642
+ const { id, node } = layoutNode
643
+ const dataAttrs = this.buildNodeDataAttributes(node)
644
+ const bg = this.renderNodeBackground(layoutNode)
645
+ const fg = this.renderNodeForeground(layoutNode)
646
+
647
+ return `<g class="node" data-id="${id}"${dataAttrs}>
648
+ ${bg}
649
+ ${fg}
650
+ </g>`
651
+ }
652
+
653
+ private renderNodeBackground(layoutNode: LayoutNode): string {
654
+ const { id, position, size, node } = layoutNode
655
+ const x = position.x
656
+ const y = position.y
657
+ const w = size.width
658
+ const h = size.height
659
+
660
+ const style = node.style || {}
661
+
662
+ // Check if this is an export connector node (for hierarchical diagrams)
663
+ const isExport = node.metadata?._isExport === true
664
+
665
+ // Special styling for export connector nodes - use subgraph colors
666
+ let fill = style.fill || this.themeColors.defaultNodeFill
667
+ let stroke = style.stroke || this.themeColors.defaultNodeStroke
668
+ if (isExport) {
669
+ fill = style.fill || this.themeColors.subgraphFill
670
+ stroke = style.stroke || this.themeColors.defaultNodeStroke
671
+ }
672
+ const strokeWidth = style.strokeWidth || 1
673
+ const strokeDasharray = style.strokeDasharray || ''
674
+
675
+ const shape = this.renderNodeShape(
676
+ node.shape,
677
+ x,
678
+ y,
679
+ w,
680
+ h,
681
+ fill,
682
+ stroke,
683
+ strokeWidth,
684
+ strokeDasharray,
685
+ )
686
+
687
+ // Build data attributes for interactive mode
688
+ const dataAttrs = this.buildNodeDataAttributes(node)
689
+
690
+ return `<g class="node-bg" data-id="${id}"${dataAttrs}>${shape}</g>`
691
+ }
692
+
693
+ /** Build data attributes for a node (interactive mode only) */
694
+ private buildNodeDataAttributes(node: Node): string {
695
+ if (!this.isInteractive || !this.dataAttrs.device) return ''
696
+
697
+ const attrs: string[] = []
698
+
699
+ if (node.type) attrs.push(`data-device-type="${this.escapeXml(node.type)}"`)
700
+ if (node.vendor) attrs.push(`data-device-vendor="${this.escapeXml(node.vendor)}"`)
701
+ if (node.model) attrs.push(`data-device-model="${this.escapeXml(node.model)}"`)
702
+ if (node.service) attrs.push(`data-device-service="${this.escapeXml(node.service)}"`)
703
+ if (node.resource) attrs.push(`data-device-resource="${this.escapeXml(node.resource)}"`)
704
+
705
+ // Include full metadata as JSON
706
+ if (this.dataAttrs.metadata) {
707
+ const deviceInfo = {
708
+ id: node.id,
709
+ label: node.label,
710
+ type: node.type,
711
+ vendor: node.vendor,
712
+ model: node.model,
713
+ service: node.service,
714
+ resource: node.resource,
715
+ metadata: node.metadata,
716
+ }
717
+ attrs.push(`data-device-json="${this.escapeXml(JSON.stringify(deviceInfo))}"`)
718
+ }
719
+
720
+ return attrs.length > 0 ? ` ${attrs.join(' ')}` : ''
721
+ }
722
+
723
+ /** Render node foreground (content only, ports rendered separately) */
724
+ private renderNodeForeground(layoutNode: LayoutNode): string {
725
+ const { id, position, size, node } = layoutNode
726
+ const x = position.x
727
+ const y = position.y
728
+ const w = size.width
729
+
730
+ const content = this.renderNodeContent(node, x, y, w)
731
+
732
+ // Include data attributes for interactive mode (same as node-bg)
733
+ const dataAttrs = this.buildNodeDataAttributes(node)
734
+
735
+ return `<g class="node-fg" data-id="${id}"${dataAttrs}>
736
+ ${content}
737
+ </g>`
738
+ }
739
+
740
+ /**
741
+ * Render ports on a node (as separate groups)
742
+ */
743
+ private renderPorts(
744
+ nodeId: string,
745
+ nodeX: number,
746
+ nodeY: number,
747
+ ports?: Map<string, LayoutPort>,
748
+ ): string {
749
+ if (!ports || ports.size === 0) return ''
750
+
751
+ const groups: string[] = []
752
+
753
+ for (const port of ports.values()) {
754
+ const px = nodeX + port.position.x
755
+ const py = nodeY + port.position.y
756
+ const pw = port.size.width
757
+ const ph = port.size.height
758
+
759
+ // Port data attribute for interactive mode
760
+ const portDeviceAttr = this.isInteractive ? ` data-port-device="${nodeId}"` : ''
761
+
762
+ const parts: string[] = []
763
+
764
+ // Port box
765
+ parts.push(`<rect class="port-box"
766
+ x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
767
+ fill="${this.themeColors.portFill}" stroke="${this.themeColors.portStroke}" stroke-width="1" rx="2" />`)
768
+
769
+ // Port label - position based on side
770
+ let labelX = px
771
+ let labelY = py
772
+ let textAnchor = 'middle'
773
+ const labelOffset = 12
774
+
775
+ switch (port.side) {
776
+ case 'top':
777
+ labelY = py - labelOffset
778
+ break
779
+ case 'bottom':
780
+ labelY = py + labelOffset + 4
781
+ break
782
+ case 'left':
783
+ labelX = px - labelOffset
784
+ textAnchor = 'end'
785
+ break
786
+ case 'right':
787
+ labelX = px + labelOffset
788
+ textAnchor = 'start'
789
+ break
790
+ }
791
+
792
+ // Port label with black background
793
+ const labelText = this.escapeXml(port.label)
794
+ const charWidth = 5.5
795
+ const labelWidth = labelText.length * charWidth + 4
796
+ const labelHeight = 12
797
+
798
+ // Calculate background rect position based on text anchor
799
+ let bgX = labelX - 2
800
+ if (textAnchor === 'middle') {
801
+ bgX = labelX - labelWidth / 2
802
+ } else if (textAnchor === 'end') {
803
+ bgX = labelX - labelWidth + 2
804
+ }
805
+ const bgY = labelY - labelHeight + 3
806
+
807
+ parts.push(
808
+ `<rect class="port-label-bg" x="${bgX}" y="${bgY}" width="${labelWidth}" height="${labelHeight}" rx="2" fill="${this.themeColors.portLabelBg}" />`,
809
+ )
810
+ parts.push(
811
+ `<text class="port-label" x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="9" fill="${this.themeColors.portLabelColor}">${labelText}</text>`,
812
+ )
813
+
814
+ // Wrap in a group with data attributes
815
+ groups.push(`<g class="port" data-port="${port.id}"${portDeviceAttr}>
816
+ ${parts.join('\n ')}
817
+ </g>`)
818
+ }
819
+
820
+ return groups.join('\n')
821
+ }
822
+
823
+ private renderNodeShape(
824
+ shape: NodeShape,
825
+ x: number,
826
+ y: number,
827
+ w: number,
828
+ h: number,
829
+ fill: string,
830
+ stroke: string,
831
+ strokeWidth: number,
832
+ strokeDasharray: string,
833
+ ): string {
834
+ const dashAttr = strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''
835
+ const halfW = w / 2
836
+ const halfH = h / 2
837
+
838
+ switch (shape) {
839
+ case 'rect':
840
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
841
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`
842
+
843
+ case 'rounded':
844
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
845
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`
846
+
847
+ case 'circle': {
848
+ const r = Math.min(halfW, halfH)
849
+ return `<circle cx="${x}" cy="${y}" r="${r}"
850
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`
851
+ }
852
+
853
+ case 'diamond':
854
+ return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
855
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`
856
+
857
+ case 'hexagon': {
858
+ const hx = halfW * 0.866
859
+ return `<polygon points="${x - halfW},${y} ${x - hx},${y - halfH} ${x + hx},${y - halfH} ${x + halfW},${y} ${x + hx},${y + halfH} ${x - hx},${y + halfH}"
860
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`
861
+ }
862
+
863
+ case 'cylinder': {
864
+ const ellipseH = h * 0.15
865
+ return `<g>
866
+ <ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
867
+ <rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
868
+ <line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
869
+ <line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
870
+ <ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />
871
+ </g>`
872
+ }
873
+
874
+ case 'stadium':
875
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
876
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`
877
+
878
+ case 'trapezoid': {
879
+ const indent = w * 0.15
880
+ return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
881
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`
882
+ }
883
+
884
+ default:
885
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
886
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Calculate icon dimensions for a node
892
+ */
893
+ private calculateIconInfo(
894
+ node: Node,
895
+ w: number,
896
+ ): { width: number; height: number; svg: string } | null {
897
+ // Cap icon width at MAX_ICON_WIDTH_RATIO of node width to leave room for ports
898
+ const maxIconWidth = Math.round(w * MAX_ICON_WIDTH_RATIO)
899
+
900
+ // Try vendor-specific icon first (service for cloud, model for hardware)
901
+ const iconKey = node.service || node.model
902
+ if (node.vendor && iconKey) {
903
+ const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
904
+ if (iconEntry) {
905
+ const vendorIcon = iconEntry[this.iconTheme] || iconEntry.default
906
+ const viewBox = iconEntry.viewBox || '0 0 48 48'
907
+
908
+ // Check if icon is a nested SVG (PNG-based with custom viewBox in content)
909
+ if (vendorIcon.startsWith('<svg')) {
910
+ const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
911
+ if (viewBoxMatch) {
912
+ const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
913
+ const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
914
+ const aspectRatio = vbWidth / vbHeight
915
+ let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
916
+ let iconHeight = DEFAULT_ICON_SIZE
917
+ if (iconWidth > maxIconWidth) {
918
+ iconWidth = maxIconWidth
919
+ iconHeight = Math.round(maxIconWidth / aspectRatio)
920
+ }
921
+ const innerSvg = vendorIcon.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
922
+ return {
923
+ width: iconWidth,
924
+ height: iconHeight,
925
+ svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="0 0 ${vbWidth} ${vbHeight}">${innerSvg}</svg>`,
926
+ }
927
+ }
928
+ }
929
+
930
+ // Parse viewBox for aspect ratio calculation
931
+ const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
932
+ if (vbMatch) {
933
+ const vbWidth = Number.parseInt(vbMatch[3], 10)
934
+ const vbHeight = Number.parseInt(vbMatch[4], 10)
935
+ const aspectRatio = vbWidth / vbHeight
936
+ let iconWidth =
937
+ Math.abs(aspectRatio - 1) < 0.01
938
+ ? DEFAULT_ICON_SIZE
939
+ : Math.round(DEFAULT_ICON_SIZE * aspectRatio)
940
+ let iconHeight = DEFAULT_ICON_SIZE
941
+ if (iconWidth > maxIconWidth) {
942
+ iconWidth = maxIconWidth
943
+ iconHeight = Math.round(maxIconWidth / aspectRatio)
944
+ }
945
+ return {
946
+ width: iconWidth,
947
+ height: iconHeight,
948
+ svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="${viewBox}">${vendorIcon}</svg>`,
949
+ }
950
+ }
951
+
952
+ // Fallback: use viewBox directly
953
+ return {
954
+ width: DEFAULT_ICON_SIZE,
955
+ height: DEFAULT_ICON_SIZE,
956
+ svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="${viewBox}">${vendorIcon}</svg>`,
957
+ }
958
+ }
959
+ }
960
+
961
+ // Fall back to device type icon
962
+ const iconPath = getDeviceIcon(node.type)
963
+ if (!iconPath) return null
964
+
965
+ return {
966
+ width: DEFAULT_ICON_SIZE,
967
+ height: DEFAULT_ICON_SIZE,
968
+ svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Render node content (icon + label) with dynamic vertical centering
974
+ */
975
+ private renderNodeContent(node: Node, x: number, y: number, w: number): string {
976
+ // Check if this is an export connector node
977
+ const isExport = node.metadata?._isExport === true
978
+
979
+ // For export connectors, render with arrow icon
980
+ if (isExport) {
981
+ return this.renderExportConnectorContent(node, x, y)
982
+ }
983
+
984
+ const iconInfo = this.calculateIconInfo(node, w)
985
+ const labels = Array.isArray(node.label) ? node.label : [node.label]
986
+ const labelHeight = labels.length * LABEL_LINE_HEIGHT
987
+
988
+ // Calculate total content height
989
+ const iconHeight = iconInfo?.height || 0
990
+ const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
991
+ const totalContentHeight = iconHeight + gap + labelHeight
992
+
993
+ // Center the content block vertically in the node
994
+ const contentTop = y - totalContentHeight / 2
995
+
996
+ const parts: string[] = []
997
+
998
+ // Render icon at top of content block
999
+ if (iconInfo) {
1000
+ const iconY = contentTop
1001
+ parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
1002
+ ${iconInfo.svg}
1003
+ </g>`)
1004
+ }
1005
+
1006
+ // Render labels below icon
1007
+ const labelStartY = contentTop + iconHeight + gap + LABEL_LINE_HEIGHT * 0.7 // 0.7 for text baseline adjustment
1008
+ for (const [i, line] of labels.entries()) {
1009
+ const isBold = line.includes('<b>') || line.includes('<strong>')
1010
+ const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '')
1011
+ const className = isBold ? 'node-label node-label-bold' : 'node-label'
1012
+ parts.push(
1013
+ `<text x="${x}" y="${labelStartY + i * LABEL_LINE_HEIGHT}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`,
1014
+ )
1015
+ }
1016
+
1017
+ return parts.join('\n ')
1018
+ }
1019
+
1020
+ /** Render content for export connector nodes */
1021
+ private renderExportConnectorContent(node: Node, x: number, y: number): string {
1022
+ const labels = Array.isArray(node.label) ? node.label : [node.label]
1023
+ const labelText = labels[0] || ''
1024
+
1025
+ // Just render the label (subgraph name), no arrows
1026
+ return `<text x="${x}" y="${y + 4}" class="node-label" text-anchor="middle">${this.escapeXml(labelText)}</text>`
1027
+ }
1028
+
1029
+ private renderLink(layoutLink: LayoutLink, nodes: Map<string, LayoutNode>): string {
1030
+ const { id, points, link, fromEndpoint, toEndpoint } = layoutLink
1031
+ const label = link.label
1032
+
1033
+ // Auto-apply styles based on redundancy type
1034
+ const type = link.type || this.getDefaultLinkType(link.redundancy)
1035
+ const arrow = link.arrow ?? this.getDefaultArrowType(link.redundancy)
1036
+
1037
+ const stroke =
1038
+ link.style?.stroke || this.getVlanStroke(link.vlan) || this.themeColors.defaultLinkStroke
1039
+ const dasharray = link.style?.strokeDasharray || this.getLinkDasharray(type)
1040
+ const markerEnd = arrow !== 'none' ? `url(#${this.arrowId})` : ''
1041
+
1042
+ // Get bandwidth rendering config
1043
+ const bandwidthConfig = this.getBandwidthConfig(link.bandwidth)
1044
+ const strokeWidth =
1045
+ link.style?.strokeWidth || bandwidthConfig.strokeWidth || this.getLinkStrokeWidth(type)
1046
+
1047
+ // Render link lines based on bandwidth (single or multiple parallel lines)
1048
+ let result = this.renderBandwidthLines(
1049
+ id,
1050
+ points,
1051
+ stroke,
1052
+ strokeWidth,
1053
+ dasharray,
1054
+ markerEnd,
1055
+ bandwidthConfig,
1056
+ type,
1057
+ )
1058
+
1059
+ // Center label and VLANs
1060
+ const midPoint = this.getMidPoint(points)
1061
+ let labelYOffset = -8
1062
+
1063
+ if (label) {
1064
+ const labelText = Array.isArray(label) ? label.join(' / ') : label
1065
+ result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(labelText)}</text>`
1066
+ labelYOffset += 12
1067
+ }
1068
+
1069
+ // VLANs (link-level, applies to both endpoints)
1070
+ if (link.vlan && link.vlan.length > 0) {
1071
+ const vlanText =
1072
+ link.vlan.length === 1 ? `VLAN ${link.vlan[0]}` : `VLAN ${link.vlan.join(', ')}`
1073
+ result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(vlanText)}</text>`
1074
+ }
1075
+
1076
+ // Get node center positions for label placement
1077
+ const fromNode = nodes.get(fromEndpoint.node)
1078
+ const toNode = nodes.get(toEndpoint.node)
1079
+ const fromNodeCenterX = fromNode ? fromNode.position.x : points[0].x
1080
+ const toNodeCenterX = toNode ? toNode.position.x : points[points.length - 1].x
1081
+
1082
+ // Endpoint labels (port/ip at both ends) - positioned along the line
1083
+ const fromLabels = this.formatEndpointLabels(fromEndpoint)
1084
+ const toLabels = this.formatEndpointLabels(toEndpoint)
1085
+
1086
+ if (fromLabels.length > 0 && points.length > 1) {
1087
+ const portName = fromEndpoint.port || ''
1088
+ const labelPos = this.getEndpointLabelPosition(points, 'start', fromNodeCenterX, portName)
1089
+ result += this.renderEndpointLabels(fromLabels, labelPos.x, labelPos.y, labelPos.anchor)
1090
+ }
1091
+
1092
+ if (toLabels.length > 0 && points.length > 1) {
1093
+ const portName = toEndpoint.port || ''
1094
+ const labelPos = this.getEndpointLabelPosition(points, 'end', toNodeCenterX, portName)
1095
+ result += this.renderEndpointLabels(toLabels, labelPos.x, labelPos.y, labelPos.anchor)
1096
+ }
1097
+
1098
+ // Build data attributes for interactive mode
1099
+ const dataAttrs = this.buildLinkDataAttributes(layoutLink)
1100
+
1101
+ return `<g class="link-group" data-link-id="${id}"${dataAttrs}>\n${result}\n</g>`
1102
+ }
1103
+
1104
+ /** Build data attributes for a link (interactive mode only) */
1105
+ private buildLinkDataAttributes(layoutLink: LayoutLink): string {
1106
+ if (!this.isInteractive || !this.dataAttrs.link) return ''
1107
+
1108
+ const { link, fromEndpoint, toEndpoint } = layoutLink
1109
+ const attrs: string[] = []
1110
+
1111
+ // Basic link attributes
1112
+ if (link.bandwidth) attrs.push(`data-link-bandwidth="${this.escapeXml(link.bandwidth)}"`)
1113
+ if (link.vlan && link.vlan.length > 0) {
1114
+ attrs.push(`data-link-vlan="${link.vlan.join(',')}"`)
1115
+ }
1116
+ if (link.redundancy) attrs.push(`data-link-redundancy="${this.escapeXml(link.redundancy)}"`)
1117
+
1118
+ // Endpoint info
1119
+ const fromStr = fromEndpoint.port
1120
+ ? `${fromEndpoint.node}:${fromEndpoint.port}`
1121
+ : fromEndpoint.node
1122
+ const toStr = toEndpoint.port ? `${toEndpoint.node}:${toEndpoint.port}` : toEndpoint.node
1123
+ attrs.push(`data-link-from="${this.escapeXml(fromStr)}"`)
1124
+ attrs.push(`data-link-to="${this.escapeXml(toStr)}"`)
1125
+
1126
+ // Include full metadata as JSON
1127
+ if (this.dataAttrs.metadata) {
1128
+ const linkInfo = {
1129
+ id: layoutLink.id,
1130
+ from: fromEndpoint,
1131
+ to: toEndpoint,
1132
+ bandwidth: link.bandwidth,
1133
+ vlan: link.vlan,
1134
+ redundancy: link.redundancy,
1135
+ label: link.label,
1136
+ metadata: link.metadata,
1137
+ }
1138
+ attrs.push(`data-link-json="${this.escapeXml(JSON.stringify(linkInfo))}"`)
1139
+ }
1140
+
1141
+ return attrs.length > 0 ? ` ${attrs.join(' ')}` : ''
1142
+ }
1143
+
1144
+ private formatEndpointLabels(endpoint: { node: string; port?: string; ip?: string }): string[] {
1145
+ const parts: string[] = []
1146
+ // Port is now rendered on the node itself, so don't include it here
1147
+ if (endpoint.ip) parts.push(endpoint.ip)
1148
+ return parts
1149
+ }
1150
+
1151
+ /**
1152
+ * Calculate position for endpoint label near the port (not along the line)
1153
+ * This avoids label clustering at the center of links
1154
+ * Labels are placed based on port position relative to node center
1155
+ */
1156
+ private getEndpointLabelPosition(
1157
+ points: { x: number; y: number }[],
1158
+ which: 'start' | 'end',
1159
+ nodeCenterX: number,
1160
+ portName: string,
1161
+ ): { x: number; y: number; anchor: string } {
1162
+ // Get the endpoint position (port position)
1163
+ const endpointIdx = which === 'start' ? 0 : points.length - 1
1164
+ const endpoint = points[endpointIdx]
1165
+
1166
+ // Get the next/prev point to determine line direction
1167
+ const nextIdx = which === 'start' ? 1 : points.length - 2
1168
+ const nextPoint = points[nextIdx]
1169
+
1170
+ // Calculate direction from endpoint toward the line
1171
+ const dx = nextPoint.x - endpoint.x
1172
+ const dy = nextPoint.y - endpoint.y
1173
+ const len = Math.sqrt(dx * dx + dy * dy)
1174
+
1175
+ // Normalize direction
1176
+ const nx = len > 0 ? dx / len : 0
1177
+ const ny = len > 0 ? dy / len : 1
1178
+
1179
+ const isVertical = Math.abs(dy) > Math.abs(dx)
1180
+
1181
+ // Hash port name as fallback
1182
+ const portHash = this.hashString(portName)
1183
+ const hashDirection = portHash % 2 === 0 ? 1 : -1
1184
+
1185
+ // Port position relative to node center determines label side
1186
+ const portOffsetFromCenter = endpoint.x - nodeCenterX
1187
+
1188
+ let sideMultiplier: number
1189
+
1190
+ if (isVertical) {
1191
+ if (Math.abs(portOffsetFromCenter) > 5) {
1192
+ // Port is on one side of node - place label outward
1193
+ sideMultiplier = portOffsetFromCenter > 0 ? 1 : -1
1194
+ } else {
1195
+ // Center port - use small hash-based offset to avoid overlap
1196
+ sideMultiplier = hashDirection * 0.2
1197
+ }
1198
+ } else {
1199
+ // Horizontal link: place label above/below based on which end
1200
+ const isStart = which === 'start'
1201
+ sideMultiplier = isStart ? -1 : 1
1202
+ }
1203
+
1204
+ const offsetDist = 30 // Distance along line direction
1205
+ const perpDist = 20 // Perpendicular offset (fixed)
1206
+
1207
+ // Position: offset along line direction + fixed horizontal offset for vertical links
1208
+ let x: number
1209
+ let y: number
1210
+
1211
+ let anchor: string
1212
+
1213
+ if (isVertical) {
1214
+ // For vertical links, use fixed horizontal offset (simpler and consistent)
1215
+ x = endpoint.x + perpDist * sideMultiplier
1216
+ y = endpoint.y + ny * offsetDist
1217
+
1218
+ // Text anchor based on final position relative to endpoint
1219
+ anchor = 'middle'
1220
+ const labelDx = x - endpoint.x
1221
+ if (Math.abs(labelDx) > 8) {
1222
+ anchor = labelDx > 0 ? 'start' : 'end'
1223
+ }
1224
+ } else {
1225
+ // For horizontal links, position label near the port (not toward center)
1226
+ // Keep x near the endpoint, offset y below the line
1227
+ x = endpoint.x
1228
+ y = endpoint.y + perpDist // Always below the line
1229
+
1230
+ // Text anchor: extend toward the center of the link
1231
+ // Start endpoint extends right (start), end endpoint extends left (end)
1232
+ // Check direction: if nextPoint is to the left, we're on the right side
1233
+ anchor = nx < 0 ? 'end' : 'start'
1234
+ }
1235
+
1236
+ return { x, y, anchor }
1237
+ }
1238
+
1239
+ /**
1240
+ * Render endpoint labels (IP) with white background
1241
+ */
1242
+ private renderEndpointLabels(lines: string[], x: number, y: number, anchor: string): string {
1243
+ if (lines.length === 0) return ''
1244
+
1245
+ const lineHeight = 11
1246
+ const paddingX = 2
1247
+ const paddingY = 2
1248
+ const charWidth = 4.8 // Approximate character width for 9px font
1249
+
1250
+ // Calculate dimensions
1251
+ const maxLen = Math.max(...lines.map((l) => l.length))
1252
+ const rectWidth = maxLen * charWidth + paddingX * 2
1253
+ const rectHeight = lines.length * lineHeight + paddingY * 2
1254
+
1255
+ // Adjust rect position based on text anchor
1256
+ let rectX = x - paddingX
1257
+ if (anchor === 'middle') {
1258
+ rectX = x - rectWidth / 2
1259
+ } else if (anchor === 'end') {
1260
+ rectX = x - rectWidth + paddingX
1261
+ }
1262
+
1263
+ const rectY = y - lineHeight + paddingY
1264
+
1265
+ let result = `\n<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" rx="2" fill="${this.themeColors.endpointLabelBg}" stroke="${this.themeColors.endpointLabelStroke}" stroke-width="0.5" />`
1266
+
1267
+ for (const [i, line] of lines.entries()) {
1268
+ const textY = y + i * lineHeight
1269
+ result += `\n<text x="${x}" y="${textY}" class="endpoint-label" text-anchor="${anchor}">${this.escapeXml(line)}</text>`
1270
+ }
1271
+
1272
+ return result
1273
+ }
1274
+
1275
+ private getLinkStrokeWidth(type: LinkType): number {
1276
+ switch (type) {
1277
+ case 'thick':
1278
+ return 3
1279
+ case 'double':
1280
+ return 2
1281
+ default:
1282
+ return 2
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * Bandwidth rendering configuration - line count represents speed
1288
+ * 1G → 1 line
1289
+ * 10G → 2 lines
1290
+ * 25G → 3 lines
1291
+ * 40G → 4 lines
1292
+ * 100G → 5 lines
1293
+ */
1294
+ private getBandwidthConfig(bandwidth?: string): { lineCount: number; strokeWidth: number } {
1295
+ const strokeWidth = 2
1296
+ switch (bandwidth) {
1297
+ case '1G':
1298
+ return { lineCount: 1, strokeWidth }
1299
+ case '10G':
1300
+ return { lineCount: 2, strokeWidth }
1301
+ case '25G':
1302
+ return { lineCount: 3, strokeWidth }
1303
+ case '40G':
1304
+ return { lineCount: 4, strokeWidth }
1305
+ case '100G':
1306
+ return { lineCount: 5, strokeWidth }
1307
+ default:
1308
+ return { lineCount: 1, strokeWidth }
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * Render bandwidth lines (single or multiple parallel lines)
1314
+ */
1315
+ private renderBandwidthLines(
1316
+ id: string,
1317
+ points: { x: number; y: number }[],
1318
+ stroke: string,
1319
+ strokeWidth: number,
1320
+ dasharray: string,
1321
+ markerEnd: string,
1322
+ config: { lineCount: number; strokeWidth: number },
1323
+ type: LinkType,
1324
+ ): string {
1325
+ const { lineCount } = config
1326
+ const lineSpacing = 3 // Space between parallel lines
1327
+
1328
+ // Generate line paths
1329
+ const lines: string[] = []
1330
+ const basePath = this.generatePath(points)
1331
+
1332
+ if (lineCount === 1) {
1333
+ // Single line
1334
+ let linePath = `<path class="link" data-id="${id}" d="${basePath}"
1335
+ fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
1336
+ ${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
1337
+ ${markerEnd ? `marker-end="${markerEnd}"` : ''} pointer-events="none" />`
1338
+
1339
+ // Double line effect for redundancy types
1340
+ if (type === 'double') {
1341
+ linePath = `<path class="link-double-outer" d="${basePath}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" pointer-events="none" />
1342
+ <path class="link-double-inner" d="${basePath}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" pointer-events="none" />
1343
+ ${linePath}`
1344
+ }
1345
+ lines.push(linePath)
1346
+ } else {
1347
+ // Multiple parallel lines
1348
+ const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
1349
+ for (const offset of offsets) {
1350
+ const offsetPoints = this.offsetPoints(points, offset)
1351
+ const path = this.generatePath(offsetPoints)
1352
+ lines.push(`<path class="link" data-id="${id}" d="${path}"
1353
+ fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
1354
+ ${dasharray ? `stroke-dasharray="${dasharray}"` : ''} pointer-events="none" />`)
1355
+ }
1356
+ }
1357
+
1358
+ // Calculate hit area width (same as actual lines, hidden underneath)
1359
+ const hitWidth = lineCount === 1 ? strokeWidth : (lineCount - 1) * lineSpacing + strokeWidth
1360
+
1361
+ // Wrap all lines in a group with transparent hit area on top
1362
+ return `<g class="link-lines">
1363
+ ${lines.join('\n')}
1364
+ <path class="link-hit-area" d="${basePath}"
1365
+ fill="none" stroke="${stroke}" stroke-width="${hitWidth}" opacity="0" />
1366
+ </g>`
1367
+ }
1368
+
1369
+ /**
1370
+ * Generate SVG path string from points with rounded corners
1371
+ */
1372
+ private generatePath(points: { x: number; y: number }[], cornerRadius = 8): string {
1373
+ if (points.length < 2) return ''
1374
+ if (points.length === 2) {
1375
+ return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`
1376
+ }
1377
+
1378
+ const parts: string[] = [`M ${points[0].x} ${points[0].y}`]
1379
+
1380
+ for (let i = 1; i < points.length - 1; i++) {
1381
+ const prev = points[i - 1]
1382
+ const curr = points[i]
1383
+ const next = points[i + 1]
1384
+
1385
+ // Calculate distances to prev and next points
1386
+ const distPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y)
1387
+ const distNext = Math.hypot(next.x - curr.x, next.y - curr.y)
1388
+
1389
+ // Limit radius to half the shortest segment
1390
+ const maxRadius = Math.min(distPrev, distNext) / 2
1391
+ const radius = Math.min(cornerRadius, maxRadius)
1392
+
1393
+ if (radius < 1) {
1394
+ // Too small for rounding, just use straight line
1395
+ parts.push(`L ${curr.x} ${curr.y}`)
1396
+ continue
1397
+ }
1398
+
1399
+ // Calculate direction vectors
1400
+ const dirPrev = { x: (curr.x - prev.x) / distPrev, y: (curr.y - prev.y) / distPrev }
1401
+ const dirNext = { x: (next.x - curr.x) / distNext, y: (next.y - curr.y) / distNext }
1402
+
1403
+ // Points where curve starts and ends
1404
+ const startCurve = { x: curr.x - dirPrev.x * radius, y: curr.y - dirPrev.y * radius }
1405
+ const endCurve = { x: curr.x + dirNext.x * radius, y: curr.y + dirNext.y * radius }
1406
+
1407
+ // Line to start of curve, then quadratic bezier through corner
1408
+ parts.push(`L ${startCurve.x} ${startCurve.y}`)
1409
+ parts.push(`Q ${curr.x} ${curr.y} ${endCurve.x} ${endCurve.y}`)
1410
+ }
1411
+
1412
+ // Line to final point
1413
+ const last = points[points.length - 1]
1414
+ parts.push(`L ${last.x} ${last.y}`)
1415
+
1416
+ return parts.join(' ')
1417
+ }
1418
+
1419
+ /**
1420
+ * Calculate offsets for parallel lines (centered around 0)
1421
+ */
1422
+ private calculateLineOffsets(lineCount: number, spacing: number): number[] {
1423
+ const offsets: number[] = []
1424
+ const totalWidth = (lineCount - 1) * spacing
1425
+ const startOffset = -totalWidth / 2
1426
+
1427
+ for (let i = 0; i < lineCount; i++) {
1428
+ offsets.push(startOffset + i * spacing)
1429
+ }
1430
+ return offsets
1431
+ }
1432
+
1433
+ /**
1434
+ * Offset points perpendicular to line direction, handling each segment properly
1435
+ * For orthogonal paths, this maintains parallel lines through bends
1436
+ */
1437
+ private offsetPoints(
1438
+ points: { x: number; y: number }[],
1439
+ offset: number,
1440
+ ): { x: number; y: number }[] {
1441
+ if (points.length < 2) return points
1442
+
1443
+ const result: { x: number; y: number }[] = []
1444
+
1445
+ for (let i = 0; i < points.length; i++) {
1446
+ const p = points[i]
1447
+
1448
+ if (i === 0) {
1449
+ // First point: use direction to next point
1450
+ const next = points[i + 1]
1451
+ const perp = this.getPerpendicular(p, next)
1452
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1453
+ } else if (i === points.length - 1) {
1454
+ // Last point: use direction from previous point
1455
+ const prev = points[i - 1]
1456
+ const perp = this.getPerpendicular(prev, p)
1457
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1458
+ } else {
1459
+ // Middle point (bend): offset based on incoming segment direction
1460
+ const prev = points[i - 1]
1461
+ const perp = this.getPerpendicular(prev, p)
1462
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1463
+
1464
+ // Also add a point for the outgoing segment if direction changes
1465
+ const next = points[i + 1]
1466
+ const perpNext = this.getPerpendicular(p, next)
1467
+
1468
+ // Check if direction changed (bend point)
1469
+ if (Math.abs(perp.x - perpNext.x) > 0.01 || Math.abs(perp.y - perpNext.y) > 0.01) {
1470
+ result.push({ x: p.x + perpNext.x * offset, y: p.y + perpNext.y * offset })
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ return result
1476
+ }
1477
+
1478
+ /**
1479
+ * Get perpendicular unit vector for a line segment
1480
+ */
1481
+ private getPerpendicular(
1482
+ from: { x: number; y: number },
1483
+ to: { x: number; y: number },
1484
+ ): { x: number; y: number } {
1485
+ const dx = to.x - from.x
1486
+ const dy = to.y - from.y
1487
+ const len = Math.sqrt(dx * dx + dy * dy)
1488
+
1489
+ if (len === 0) return { x: 0, y: 0 }
1490
+
1491
+ // Perpendicular unit vector (rotate 90 degrees)
1492
+ return { x: -dy / len, y: dx / len }
1493
+ }
1494
+
1495
+ /**
1496
+ * Get default link type based on redundancy
1497
+ */
1498
+ private getDefaultLinkType(redundancy?: string): LinkType {
1499
+ switch (redundancy) {
1500
+ case 'ha':
1501
+ case 'vc':
1502
+ case 'vss':
1503
+ case 'vpc':
1504
+ case 'mlag':
1505
+ return 'double'
1506
+ case 'stack':
1507
+ return 'thick'
1508
+ default:
1509
+ return 'solid'
1510
+ }
1511
+ }
1512
+
1513
+ /**
1514
+ * Get default arrow type based on redundancy
1515
+ */
1516
+ private getDefaultArrowType(_redundancy?: string): 'none' | 'forward' | 'back' | 'both' {
1517
+ // Network diagrams typically show bidirectional connections, so no arrow by default
1518
+ return 'none'
1519
+ }
1520
+
1521
+ /**
1522
+ * VLAN color palette - distinct colors for different VLANs
1523
+ */
1524
+ private static readonly VLAN_COLORS = [
1525
+ '#dc2626', // Red
1526
+ '#ea580c', // Orange
1527
+ '#ca8a04', // Yellow
1528
+ '#16a34a', // Green
1529
+ '#0891b2', // Cyan
1530
+ '#2563eb', // Blue
1531
+ '#7c3aed', // Violet
1532
+ '#c026d3', // Magenta
1533
+ '#db2777', // Pink
1534
+ '#059669', // Emerald
1535
+ '#0284c7', // Light Blue
1536
+ '#4f46e5', // Indigo
1537
+ ]
1538
+
1539
+ /**
1540
+ * Get stroke color based on VLANs
1541
+ */
1542
+ private getVlanStroke(vlan?: number[]): string | undefined {
1543
+ if (!vlan || vlan.length === 0) {
1544
+ return undefined
1545
+ }
1546
+
1547
+ if (vlan.length === 1) {
1548
+ // Single VLAN: use color based on VLAN ID
1549
+ const colorIndex = vlan[0] % SVGRenderer.VLAN_COLORS.length
1550
+ return SVGRenderer.VLAN_COLORS[colorIndex]
1551
+ }
1552
+
1553
+ // Multiple VLANs (trunk): use a combined hash color
1554
+ const hash = vlan.reduce((acc, v) => acc + v, 0)
1555
+ const colorIndex = hash % SVGRenderer.VLAN_COLORS.length
1556
+ return SVGRenderer.VLAN_COLORS[colorIndex]
1557
+ }
1558
+
1559
+ private getLinkDasharray(type: LinkType): string {
1560
+ switch (type) {
1561
+ case 'dashed':
1562
+ return '5 3'
1563
+ case 'invisible':
1564
+ return '0'
1565
+ default:
1566
+ return ''
1567
+ }
1568
+ }
1569
+
1570
+ private getMidPoint(points: { x: number; y: number }[]): { x: number; y: number } {
1571
+ if (points.length === 4) {
1572
+ // Cubic bezier curve midpoint at t=0.5
1573
+ const t = 0.5
1574
+ const mt = 1 - t
1575
+ const x =
1576
+ mt * mt * mt * points[0].x +
1577
+ 3 * mt * mt * t * points[1].x +
1578
+ 3 * mt * t * t * points[2].x +
1579
+ t * t * t * points[3].x
1580
+ const y =
1581
+ mt * mt * mt * points[0].y +
1582
+ 3 * mt * mt * t * points[1].y +
1583
+ 3 * mt * t * t * points[2].y +
1584
+ t * t * t * points[3].y
1585
+ return { x, y }
1586
+ }
1587
+
1588
+ if (points.length === 2) {
1589
+ // Simple midpoint between two points
1590
+ return {
1591
+ x: (points[0].x + points[1].x) / 2,
1592
+ y: (points[0].y + points[1].y) / 2,
1593
+ }
1594
+ }
1595
+
1596
+ // For polylines, find the middle segment and get its midpoint
1597
+ const midIndex = Math.floor(points.length / 2)
1598
+ if (midIndex > 0 && midIndex < points.length) {
1599
+ return {
1600
+ x: (points[midIndex - 1].x + points[midIndex].x) / 2,
1601
+ y: (points[midIndex - 1].y + points[midIndex].y) / 2,
1602
+ }
1603
+ }
1604
+
1605
+ return points[midIndex] || points[0]
1606
+ }
1607
+
1608
+ private escapeXml(str: string): string {
1609
+ return str
1610
+ .replace(/&/g, '&amp;')
1611
+ .replace(/</g, '&lt;')
1612
+ .replace(/>/g, '&gt;')
1613
+ .replace(/"/g, '&quot;')
1614
+ .replace(/'/g, '&#39;')
1615
+ }
1616
+
1617
+ /**
1618
+ * Simple string hash for consistent but varied label placement
1619
+ */
1620
+ private hashString(str: string): number {
1621
+ let hash = 0
1622
+ for (let i = 0; i < str.length; i++) {
1623
+ const char = str.charCodeAt(i)
1624
+ hash = (hash << 5) - hash + char
1625
+ hash = hash & hash // Convert to 32-bit integer
1626
+ }
1627
+ return Math.abs(hash)
1628
+ }
1629
+ }
1630
+
1631
+ // Namespace-style API
1632
+ export interface RenderOptions extends SVGRendererOptions {}
1633
+
1634
+ /**
1635
+ * Render NetworkGraph to SVG string
1636
+ */
1637
+ export function render(graph: NetworkGraph, layout: LayoutResult, options?: RenderOptions): string {
1638
+ const renderer = new SVGRenderer(options)
1639
+ return renderer.render(graph, layout)
1640
+ }