@shumoku/core 0.2.1 → 0.2.3

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