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