@shumoku/core 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/icons/build-icons.js +3 -3
  2. package/dist/icons/build-icons.js.map +1 -1
  3. package/dist/icons/generated-icons.js +10 -10
  4. package/dist/icons/generated-icons.js.map +1 -1
  5. package/dist/index.d.ts +3 -4
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +6 -8
  8. package/dist/index.js.map +1 -1
  9. package/dist/layout/hierarchical.d.ts +1 -1
  10. package/dist/layout/hierarchical.d.ts.map +1 -1
  11. package/dist/layout/hierarchical.js +82 -66
  12. package/dist/layout/hierarchical.js.map +1 -1
  13. package/dist/layout/index.d.ts +1 -1
  14. package/dist/layout/index.d.ts.map +1 -1
  15. package/dist/layout/index.js.map +1 -1
  16. package/dist/models/types.d.ts.map +1 -1
  17. package/dist/models/types.js +13 -13
  18. package/dist/models/types.js.map +1 -1
  19. package/dist/themes/dark.d.ts.map +1 -1
  20. package/dist/themes/dark.js +1 -1
  21. package/dist/themes/dark.js.map +1 -1
  22. package/dist/themes/index.d.ts +3 -3
  23. package/dist/themes/index.d.ts.map +1 -1
  24. package/dist/themes/index.js +4 -4
  25. package/dist/themes/index.js.map +1 -1
  26. package/dist/themes/modern.d.ts.map +1 -1
  27. package/dist/themes/modern.js.map +1 -1
  28. package/dist/themes/types.d.ts.map +1 -1
  29. package/dist/themes/utils.d.ts +1 -1
  30. package/dist/themes/utils.d.ts.map +1 -1
  31. package/dist/themes/utils.js +5 -4
  32. package/dist/themes/utils.js.map +1 -1
  33. package/package.json +88 -92
  34. package/src/constants.ts +35 -35
  35. package/src/icons/build-icons.ts +12 -6
  36. package/src/icons/generated-icons.ts +12 -12
  37. package/src/index.test.ts +66 -0
  38. package/src/index.ts +6 -13
  39. package/src/layout/hierarchical.ts +1251 -1221
  40. package/src/layout/index.ts +1 -1
  41. package/src/models/types.ts +47 -37
  42. package/src/themes/dark.ts +15 -15
  43. package/src/themes/index.ts +7 -7
  44. package/src/themes/modern.ts +22 -22
  45. package/src/themes/types.ts +26 -26
  46. package/src/themes/utils.ts +25 -24
  47. package/dist/renderer/index.d.ts +0 -6
  48. package/dist/renderer/index.d.ts.map +0 -1
  49. package/dist/renderer/index.js +0 -5
  50. package/dist/renderer/index.js.map +0 -1
  51. package/dist/renderer/svg.d.ts +0 -131
  52. package/dist/renderer/svg.d.ts.map +0 -1
  53. package/dist/renderer/svg.js +0 -1031
  54. package/dist/renderer/svg.js.map +0 -1
  55. package/src/renderer/index.ts +0 -6
  56. package/src/renderer/svg.ts +0 -1277
