@shumoku/renderer 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/svg.ts ADDED
@@ -0,0 +1,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
+ 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
+ }