@@ -1,1277 +0,0 @@
1
- /**
2
- * SVG Renderer
3
- * Renders NetworkGraph to SVG
4
- */
5
-
6
- import type {
7
- NetworkGraph,
8
- LayoutResult,
9
- LayoutNode,
10
- LayoutLink,
11
- LayoutSubgraph,
12
- LayoutPort,
13
- Node,
14
- NodeShape,
15
- LinkType,
16
- ThemeType,
17
- LegendSettings,
18
- LinkBandwidth,
19
- } from '../models/index.js'
20
- import { getDeviceIcon, getVendorIconEntry, type IconThemeVariant } from '../icons/index.js'
21
- import {
22
- DEFAULT_ICON_SIZE,
23
- ICON_LABEL_GAP,
24
- LABEL_LINE_HEIGHT,
25
- MAX_ICON_WIDTH_RATIO,
26
- } from '../constants.js'
27
-
28
- // ============================================
29
- // Theme Colors
30
- // ============================================
31
-
32
- interface ThemeColors {
33
- backgroundColor: string
34
- defaultNodeFill: string
35
- defaultNodeStroke: string
36
- defaultLinkStroke: string
37
- labelColor: string
38
- labelSecondaryColor: string
39
- subgraphFill: string
40
- subgraphStroke: string
41
- subgraphLabelColor: string
42
- portFill: string
43
- portStroke: string
44
- portLabelBg: string
45
- portLabelColor: string
46
- endpointLabelBg: string
47
- endpointLabelStroke: string
48
- }
49
-
50
- const LIGHT_THEME: ThemeColors = {
51
- backgroundColor: '#ffffff',
52
- defaultNodeFill: '#e2e8f0',
53
- defaultNodeStroke: '#64748b',
54
- defaultLinkStroke: '#94a3b8',
55
- labelColor: '#1e293b',
56
- labelSecondaryColor: '#64748b',
57
- subgraphFill: '#f8fafc',
58
- subgraphStroke: '#cbd5e1',
59
- subgraphLabelColor: '#374151',
60
- portFill: '#475569',
61
- portStroke: '#1e293b',
62
- portLabelBg: '#1e293b',
63
- portLabelColor: '#ffffff',
64
- endpointLabelBg: '#ffffff',
65
- endpointLabelStroke: '#cbd5e1',
66
- }
67
-
68
- const DARK_THEME: ThemeColors = {
69
- backgroundColor: '#1e293b',
70
- defaultNodeFill: '#334155',
71
- defaultNodeStroke: '#64748b',
72
- defaultLinkStroke: '#64748b',
73
- labelColor: '#f1f5f9',
74
- labelSecondaryColor: '#94a3b8',
75
- subgraphFill: '#0f172a',
76
- subgraphStroke: '#475569',
77
- subgraphLabelColor: '#e2e8f0',
78
- portFill: '#64748b',
79
- portStroke: '#94a3b8',
80
- portLabelBg: '#0f172a',
81
- portLabelColor: '#f1f5f9',
82
- endpointLabelBg: '#1e293b',
83
- endpointLabelStroke: '#475569',
84
- }
85
-
86
- // ============================================
87
- // Renderer Options
88
- // ============================================
89
-
90
- export interface SVGRendererOptions {
91
- /** Font family */
92
- fontFamily?: string
93
- /** Include interactive elements */
94
- interactive?: boolean
95
- }
96
-
97
- const DEFAULT_OPTIONS: Required<SVGRendererOptions> = {
98
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
99
- interactive: true,
100
- }
101
-
102
- // ============================================
103
- // SVG Renderer
104
- // ============================================
105
-
106
- export class SVGRenderer {
107
- private options: Required<SVGRendererOptions>
108
- private themeColors: ThemeColors = LIGHT_THEME
109
- private iconTheme: IconThemeVariant = 'default'
110
-
111
- constructor(options?: SVGRendererOptions) {
112
- this.options = { ...DEFAULT_OPTIONS, ...options }
113
- }
114
-
115
- /**
116
- * Get theme colors based on theme type
117
- */
118
- private getThemeColors(theme?: ThemeType): ThemeColors {
119
- return theme === 'dark' ? DARK_THEME : LIGHT_THEME
120
- }
121
-
122
- /**
123
- * Get icon theme variant based on theme type
124
- */
125
- private getIconTheme(theme?: ThemeType): IconThemeVariant {
126
- return theme === 'dark' ? 'dark' : 'light'
127
- }
128
-
129
- render(graph: NetworkGraph, layout: LayoutResult): string {
130
- const { bounds } = layout
131
-
132
- // Set theme colors based on graph settings
133
- const theme = graph.settings?.theme
134
- this.themeColors = this.getThemeColors(theme)
135
- this.iconTheme = this.getIconTheme(theme)
136
-
137
- // Calculate legend dimensions if enabled
138
- const legendSettings = this.getLegendSettings(graph.settings?.legend)
139
- let legendWidth = 0
140
- let legendHeight = 0
141
- if (legendSettings.enabled) {
142
- const legendDims = this.calculateLegendDimensions(graph, legendSettings)
143
- legendWidth = legendDims.width
144
- legendHeight = legendDims.height
145
- }
146
-
147
- // Expand bounds to include legend with padding
148
- const legendPadding = 20
149
- const expandedBounds = {
150
- x: bounds.x,
151
- y: bounds.y,
152
- width: bounds.width + (legendSettings.enabled && legendWidth > 0 ? legendPadding : 0),
153
- height: bounds.height + (legendSettings.enabled && legendHeight > 0 ? legendPadding : 0),
154
- }
155
-
156
- const parts: string[] = []
157
-
158
- // SVG header using expanded bounds
159
- const viewBox = `${expandedBounds.x} ${expandedBounds.y} ${expandedBounds.width} ${expandedBounds.height}`
160
- parts.push(this.renderHeader(expandedBounds.width, expandedBounds.height, viewBox))
161
-
162
- // Defs (markers, gradients)
163
- parts.push(this.renderDefs())
164
-
165
- // Styles
166
- parts.push(this.renderStyles())
167
-
168
- // Layer 1: Subgraphs (background)
169
- layout.subgraphs.forEach((sg) => {
170
- parts.push(this.renderSubgraph(sg))
171
- })
172
-
173
- // Layer 2: Node backgrounds (shapes)
174
- layout.nodes.forEach((node) => {
175
- parts.push(this.renderNodeBackground(node))
176
- })
177
-
178
- // Layer 3: Links (on top of node backgrounds)
179
- layout.links.forEach((link) => {
180
- parts.push(this.renderLink(link, layout.nodes))
181
- })
182
-
183
- // Layer 4: Node foregrounds (content + ports, on top of links)
184
- layout.nodes.forEach((node) => {
185
- parts.push(this.renderNodeForeground(node))
186
- })
187
-
188
- // Legend (if enabled) - use already calculated legendSettings
189
- if (legendSettings.enabled && legendWidth > 0) {
190
- parts.push(this.renderLegend(graph, layout, legendSettings))
191
- }
192
-
193
- // Close SVG
194
- parts.push('</svg>')
195
-
196
- return parts.join('\n')
197
- }
198
-
199
- /**
200
- * Calculate legend dimensions without rendering
201
- */
202
- private calculateLegendDimensions(graph: NetworkGraph, settings: LegendSettings): { width: number; height: number } {
203
- const lineHeight = 20
204
- const padding = 12
205
- const iconWidth = 30
206
- const maxLabelWidth = 100
207
-
208
- // Count items
209
- let itemCount = 0
210
-
211
- if (settings.showBandwidth) {
212
- const usedBandwidths = new Set<LinkBandwidth>()
213
- graph.links.forEach(link => {
214
- if (link.bandwidth) usedBandwidths.add(link.bandwidth)
215
- })
216
- itemCount += usedBandwidths.size
217
- }
218
-
219
- if (itemCount === 0) {
220
- return { width: 0, height: 0 }
221
- }
222
-
223
- const width = iconWidth + maxLabelWidth + padding * 2
224
- const height = itemCount * lineHeight + padding * 2 + 20 // +20 for title
225
-
226
- return { width, height }
227
- }
228
-
229
- /**
230
- * Parse legend settings from various input formats
231
- */
232
- private getLegendSettings(legend?: boolean | LegendSettings): LegendSettings & { enabled: boolean } {
233
- if (legend === true) {
234
- return {
235
- enabled: true,
236
- position: 'top-right',
237
- showDeviceTypes: true,
238
- showBandwidth: true,
239
- showCableTypes: true,
240
- showVlans: false,
241
- }
242
- }
243
-
244
- if (legend && typeof legend === 'object') {
245
- return {
246
- enabled: legend.enabled !== false,
247
- position: legend.position ?? 'top-right',
248
- showDeviceTypes: legend.showDeviceTypes ?? true,
249
- showBandwidth: legend.showBandwidth ?? true,
250
- showCableTypes: legend.showCableTypes ?? true,
251
- showVlans: legend.showVlans ?? false,
252
- }
253
- }
254
-
255
- return { enabled: false, position: 'top-right' }
256
- }
257
-
258
- /**
259
- * Render legend showing visual elements used in the diagram
260
- */
261
- private renderLegend(graph: NetworkGraph, layout: LayoutResult, settings: LegendSettings): string {
262
- const items: { icon: string; label: string }[] = []
263
- const lineHeight = 20
264
- const padding = 12
265
- const iconWidth = 30
266
- const maxLabelWidth = 100
267
-
268
- // Collect used bandwidths
269
- const usedBandwidths = new Set<LinkBandwidth>()
270
- graph.links.forEach(link => {
271
- if (link.bandwidth) usedBandwidths.add(link.bandwidth)
272
- })
273
-
274
- // Collect used device types
275
- const usedDeviceTypes = new Set<string>()
276
- graph.nodes.forEach(node => {
277
- if (node.type) usedDeviceTypes.add(node.type)
278
- })
279
-
280
- // Build legend items
281
- if (settings.showBandwidth && usedBandwidths.size > 0) {
282
- const sortedBandwidths: LinkBandwidth[] = ['1G', '10G', '25G', '40G', '100G'].filter(
283
- b => usedBandwidths.has(b as LinkBandwidth)
284
- ) as LinkBandwidth[]
285
-
286
- for (const bw of sortedBandwidths) {
287
- const config = this.getBandwidthConfig(bw)
288
- items.push({
289
- icon: this.renderBandwidthLegendIcon(config.lineCount),
290
- label: bw,
291
- })
292
- }
293
- }
294
-
295
- if (items.length === 0) return ''
296
-
297
- // Calculate legend dimensions
298
- const legendWidth = iconWidth + maxLabelWidth + padding * 2
299
- const legendHeight = items.length * lineHeight + padding * 2 + 20 // +20 for title
300
-
301
- // Position based on settings
302
- const { bounds } = layout
303
- let legendX = bounds.x + bounds.width - legendWidth - 10
304
- let legendY = bounds.y + bounds.height - legendHeight - 10
305
-
306
- switch (settings.position) {
307
- case 'top-left':
308
- legendX = bounds.x + 10
309
- legendY = bounds.y + 10
310
- break
311
- case 'top-right':
312
- legendX = bounds.x + bounds.width - legendWidth - 10
313
- legendY = bounds.y + 10
314
- break
315
- case 'bottom-left':
316
- legendX = bounds.x + 10
317
- legendY = bounds.y + bounds.height - legendHeight - 10
318
- break
319
- }
320
-
321
- // Render legend box
322
- let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
323
- <rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
324
- fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
325
- <text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`
326
-
327
- // Render items
328
- items.forEach((item, index) => {
329
- const y = padding + 28 + index * lineHeight
330
- svg += `\n <g transform="translate(${padding}, ${y})">`
331
- svg += `\n ${item.icon}`
332
- svg += `\n <text x="${iconWidth + 4}" y="4" class="node-label" font-size="10">${this.escapeXml(item.label)}</text>`
333
- svg += `\n </g>`
334
- })
335
-
336
- svg += '\n</g>'
337
- return svg
338
- }
339
-
340
- /**
341
- * Render bandwidth indicator for legend
342
- */
343
- private renderBandwidthLegendIcon(lineCount: number): string {
344
- const lineSpacing = 3
345
- const lineWidth = 24
346
- const strokeWidth = 2
347
- const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
348
-
349
- const lines = offsets.map(offset => {
350
- const y = offset
351
- return `<line x1="0" y1="${y}" x2="${lineWidth}" y2="${y}" stroke="${this.themeColors.defaultLinkStroke}" stroke-width="${strokeWidth}" />`
352
- })
353
-
354
- return `<g transform="translate(0, 0)">${lines.join('')}</g>`
355
- }
356
-
357
- private renderHeader(width: number, height: number, viewBox: string): string {
358
- return `<svg xmlns="http://www.w3.org/2000/svg"
359
- viewBox="${viewBox}"
360
- width="${width}"
361
- height="${height}"
362
- style="background: ${this.themeColors.backgroundColor}">`
363
- }
364
-
365
- private renderDefs(): string {
366
- return `<defs>
367
- <!-- Arrow marker -->
368
- <marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
369
- <polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
370
- </marker>
371
- <marker id="arrow-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
372
- <polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
373
- </marker>
374
-
375
- <!-- Filters -->
376
- <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
377
- <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
378
- </filter>
379
- </defs>`
380
- }
381
-
382
- private renderStyles(): string {
383
- return `<style>
384
- .node { cursor: pointer; }
385
- .node:hover rect, .node:hover circle, .node:hover polygon { filter: brightness(0.95); }
386
- .node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
387
- .node-label-bold { font-weight: bold; }
388
- .node-icon { color: ${this.themeColors.labelSecondaryColor}; }
389
- .subgraph-icon { opacity: 0.9; }
390
- .subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
391
- .link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
392
- .endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
393
- </style>`
394
- }
395
-
396
- private renderSubgraph(sg: LayoutSubgraph): string {
397
- const { bounds, subgraph } = sg
398
- const style = subgraph.style || {}
399
-
400
- const fill = style.fill || this.themeColors.subgraphFill
401
- const stroke = style.stroke || this.themeColors.subgraphStroke
402
- const strokeWidth = style.strokeWidth || 1
403
- const strokeDasharray = style.strokeDasharray || ''
404
- const labelPos = style.labelPosition || 'top'
405
-
406
- const rx = 8 // Border radius
407
-
408
- // Check if subgraph has vendor icon (service for cloud, model for hardware)
409
- const iconKey = subgraph.service || subgraph.model
410
- const hasIcon = subgraph.vendor && iconKey
411
- const iconSize = 24
412
- const iconPadding = 8
413
-
414
- // Calculate icon position (top-left corner)
415
- const iconX = bounds.x + iconPadding
416
- const iconY = bounds.y + iconPadding
417
-
418
- // Label position - shift right if there's an icon
419
- let labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
420
- let labelY = bounds.y + 20
421
- const textAnchor = 'start'
422
-
423
- if (labelPos === 'top') {
424
- labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
425
- labelY = bounds.y + 20
426
- }
427
-
428
- // Render vendor icon if available
429
- let iconSvg = ''
430
- if (hasIcon) {
431
- const iconEntry = getVendorIconEntry(subgraph.vendor!, iconKey!, subgraph.resource)
432
- if (iconEntry) {
433
- const iconContent = iconEntry[this.iconTheme] || iconEntry.default
434
- const viewBox = iconEntry.viewBox || '0 0 48 48'
435
-
436
- // Check if icon is a nested SVG (PNG-based with custom viewBox in content)
437
- if (iconContent.startsWith('<svg')) {
438
- const viewBoxMatch = iconContent.match(/viewBox="0 0 (\d+) (\d+)"/)
439
- if (viewBoxMatch) {
440
- const vbWidth = parseInt(viewBoxMatch[1])
441
- const vbHeight = parseInt(viewBoxMatch[2])
442
- const aspectRatio = vbWidth / vbHeight
443
- const iconWidth = Math.round(iconSize * aspectRatio)
444
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
445
- <svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
446
- ${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
447
- </svg>
448
- </g>`
449
- }
450
- } else {
451
- // Use viewBox from entry
452
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
453
- <svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
454
- ${iconContent}
455
- </svg>
456
- </g>`
457
- }
458
- }
459
- }
460
-
461
- return `<g class="subgraph" data-id="${sg.id}">
462
- <rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
463
- rx="${rx}" ry="${rx}"
464
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
465
- ${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
466
- ${iconSvg}
467
- <text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
468
- </g>`
469
- }
470
-
471
- /** Render node background (shape only) */
472
- private renderNodeBackground(layoutNode: LayoutNode): string {
473
- const { id, position, size, node } = layoutNode
474
- const x = position.x
475
- const y = position.y
476
- const w = size.width
477
- const h = size.height
478
-
479
- const style = node.style || {}
480
- const fill = style.fill || this.themeColors.defaultNodeFill
481
- const stroke = style.stroke || this.themeColors.defaultNodeStroke
482
- const strokeWidth = style.strokeWidth || 1
483
- const strokeDasharray = style.strokeDasharray || ''
484
-
485
- const shape = this.renderNodeShape(node.shape, x, y, w, h, fill, stroke, strokeWidth, strokeDasharray)
486
-
487
- return `<g class="node-bg" data-id="${id}">${shape}</g>`
488
- }
489
-
490
- /** Render node foreground (content + ports) */
491
- private renderNodeForeground(layoutNode: LayoutNode): string {
492
- const { id, position, size, node, ports } = layoutNode
493
- const x = position.x
494
- const y = position.y
495
- const w = size.width
496
-
497
- const content = this.renderNodeContent(node, x, y, w)
498
- const portsRendered = this.renderPorts(x, y, ports)
499
-
500
- return `<g class="node-fg" data-id="${id}">
501
- ${content}
502
- ${portsRendered}
503
- </g>`
504
- }
505
-
506
-
507
- /**
508
- * Render ports on a node
509
- */
510
- private renderPorts(
511
- nodeX: number,
512
- nodeY: number,
513
- ports?: Map<string, LayoutPort>
514
- ): string {
515
- if (!ports || ports.size === 0) return ''
516
-
517
- const parts: string[] = []
518
-
519
- ports.forEach((port) => {
520
- const px = nodeX + port.position.x
521
- const py = nodeY + port.position.y
522
- const pw = port.size.width
523
- const ph = port.size.height
524
-
525
- // Port box
526
- parts.push(`<rect class="port" data-port="${port.id}"
527
- x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
528
- fill="${this.themeColors.portFill}" stroke="${this.themeColors.portStroke}" stroke-width="1" rx="2" />`)
529
-
530
- // Port label - position based on side
531
- let labelX = px
532
- let labelY = py
533
- let textAnchor = 'middle'
534
- const labelOffset = 12
535
-
536
- switch (port.side) {
537
- case 'top':
538
- labelY = py - labelOffset
539
- break
540
- case 'bottom':
541
- labelY = py + labelOffset + 4
542
- break
543
- case 'left':
544
- labelX = px - labelOffset
545
- textAnchor = 'end'
546
- break
547
- case 'right':
548
- labelX = px + labelOffset
549
- textAnchor = 'start'
550
- break
551
- }
552
-
553
- // Port label with black background
554
- const labelText = this.escapeXml(port.label)
555
- const charWidth = 5.5
556
- const labelWidth = labelText.length * charWidth + 4
557
- const labelHeight = 12
558
-
559
- // Calculate background rect position based on text anchor
560
- let bgX = labelX - 2
561
- if (textAnchor === 'middle') {
562
- bgX = labelX - labelWidth / 2
563
- } else if (textAnchor === 'end') {
564
- bgX = labelX - labelWidth + 2
565
- }
566
- const bgY = labelY - labelHeight + 3
567
-
568
- parts.push(`<rect class="port-label-bg" x="${bgX}" y="${bgY}" width="${labelWidth}" height="${labelHeight}" rx="2" fill="${this.themeColors.portLabelBg}" />`)
569
- parts.push(`<text class="port-label" x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="9" fill="${this.themeColors.portLabelColor}">${labelText}</text>`)
570
- })
571
-
572
- return parts.join('\n ')
573
- }
574
-
575
- private renderNodeShape(
576
- shape: NodeShape,
577
- x: number, y: number,
578
- w: number, h: number,
579
- fill: string, stroke: string,
580
- strokeWidth: number, strokeDasharray: string
581
- ): string {
582
- const dashAttr = strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''
583
- const halfW = w / 2
584
- const halfH = h / 2
585
-
586
- switch (shape) {
587
- case 'rect':
588
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
589
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
590
-
591
- case 'rounded':
592
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
593
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
594
-
595
- case 'circle':
596
- const r = Math.min(halfW, halfH)
597
- return `<circle cx="${x}" cy="${y}" r="${r}"
598
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
599
-
600
- case 'diamond':
601
- return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
602
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
603
-
604
- case 'hexagon':
605
- const hx = halfW * 0.866
606
- 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}"
607
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
608
-
609
- case 'cylinder':
610
- const ellipseH = h * 0.15
611
- return `<g>
612
- <ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
613
- <rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
614
- <line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
615
- <line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
616
- <ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />
617
- </g>`
618
-
619
- case 'stadium':
620
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
621
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
622
-
623
- case 'trapezoid':
624
- const indent = w * 0.15
625
- return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
626
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
627
-
628
- default:
629
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
630
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
631
- }
632
- }
633
-
634
- /**
635
- * Calculate icon dimensions for a node
636
- */
637
- private calculateIconInfo(node: Node, w: number): { width: number; height: number; svg: string } | null {
638
- // Cap icon width at MAX_ICON_WIDTH_RATIO of node width to leave room for ports
639
- const maxIconWidth = Math.round(w * MAX_ICON_WIDTH_RATIO)
640
-
641
- // Try vendor-specific icon first (service for cloud, model for hardware)
642
- const iconKey = node.service || node.model
643
- if (node.vendor && iconKey) {
644
- const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
645
- if (iconEntry) {
646
- const vendorIcon = iconEntry[this.iconTheme] || iconEntry.default
647
- const viewBox = iconEntry.viewBox || '0 0 48 48'
648
-
649
- // Check if icon is a nested SVG (PNG-based with custom viewBox in content)
650
- if (vendorIcon.startsWith('<svg')) {
651
- const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
652
- if (viewBoxMatch) {
653
- const vbWidth = parseInt(viewBoxMatch[1])
654
- const vbHeight = parseInt(viewBoxMatch[2])
655
- const aspectRatio = vbWidth / vbHeight
656
- let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
657
- let iconHeight = DEFAULT_ICON_SIZE
658
- if (iconWidth > maxIconWidth) {
659
- iconWidth = maxIconWidth
660
- iconHeight = Math.round(maxIconWidth / aspectRatio)
661
- }
662
- const innerSvg = vendorIcon.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
663
- return {
664
- width: iconWidth,
665
- height: iconHeight,
666
- svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="0 0 ${vbWidth} ${vbHeight}">${innerSvg}</svg>`,
667
- }
668
- }
669
- }
670
-
671
- // Parse viewBox for aspect ratio calculation
672
- const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
673
- if (vbMatch) {
674
- const vbWidth = parseInt(vbMatch[3])
675
- const vbHeight = parseInt(vbMatch[4])
676
- const aspectRatio = vbWidth / vbHeight
677
- let iconWidth = Math.abs(aspectRatio - 1) < 0.01 ? DEFAULT_ICON_SIZE : Math.round(DEFAULT_ICON_SIZE * aspectRatio)
678
- let iconHeight = DEFAULT_ICON_SIZE
679
- if (iconWidth > maxIconWidth) {
680
- iconWidth = maxIconWidth
681
- iconHeight = Math.round(maxIconWidth / aspectRatio)
682
- }
683
- return {
684
- width: iconWidth,
685
- height: iconHeight,
686
- svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="${viewBox}">${vendorIcon}</svg>`,
687
- }
688
- }
689
-
690
- // Fallback: use viewBox directly
691
- return {
692
- width: DEFAULT_ICON_SIZE,
693
- height: DEFAULT_ICON_SIZE,
694
- svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="${viewBox}">${vendorIcon}</svg>`,
695
- }
696
- }
697
- }
698
-
699
- // Fall back to device type icon
700
- const iconPath = getDeviceIcon(node.type)
701
- if (!iconPath) return null
702
-
703
- return {
704
- width: DEFAULT_ICON_SIZE,
705
- height: DEFAULT_ICON_SIZE,
706
- svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
707
- }
708
- }
709
-
710
- /**
711
- * Render node content (icon + label) with dynamic vertical centering
712
- */
713
- private renderNodeContent(node: Node, x: number, y: number, w: number): string {
714
- const iconInfo = this.calculateIconInfo(node, w)
715
- const labels = Array.isArray(node.label) ? node.label : [node.label]
716
- const labelHeight = labels.length * LABEL_LINE_HEIGHT
717
-
718
- // Calculate total content height
719
- const iconHeight = iconInfo?.height || 0
720
- const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
721
- const totalContentHeight = iconHeight + gap + labelHeight
722
-
723
- // Center the content block vertically in the node
724
- const contentTop = y - totalContentHeight / 2
725
-
726
- const parts: string[] = []
727
-
728
- // Render icon at top of content block
729
- if (iconInfo) {
730
- const iconY = contentTop
731
- parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
732
- ${iconInfo.svg}
733
- </g>`)
734
- }
735
-
736
- // Render labels below icon
737
- const labelStartY = contentTop + iconHeight + gap + LABEL_LINE_HEIGHT * 0.7 // 0.7 for text baseline adjustment
738
- labels.forEach((line, i) => {
739
- const isBold = line.includes('<b>') || line.includes('<strong>')
740
- const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '')
741
- const className = isBold ? 'node-label node-label-bold' : 'node-label'
742
- parts.push(`<text x="${x}" y="${labelStartY + i * LABEL_LINE_HEIGHT}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`)
743
- })
744
-
745
- return parts.join('\n ')
746
- }
747
-
748
- private renderLink(layoutLink: LayoutLink, nodes: Map<string, LayoutNode>): string {
749
- const { id, points, link, fromEndpoint, toEndpoint } = layoutLink
750
- const label = link.label
751
-
752
- // Auto-apply styles based on redundancy type
753
- const type = link.type || this.getDefaultLinkType(link.redundancy)
754
- const arrow = link.arrow ?? this.getDefaultArrowType(link.redundancy)
755
-
756
- const stroke = link.style?.stroke || this.getVlanStroke(link.vlan) || this.themeColors.defaultLinkStroke
757
- const dasharray = link.style?.strokeDasharray || this.getLinkDasharray(type)
758
- const markerEnd = arrow !== 'none' ? 'url(#arrow)' : ''
759
-
760
- // Get bandwidth rendering config
761
- const bandwidthConfig = this.getBandwidthConfig(link.bandwidth)
762
- const strokeWidth = link.style?.strokeWidth || bandwidthConfig.strokeWidth || this.getLinkStrokeWidth(type)
763
-
764
- // Render link lines based on bandwidth (single or multiple parallel lines)
765
- let result = this.renderBandwidthLines(
766
- id, points, stroke, strokeWidth, dasharray, markerEnd, bandwidthConfig, type
767
- )
768
-
769
- // Center label and VLANs
770
- const midPoint = this.getMidPoint(points)
771
- let labelYOffset = -8
772
-
773
- if (label) {
774
- const labelText = Array.isArray(label) ? label.join(' / ') : label
775
- result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(labelText)}</text>`
776
- labelYOffset += 12
777
- }
778
-
779
- // VLANs (link-level, applies to both endpoints)
780
- if (link.vlan && link.vlan.length > 0) {
781
- const vlanText = link.vlan.length === 1
782
- ? `VLAN ${link.vlan[0]}`
783
- : `VLAN ${link.vlan.join(', ')}`
784
- result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(vlanText)}</text>`
785
- }
786
-
787
- // Get node center positions for label placement
788
- const fromNode = nodes.get(fromEndpoint.node)
789
- const toNode = nodes.get(toEndpoint.node)
790
- const fromNodeCenterX = fromNode ? fromNode.position.x : points[0].x
791
- const toNodeCenterX = toNode ? toNode.position.x : points[points.length - 1].x
792
-
793
- // Endpoint labels (port/ip at both ends) - positioned along the line
794
- const fromLabels = this.formatEndpointLabels(fromEndpoint)
795
- const toLabels = this.formatEndpointLabels(toEndpoint)
796
-
797
- if (fromLabels.length > 0 && points.length > 1) {
798
- const portName = fromEndpoint.port || ''
799
- const labelPos = this.getEndpointLabelPosition(points, 'start', fromNodeCenterX, portName)
800
- result += this.renderEndpointLabels(fromLabels, labelPos.x, labelPos.y, labelPos.anchor)
801
- }
802
-
803
- if (toLabels.length > 0 && points.length > 1) {
804
- const portName = toEndpoint.port || ''
805
- const labelPos = this.getEndpointLabelPosition(points, 'end', toNodeCenterX, portName)
806
- result += this.renderEndpointLabels(toLabels, labelPos.x, labelPos.y, labelPos.anchor)
807
- }
808
-
809
- return `<g class="link-group">\n${result}\n</g>`
810
- }
811
-
812
- private formatEndpointLabels(endpoint: { node: string; port?: string; ip?: string }): string[] {
813
- const parts: string[] = []
814
- // Port is now rendered on the node itself, so don't include it here
815
- if (endpoint.ip) parts.push(endpoint.ip)
816
- return parts
817
- }
818
-
819
- /**
820
- * Calculate position for endpoint label near the port (not along the line)
821
- * This avoids label clustering at the center of links
822
- * Labels are placed based on port position relative to node center
823
- */
824
- private getEndpointLabelPosition(
825
- points: { x: number; y: number }[],
826
- which: 'start' | 'end',
827
- nodeCenterX: number,
828
- portName: string
829
- ): { x: number; y: number; anchor: string } {
830
- // Get the endpoint position (port position)
831
- const endpointIdx = which === 'start' ? 0 : points.length - 1
832
- const endpoint = points[endpointIdx]
833
-
834
- // Get the next/prev point to determine line direction
835
- const nextIdx = which === 'start' ? 1 : points.length - 2
836
- const nextPoint = points[nextIdx]
837
-
838
- // Calculate direction from endpoint toward the line
839
- const dx = nextPoint.x - endpoint.x
840
- const dy = nextPoint.y - endpoint.y
841
- const len = Math.sqrt(dx * dx + dy * dy)
842
-
843
- // Normalize direction
844
- const nx = len > 0 ? dx / len : 0
845
- const ny = len > 0 ? dy / len : 1
846
-
847
- const isVertical = Math.abs(dy) > Math.abs(dx)
848
-
849
- // Hash port name as fallback
850
- const portHash = this.hashString(portName)
851
- const hashDirection = portHash % 2 === 0 ? 1 : -1
852
-
853
- // Port position relative to node center determines label side
854
- const portOffsetFromCenter = endpoint.x - nodeCenterX
855
-
856
- let sideMultiplier: number
857
-
858
- if (isVertical) {
859
- if (Math.abs(portOffsetFromCenter) > 5) {
860
- // Port is on one side of node - place label outward
861
- sideMultiplier = portOffsetFromCenter > 0 ? 1 : -1
862
- } else {
863
- // Center port - use small hash-based offset to avoid overlap
864
- sideMultiplier = hashDirection * 0.2
865
- }
866
- } else {
867
- // Horizontal link: place label above/below based on which end
868
- const isStart = which === 'start'
869
- sideMultiplier = isStart ? -1 : 1
870
- }
871
-
872
- const offsetDist = 30 // Distance along line direction
873
- const perpDist = 20 // Perpendicular offset (fixed)
874
-
875
- // Position: offset along line direction + fixed horizontal offset for vertical links
876
- let x: number
877
- let y: number
878
-
879
- let anchor: string
880
-
881
- if (isVertical) {
882
- // For vertical links, use fixed horizontal offset (simpler and consistent)
883
- x = endpoint.x + perpDist * sideMultiplier
884
- y = endpoint.y + ny * offsetDist
885
-
886
- // Text anchor based on final position relative to endpoint
887
- anchor = 'middle'
888
- const labelDx = x - endpoint.x
889
- if (Math.abs(labelDx) > 8) {
890
- anchor = labelDx > 0 ? 'start' : 'end'
891
- }
892
- } else {
893
- // For horizontal links, position label near the port (not toward center)
894
- // Keep x near the endpoint, offset y below the line
895
- x = endpoint.x
896
- y = endpoint.y + perpDist // Always below the line
897
-
898
- // Text anchor: extend toward the center of the link
899
- // Start endpoint extends right (start), end endpoint extends left (end)
900
- // Check direction: if nextPoint is to the left, we're on the right side
901
- anchor = nx < 0 ? 'end' : 'start'
902
- }
903
-
904
- return { x, y, anchor }
905
- }
906
-
907
- /**
908
- * Render endpoint labels (IP) with white background
909
- */
910
- private renderEndpointLabels(lines: string[], x: number, y: number, anchor: string): string {
911
- if (lines.length === 0) return ''
912
-
913
- const lineHeight = 11
914
- const paddingX = 2
915
- const paddingY = 2
916
- const charWidth = 4.8 // Approximate character width for 9px font
917
-
918
- // Calculate dimensions
919
- const maxLen = Math.max(...lines.map(l => l.length))
920
- const rectWidth = maxLen * charWidth + paddingX * 2
921
- const rectHeight = lines.length * lineHeight + paddingY * 2
922
-
923
- // Adjust rect position based on text anchor
924
- let rectX = x - paddingX
925
- if (anchor === 'middle') {
926
- rectX = x - rectWidth / 2
927
- } else if (anchor === 'end') {
928
- rectX = x - rectWidth + paddingX
929
- }
930
-
931
- const rectY = y - lineHeight + paddingY
932
-
933
- 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" />`
934
-
935
- lines.forEach((line, i) => {
936
- const textY = y + i * lineHeight
937
- result += `\n<text x="${x}" y="${textY}" class="endpoint-label" text-anchor="${anchor}">${this.escapeXml(line)}</text>`
938
- })
939
-
940
- return result
941
- }
942
-
943
- private getLinkStrokeWidth(type: LinkType): number {
944
- switch (type) {
945
- case 'thick': return 3
946
- case 'double': return 2
947
- default: return 2
948
- }
949
- }
950
-
951
- /**
952
- * Bandwidth rendering configuration - line count represents speed
953
- * 1G → 1 line
954
- * 10G → 2 lines
955
- * 25G → 3 lines
956
- * 40G → 4 lines
957
- * 100G → 5 lines
958
- */
959
- private getBandwidthConfig(bandwidth?: string): { lineCount: number; strokeWidth: number } {
960
- const strokeWidth = 2
961
- switch (bandwidth) {
962
- case '1G':
963
- return { lineCount: 1, strokeWidth }
964
- case '10G':
965
- return { lineCount: 2, strokeWidth }
966
- case '25G':
967
- return { lineCount: 3, strokeWidth }
968
- case '40G':
969
- return { lineCount: 4, strokeWidth }
970
- case '100G':
971
- return { lineCount: 5, strokeWidth }
972
- default:
973
- return { lineCount: 1, strokeWidth }
974
- }
975
- }
976
-
977
- /**
978
- * Render bandwidth lines (single or multiple parallel lines)
979
- */
980
- private renderBandwidthLines(
981
- id: string,
982
- points: { x: number; y: number }[],
983
- stroke: string,
984
- strokeWidth: number,
985
- dasharray: string,
986
- markerEnd: string,
987
- config: { lineCount: number; strokeWidth: number },
988
- type: LinkType
989
- ): string {
990
- const { lineCount } = config
991
- const lineSpacing = 3 // Space between parallel lines
992
-
993
- if (lineCount === 1) {
994
- // Single line
995
- const path = this.generatePath(points)
996
- let result = `<path class="link" data-id="${id}" d="${path}"
997
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
998
- ${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
999
- ${markerEnd ? `marker-end="${markerEnd}"` : ''} />`
1000
-
1001
- // Double line effect for redundancy types
1002
- if (type === 'double') {
1003
- result = `<path class="link-double-outer" d="${path}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" />
1004
- <path class="link-double-inner" d="${path}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" />
1005
- ${result}`
1006
- }
1007
- return result
1008
- }
1009
-
1010
- // Multiple parallel lines
1011
- const paths: string[] = []
1012
- const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
1013
-
1014
- for (const offset of offsets) {
1015
- const offsetPoints = this.offsetPoints(points, offset)
1016
- const path = this.generatePath(offsetPoints)
1017
- paths.push(`<path class="link" data-id="${id}" d="${path}"
1018
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
1019
- ${dasharray ? `stroke-dasharray="${dasharray}"` : ''} />`)
1020
- }
1021
-
1022
- return paths.join('\n')
1023
- }
1024
-
1025
- /**
1026
- * Generate SVG path string from points with rounded corners
1027
- */
1028
- private generatePath(points: { x: number; y: number }[], cornerRadius: number = 8): string {
1029
- if (points.length < 2) return ''
1030
- if (points.length === 2) {
1031
- return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`
1032
- }
1033
-
1034
- const parts: string[] = [`M ${points[0].x} ${points[0].y}`]
1035
-
1036
- for (let i = 1; i < points.length - 1; i++) {
1037
- const prev = points[i - 1]
1038
- const curr = points[i]
1039
- const next = points[i + 1]
1040
-
1041
- // Calculate distances to prev and next points
1042
- const distPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y)
1043
- const distNext = Math.hypot(next.x - curr.x, next.y - curr.y)
1044
-
1045
- // Limit radius to half the shortest segment
1046
- const maxRadius = Math.min(distPrev, distNext) / 2
1047
- const radius = Math.min(cornerRadius, maxRadius)
1048
-
1049
- if (radius < 1) {
1050
- // Too small for rounding, just use straight line
1051
- parts.push(`L ${curr.x} ${curr.y}`)
1052
- continue
1053
- }
1054
-
1055
- // Calculate direction vectors
1056
- const dirPrev = { x: (curr.x - prev.x) / distPrev, y: (curr.y - prev.y) / distPrev }
1057
- const dirNext = { x: (next.x - curr.x) / distNext, y: (next.y - curr.y) / distNext }
1058
-
1059
- // Points where curve starts and ends
1060
- const startCurve = { x: curr.x - dirPrev.x * radius, y: curr.y - dirPrev.y * radius }
1061
- const endCurve = { x: curr.x + dirNext.x * radius, y: curr.y + dirNext.y * radius }
1062
-
1063
- // Line to start of curve, then quadratic bezier through corner
1064
- parts.push(`L ${startCurve.x} ${startCurve.y}`)
1065
- parts.push(`Q ${curr.x} ${curr.y} ${endCurve.x} ${endCurve.y}`)
1066
- }
1067
-
1068
- // Line to final point
1069
- const last = points[points.length - 1]
1070
- parts.push(`L ${last.x} ${last.y}`)
1071
-
1072
- return parts.join(' ')
1073
- }
1074
-
1075
- /**
1076
- * Calculate offsets for parallel lines (centered around 0)
1077
- */
1078
- private calculateLineOffsets(lineCount: number, spacing: number): number[] {
1079
- const offsets: number[] = []
1080
- const totalWidth = (lineCount - 1) * spacing
1081
- const startOffset = -totalWidth / 2
1082
-
1083
- for (let i = 0; i < lineCount; i++) {
1084
- offsets.push(startOffset + i * spacing)
1085
- }
1086
- return offsets
1087
- }
1088
-
1089
- /**
1090
- * Offset points perpendicular to line direction, handling each segment properly
1091
- * For orthogonal paths, this maintains parallel lines through bends
1092
- */
1093
- private offsetPoints(points: { x: number; y: number }[], offset: number): { x: number; y: number }[] {
1094
- if (points.length < 2) return points
1095
-
1096
- const result: { x: number; y: number }[] = []
1097
-
1098
- for (let i = 0; i < points.length; i++) {
1099
- const p = points[i]
1100
-
1101
- if (i === 0) {
1102
- // First point: use direction to next point
1103
- const next = points[i + 1]
1104
- const perp = this.getPerpendicular(p, next)
1105
- result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1106
- } else if (i === points.length - 1) {
1107
- // Last point: use direction from previous point
1108
- const prev = points[i - 1]
1109
- const perp = this.getPerpendicular(prev, p)
1110
- result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1111
- } else {
1112
- // Middle point (bend): offset based on incoming segment direction
1113
- const prev = points[i - 1]
1114
- const perp = this.getPerpendicular(prev, p)
1115
- result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1116
-
1117
- // Also add a point for the outgoing segment if direction changes
1118
- const next = points[i + 1]
1119
- const perpNext = this.getPerpendicular(p, next)
1120
-
1121
- // Check if direction changed (bend point)
1122
- if (Math.abs(perp.x - perpNext.x) > 0.01 || Math.abs(perp.y - perpNext.y) > 0.01) {
1123
- result.push({ x: p.x + perpNext.x * offset, y: p.y + perpNext.y * offset })
1124
- }
1125
- }
1126
- }
1127
-
1128
- return result
1129
- }
1130
-
1131
- /**
1132
- * Get perpendicular unit vector for a line segment
1133
- */
1134
- private getPerpendicular(from: { x: number; y: number }, to: { x: number; y: number }): { x: number; y: number } {
1135
- const dx = to.x - from.x
1136
- const dy = to.y - from.y
1137
- const len = Math.sqrt(dx * dx + dy * dy)
1138
-
1139
- if (len === 0) return { x: 0, y: 0 }
1140
-
1141
- // Perpendicular unit vector (rotate 90 degrees)
1142
- return { x: -dy / len, y: dx / len }
1143
- }
1144
-
1145
- /**
1146
- * Get default link type based on redundancy
1147
- */
1148
- private getDefaultLinkType(redundancy?: string): LinkType {
1149
- switch (redundancy) {
1150
- case 'ha':
1151
- case 'vc':
1152
- case 'vss':
1153
- case 'vpc':
1154
- case 'mlag':
1155
- return 'double'
1156
- case 'stack':
1157
- return 'thick'
1158
- default:
1159
- return 'solid'
1160
- }
1161
- }
1162
-
1163
- /**
1164
- * Get default arrow type based on redundancy
1165
- */
1166
- private getDefaultArrowType(_redundancy?: string): 'none' | 'forward' | 'back' | 'both' {
1167
- // Network diagrams typically show bidirectional connections, so no arrow by default
1168
- return 'none'
1169
- }
1170
-
1171
- /**
1172
- * VLAN color palette - distinct colors for different VLANs
1173
- */
1174
- private static readonly VLAN_COLORS = [
1175
- '#dc2626', // Red
1176
- '#ea580c', // Orange
1177
- '#ca8a04', // Yellow
1178
- '#16a34a', // Green
1179
- '#0891b2', // Cyan
1180
- '#2563eb', // Blue
1181
- '#7c3aed', // Violet
1182
- '#c026d3', // Magenta
1183
- '#db2777', // Pink
1184
- '#059669', // Emerald
1185
- '#0284c7', // Light Blue
1186
- '#4f46e5', // Indigo
1187
- ]
1188
-
1189
- /**
1190
- * Get stroke color based on VLANs
1191
- */
1192
- private getVlanStroke(vlan?: number[]): string | undefined {
1193
- if (!vlan || vlan.length === 0) {
1194
- return undefined
1195
- }
1196
-
1197
- if (vlan.length === 1) {
1198
- // Single VLAN: use color based on VLAN ID
1199
- const colorIndex = vlan[0] % SVGRenderer.VLAN_COLORS.length
1200
- return SVGRenderer.VLAN_COLORS[colorIndex]
1201
- }
1202
-
1203
- // Multiple VLANs (trunk): use a combined hash color
1204
- const hash = vlan.reduce((acc, v) => acc + v, 0)
1205
- const colorIndex = hash % SVGRenderer.VLAN_COLORS.length
1206
- return SVGRenderer.VLAN_COLORS[colorIndex]
1207
- }
1208
-
1209
- private getLinkDasharray(type: LinkType): string {
1210
- switch (type) {
1211
- case 'dashed': return '5 3'
1212
- case 'invisible': return '0'
1213
- default: return ''
1214
- }
1215
- }
1216
-
1217
- private getMidPoint(points: { x: number; y: number }[]): { x: number; y: number } {
1218
- if (points.length === 4) {
1219
- // Cubic bezier curve midpoint at t=0.5
1220
- const t = 0.5
1221
- const mt = 1 - t
1222
- const x = mt * mt * mt * points[0].x +
1223
- 3 * mt * mt * t * points[1].x +
1224
- 3 * mt * t * t * points[2].x +
1225
- t * t * t * points[3].x
1226
- const y = mt * mt * mt * points[0].y +
1227
- 3 * mt * mt * t * points[1].y +
1228
- 3 * mt * t * t * points[2].y +
1229
- t * t * t * points[3].y
1230
- return { x, y }
1231
- }
1232
-
1233
- if (points.length === 2) {
1234
- // Simple midpoint between two points
1235
- return {
1236
- x: (points[0].x + points[1].x) / 2,
1237
- y: (points[0].y + points[1].y) / 2,
1238
- }
1239
- }
1240
-
1241
- // For polylines, find the middle segment and get its midpoint
1242
- const midIndex = Math.floor(points.length / 2)
1243
- if (midIndex > 0 && midIndex < points.length) {
1244
- return {
1245
- x: (points[midIndex - 1].x + points[midIndex].x) / 2,
1246
- y: (points[midIndex - 1].y + points[midIndex].y) / 2,
1247
- }
1248
- }
1249
-
1250
- return points[midIndex] || points[0]
1251
- }
1252
-
1253
- private escapeXml(str: string): string {
1254
- return str
1255
- .replace(/&/g, '&amp;')
1256
- .replace(/</g, '&lt;')
1257
- .replace(/>/g, '&gt;')
1258
- .replace(/"/g, '&quot;')
1259
- .replace(/'/g, '&#39;')
1260
- }
1261
-
1262
- /**
1263
- * Simple string hash for consistent but varied label placement
1264
- */
1265
- private hashString(str: string): number {
1266
- let hash = 0
1267
- for (let i = 0; i < str.length; i++) {
1268
- const char = str.charCodeAt(i)
1269
- hash = ((hash << 5) - hash) + char
1270
- hash = hash & hash // Convert to 32-bit integer
1271
- }
1272
- return Math.abs(hash)
1273
- }
1274
- }
1275
-
1276
- // Default instance
1277
- export const svgRenderer = new SVGRenderer()