@shumoku/core 0.2.0 → 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.
Files changed (60) hide show
  1. package/dist/icons/build-icons.js +3 -3
  2. package/dist/icons/build-icons.js.map +1 -1
  3. package/dist/icons/generated-icons.js +10 -10
  4. package/dist/icons/generated-icons.js.map +1 -1
  5. package/dist/index.d.ts +3 -4
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +6 -8
  8. package/dist/index.js.map +1 -1
  9. package/dist/layout/hierarchical.d.ts +1 -1
  10. package/dist/layout/hierarchical.d.ts.map +1 -1
  11. package/dist/layout/hierarchical.js +82 -66
  12. package/dist/layout/hierarchical.js.map +1 -1
  13. package/dist/layout/index.d.ts +1 -1
  14. package/dist/layout/index.d.ts.map +1 -1
  15. package/dist/layout/index.js.map +1 -1
  16. package/dist/models/types.d.ts.map +1 -1
  17. package/dist/models/types.js +13 -13
  18. package/dist/models/types.js.map +1 -1
  19. package/dist/renderer/index.d.ts +4 -3
  20. package/dist/renderer/index.d.ts.map +1 -1
  21. package/dist/renderer/index.js +3 -2
  22. package/dist/renderer/index.js.map +1 -1
  23. package/dist/renderer/svg.d.ts +26 -4
  24. package/dist/renderer/svg.d.ts.map +1 -1
  25. package/dist/renderer/svg.js +286 -147
  26. package/dist/renderer/svg.js.map +1 -1
  27. package/dist/renderer/types.d.ts +70 -0
  28. package/dist/renderer/types.d.ts.map +1 -0
  29. package/dist/renderer/types.js +6 -0
  30. package/dist/renderer/types.js.map +1 -0
  31. package/dist/themes/dark.d.ts.map +1 -1
  32. package/dist/themes/dark.js +1 -1
  33. package/dist/themes/dark.js.map +1 -1
  34. package/dist/themes/index.d.ts +3 -3
  35. package/dist/themes/index.d.ts.map +1 -1
  36. package/dist/themes/index.js +4 -4
  37. package/dist/themes/index.js.map +1 -1
  38. package/dist/themes/modern.d.ts.map +1 -1
  39. package/dist/themes/modern.js.map +1 -1
  40. package/dist/themes/types.d.ts.map +1 -1
  41. package/dist/themes/utils.d.ts +1 -1
  42. package/dist/themes/utils.d.ts.map +1 -1
  43. package/dist/themes/utils.js +5 -4
  44. package/dist/themes/utils.js.map +1 -1
  45. package/package.json +88 -92
  46. package/src/constants.ts +35 -35
  47. package/src/icons/build-icons.ts +12 -6
  48. package/src/icons/generated-icons.ts +12 -12
  49. package/src/index.test.ts +66 -0
  50. package/src/index.ts +6 -13
  51. package/src/layout/hierarchical.ts +1251 -1221
  52. package/src/layout/index.ts +1 -1
  53. package/src/models/types.ts +47 -37
  54. package/src/themes/dark.ts +15 -15
  55. package/src/themes/index.ts +7 -7
  56. package/src/themes/modern.ts +22 -22
  57. package/src/themes/types.ts +26 -26
  58. package/src/themes/utils.ts +25 -24
  59. package/src/renderer/index.ts +0 -6
  60. package/src/renderer/svg.ts +0 -1277
@@ -2,8 +2,8 @@
2
2
  * SVG Renderer
3
3
  * Renders NetworkGraph to SVG
4
4
  */
5
- import { getDeviceIcon, getVendorIconEntry } from '../icons/index.js';
6
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
7
  const LIGHT_THEME = {
8
8
  backgroundColor: '#ffffff',
9
9
  defaultNodeFill: '#e2e8f0',
@@ -41,6 +41,8 @@ const DARK_THEME = {
41
41
  const DEFAULT_OPTIONS = {
42
42
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
43
43
  interactive: true,
44
+ renderMode: 'static',
45
+ dataAttributes: { device: true, link: true, metadata: true },
44
46
  };
45
47
  // ============================================
46
48
  // SVG Renderer
@@ -52,6 +54,18 @@ export class SVGRenderer {
52
54
  constructor(options) {
53
55
  this.options = { ...DEFAULT_OPTIONS, ...options };
54
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
+ }
55
69
  /**
56
70
  * Get theme colors based on theme type
57
71
  */
@@ -96,21 +110,24 @@ export class SVGRenderer {
96
110
  // Styles
97
111
  parts.push(this.renderStyles());
98
112
  // Layer 1: Subgraphs (background)
99
- layout.subgraphs.forEach((sg) => {
113
+ for (const sg of layout.subgraphs.values()) {
100
114
  parts.push(this.renderSubgraph(sg));
101
- });
102
- // Layer 2: Node backgrounds (shapes)
103
- layout.nodes.forEach((node) => {
104
- parts.push(this.renderNodeBackground(node));
105
- });
106
- // Layer 3: Links (on top of node backgrounds)
107
- layout.links.forEach((link) => {
115
+ }
116
+ // Layer 2: Links (below nodes)
117
+ for (const link of layout.links.values()) {
108
118
  parts.push(this.renderLink(link, layout.nodes));
109
- });
110
- // Layer 4: Node foregrounds (content + ports, on top of links)
111
- layout.nodes.forEach((node) => {
112
- parts.push(this.renderNodeForeground(node));
113
- });
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
+ }
114
131
  // Legend (if enabled) - use already calculated legendSettings
115
132
  if (legendSettings.enabled && legendWidth > 0) {
116
133
  parts.push(this.renderLegend(graph, layout, legendSettings));
@@ -131,10 +148,10 @@ export class SVGRenderer {
131
148
  let itemCount = 0;
132
149
  if (settings.showBandwidth) {
133
150
  const usedBandwidths = new Set();
134
- graph.links.forEach(link => {
151
+ for (const link of graph.links) {
135
152
  if (link.bandwidth)
136
153
  usedBandwidths.add(link.bandwidth);
137
- });
154
+ }
138
155
  itemCount += usedBandwidths.size;
139
156
  }
140
157
  if (itemCount === 0) {
@@ -181,19 +198,19 @@ export class SVGRenderer {
181
198
  const maxLabelWidth = 100;
182
199
  // Collect used bandwidths
183
200
  const usedBandwidths = new Set();
184
- graph.links.forEach(link => {
201
+ for (const link of graph.links) {
185
202
  if (link.bandwidth)
186
203
  usedBandwidths.add(link.bandwidth);
187
- });
204
+ }
188
205
  // Collect used device types
189
206
  const usedDeviceTypes = new Set();
190
- graph.nodes.forEach(node => {
207
+ for (const node of graph.nodes) {
191
208
  if (node.type)
192
209
  usedDeviceTypes.add(node.type);
193
- });
210
+ }
194
211
  // Build legend items
195
212
  if (settings.showBandwidth && usedBandwidths.size > 0) {
196
- const sortedBandwidths = ['1G', '10G', '25G', '40G', '100G'].filter(b => usedBandwidths.has(b));
213
+ const sortedBandwidths = ['1G', '10G', '25G', '40G', '100G'].filter((b) => usedBandwidths.has(b));
197
214
  for (const bw of sortedBandwidths) {
198
215
  const config = this.getBandwidthConfig(bw);
199
216
  items.push({
@@ -226,18 +243,18 @@ export class SVGRenderer {
226
243
  break;
227
244
  }
228
245
  // Render legend box
229
- let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
230
- <rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
231
- fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
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" />
232
249
  <text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`;
233
250
  // Render items
234
- items.forEach((item, index) => {
251
+ for (const [index, item] of items.entries()) {
235
252
  const y = padding + 28 + index * lineHeight;
236
253
  svg += `\n <g transform="translate(${padding}, ${y})">`;
237
254
  svg += `\n ${item.icon}`;
238
255
  svg += `\n <text x="${iconWidth + 4}" y="4" class="node-label" font-size="10">${this.escapeXml(item.label)}</text>`;
239
- svg += `\n </g>`;
240
- });
256
+ svg += '\n </g>';
257
+ }
241
258
  svg += '\n</g>';
242
259
  return svg;
243
260
  }
@@ -249,46 +266,59 @@ export class SVGRenderer {
249
266
  const lineWidth = 24;
250
267
  const strokeWidth = 2;
251
268
  const offsets = this.calculateLineOffsets(lineCount, lineSpacing);
252
- const lines = offsets.map(offset => {
269
+ const lines = offsets.map((offset) => {
253
270
  const y = offset;
254
271
  return `<line x1="0" y1="${y}" x2="${lineWidth}" y2="${y}" stroke="${this.themeColors.defaultLinkStroke}" stroke-width="${strokeWidth}" />`;
255
272
  });
256
273
  return `<g transform="translate(0, 0)">${lines.join('')}</g>`;
257
274
  }
258
275
  renderHeader(width, height, viewBox) {
259
- return `<svg xmlns="http://www.w3.org/2000/svg"
260
- viewBox="${viewBox}"
261
- width="${width}"
262
- height="${height}"
276
+ return `<svg xmlns="http://www.w3.org/2000/svg"
277
+ viewBox="${viewBox}"
278
+ width="${width}"
279
+ height="${height}"
263
280
  style="background: ${this.themeColors.backgroundColor}">`;
264
281
  }
265
282
  renderDefs() {
266
- return `<defs>
267
- <!-- Arrow marker -->
268
- <marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
269
- <polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
270
- </marker>
271
- <marker id="arrow-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
272
- <polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
273
- </marker>
274
-
275
- <!-- Filters -->
276
- <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
277
- <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
278
- </filter>
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>
279
296
  </defs>`;
280
297
  }
281
298
  renderStyles() {
282
- return `<style>
283
- .node { cursor: pointer; }
284
- .node:hover rect, .node:hover circle, .node:hover polygon { filter: brightness(0.95); }
285
- .node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
286
- .node-label-bold { font-weight: bold; }
287
- .node-icon { color: ${this.themeColors.labelSecondaryColor}; }
288
- .subgraph-icon { opacity: 0.9; }
289
- .subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
290
- .link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
291
- .endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
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}; }
292
322
  </style>`;
293
323
  }
294
324
  renderSubgraph(sg) {
@@ -327,37 +357,48 @@ export class SVGRenderer {
327
357
  if (iconContent.startsWith('<svg')) {
328
358
  const viewBoxMatch = iconContent.match(/viewBox="0 0 (\d+) (\d+)"/);
329
359
  if (viewBoxMatch) {
330
- const vbWidth = parseInt(viewBoxMatch[1]);
331
- const vbHeight = parseInt(viewBoxMatch[2]);
360
+ const vbWidth = Number.parseInt(viewBoxMatch[1], 10);
361
+ const vbHeight = Number.parseInt(viewBoxMatch[2], 10);
332
362
  const aspectRatio = vbWidth / vbHeight;
333
363
  const iconWidth = Math.round(iconSize * aspectRatio);
334
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
335
- <svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
336
- ${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
337
- </svg>
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>
338
368
  </g>`;
339
369
  }
340
370
  }
341
371
  else {
342
372
  // Use viewBox from entry
343
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
344
- <svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
345
- ${iconContent}
346
- </svg>
373
+ iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
374
+ <svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
375
+ ${iconContent}
376
+ </svg>
347
377
  </g>`;
348
378
  }
349
379
  }
350
380
  }
351
- return `<g class="subgraph" data-id="${sg.id}">
352
- <rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
353
- rx="${rx}" ry="${rx}"
354
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
355
- ${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
356
- ${iconSvg}
357
- <text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
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>
358
388
  </g>`;
359
389
  }
360
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
+ }
361
402
  renderNodeBackground(layoutNode) {
362
403
  const { id, position, size, node } = layoutNode;
363
404
  const x = position.x;
@@ -370,36 +411,72 @@ export class SVGRenderer {
370
411
  const strokeWidth = style.strokeWidth || 1;
371
412
  const strokeDasharray = style.strokeDasharray || '';
372
413
  const shape = this.renderNodeShape(node.shape, x, y, w, h, fill, stroke, strokeWidth, strokeDasharray);
373
- return `<g class="node-bg" data-id="${id}">${shape}</g>`;
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>`;
374
417
  }
375
- /** Render node foreground (content + ports) */
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) */
376
450
  renderNodeForeground(layoutNode) {
377
- const { id, position, size, node, ports } = layoutNode;
451
+ const { id, position, size, node } = layoutNode;
378
452
  const x = position.x;
379
453
  const y = position.y;
380
454
  const w = size.width;
381
455
  const content = this.renderNodeContent(node, x, y, w);
382
- const portsRendered = this.renderPorts(x, y, ports);
383
- return `<g class="node-fg" data-id="${id}">
384
- ${content}
385
- ${portsRendered}
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}
386
460
  </g>`;
387
461
  }
388
462
  /**
389
- * Render ports on a node
463
+ * Render ports on a node (as separate groups)
390
464
  */
391
- renderPorts(nodeX, nodeY, ports) {
465
+ renderPorts(nodeId, nodeX, nodeY, ports) {
392
466
  if (!ports || ports.size === 0)
393
467
  return '';
394
- const parts = [];
395
- ports.forEach((port) => {
468
+ const groups = [];
469
+ for (const port of ports.values()) {
396
470
  const px = nodeX + port.position.x;
397
471
  const py = nodeY + port.position.y;
398
472
  const pw = port.size.width;
399
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 = [];
400
477
  // Port box
401
- parts.push(`<rect class="port" data-port="${port.id}"
402
- x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
478
+ parts.push(`<rect class="port-box"
479
+ x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
403
480
  fill="${this.themeColors.portFill}" stroke="${this.themeColors.portStroke}" stroke-width="1" rx="2" />`);
404
481
  // Port label - position based on side
405
482
  let labelX = px;
@@ -438,8 +515,12 @@ export class SVGRenderer {
438
515
  const bgY = labelY - labelHeight + 3;
439
516
  parts.push(`<rect class="port-label-bg" x="${bgX}" y="${bgY}" width="${labelWidth}" height="${labelHeight}" rx="2" fill="${this.themeColors.portLabelBg}" />`);
440
517
  parts.push(`<text class="port-label" x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="9" fill="${this.themeColors.portLabelColor}">${labelText}</text>`);
441
- });
442
- return parts.join('\n ');
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');
443
524
  }
444
525
  renderNodeShape(shape, x, y, w, h, fill, stroke, strokeWidth, strokeDasharray) {
445
526
  const dashAttr = strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : '';
@@ -447,40 +528,44 @@ export class SVGRenderer {
447
528
  const halfH = h / 2;
448
529
  switch (shape) {
449
530
  case 'rect':
450
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
531
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
451
532
  fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
452
533
  case 'rounded':
453
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
534
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
454
535
  fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
455
- case 'circle':
536
+ case 'circle': {
456
537
  const r = Math.min(halfW, halfH);
457
- return `<circle cx="${x}" cy="${y}" r="${r}"
538
+ return `<circle cx="${x}" cy="${y}" r="${r}"
458
539
  fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
540
+ }
459
541
  case 'diamond':
460
- return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
542
+ return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
461
543
  fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
462
- case 'hexagon':
544
+ case 'hexagon': {
463
545
  const hx = halfW * 0.866;
464
- 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
+ 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}"
465
547
  fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
466
- case 'cylinder':
548
+ }
549
+ case 'cylinder': {
467
550
  const ellipseH = h * 0.15;
468
- return `<g>
469
- <ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
470
- <rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
471
- <line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
472
- <line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
473
- <ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />
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)" />
474
557
  </g>`;
558
+ }
475
559
  case 'stadium':
476
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
560
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
477
561
  fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
478
- case 'trapezoid':
562
+ case 'trapezoid': {
479
563
  const indent = w * 0.15;
480
- return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
564
+ return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
481
565
  fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
566
+ }
482
567
  default:
483
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
568
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
484
569
  fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
485
570
  }
486
571
  }
@@ -501,8 +586,8 @@ export class SVGRenderer {
501
586
  if (vendorIcon.startsWith('<svg')) {
502
587
  const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/);
503
588
  if (viewBoxMatch) {
504
- const vbWidth = parseInt(viewBoxMatch[1]);
505
- const vbHeight = parseInt(viewBoxMatch[2]);
589
+ const vbWidth = Number.parseInt(viewBoxMatch[1], 10);
590
+ const vbHeight = Number.parseInt(viewBoxMatch[2], 10);
506
591
  const aspectRatio = vbWidth / vbHeight;
507
592
  let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio);
508
593
  let iconHeight = DEFAULT_ICON_SIZE;
@@ -521,10 +606,12 @@ export class SVGRenderer {
521
606
  // Parse viewBox for aspect ratio calculation
522
607
  const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
523
608
  if (vbMatch) {
524
- const vbWidth = parseInt(vbMatch[3]);
525
- const vbHeight = parseInt(vbMatch[4]);
609
+ const vbWidth = Number.parseInt(vbMatch[3], 10);
610
+ const vbHeight = Number.parseInt(vbMatch[4], 10);
526
611
  const aspectRatio = vbWidth / vbHeight;
527
- let iconWidth = Math.abs(aspectRatio - 1) < 0.01 ? DEFAULT_ICON_SIZE : Math.round(DEFAULT_ICON_SIZE * aspectRatio);
612
+ let iconWidth = Math.abs(aspectRatio - 1) < 0.01
613
+ ? DEFAULT_ICON_SIZE
614
+ : Math.round(DEFAULT_ICON_SIZE * aspectRatio);
528
615
  let iconHeight = DEFAULT_ICON_SIZE;
529
616
  if (iconWidth > maxIconWidth) {
530
617
  iconWidth = maxIconWidth;
@@ -571,18 +658,18 @@ export class SVGRenderer {
571
658
  // Render icon at top of content block
572
659
  if (iconInfo) {
573
660
  const iconY = contentTop;
574
- parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
575
- ${iconInfo.svg}
661
+ parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
662
+ ${iconInfo.svg}
576
663
  </g>`);
577
664
  }
578
665
  // Render labels below icon
579
666
  const labelStartY = contentTop + iconHeight + gap + LABEL_LINE_HEIGHT * 0.7; // 0.7 for text baseline adjustment
580
- labels.forEach((line, i) => {
667
+ for (const [i, line] of labels.entries()) {
581
668
  const isBold = line.includes('<b>') || line.includes('<strong>');
582
669
  const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '');
583
670
  const className = isBold ? 'node-label node-label-bold' : 'node-label';
584
671
  parts.push(`<text x="${x}" y="${labelStartY + i * LABEL_LINE_HEIGHT}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`);
585
- });
672
+ }
586
673
  return parts.join('\n ');
587
674
  }
588
675
  renderLink(layoutLink, nodes) {
@@ -609,9 +696,7 @@ export class SVGRenderer {
609
696
  }
610
697
  // VLANs (link-level, applies to both endpoints)
611
698
  if (link.vlan && link.vlan.length > 0) {
612
- const vlanText = link.vlan.length === 1
613
- ? `VLAN ${link.vlan[0]}`
614
- : `VLAN ${link.vlan.join(', ')}`;
699
+ const vlanText = link.vlan.length === 1 ? `VLAN ${link.vlan[0]}` : `VLAN ${link.vlan.join(', ')}`;
615
700
  result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(vlanText)}</text>`;
616
701
  }
617
702
  // Get node center positions for label placement
@@ -632,7 +717,45 @@ export class SVGRenderer {
632
717
  const labelPos = this.getEndpointLabelPosition(points, 'end', toNodeCenterX, portName);
633
718
  result += this.renderEndpointLabels(toLabels, labelPos.x, labelPos.y, labelPos.anchor);
634
719
  }
635
- return `<g class="link-group">\n${result}\n</g>`;
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(' ')}` : '';
636
759
  }
637
760
  formatEndpointLabels(endpoint) {
638
761
  const parts = [];
@@ -722,7 +845,7 @@ export class SVGRenderer {
722
845
  const paddingY = 2;
723
846
  const charWidth = 4.8; // Approximate character width for 9px font
724
847
  // Calculate dimensions
725
- const maxLen = Math.max(...lines.map(l => l.length));
848
+ const maxLen = Math.max(...lines.map((l) => l.length));
726
849
  const rectWidth = maxLen * charWidth + paddingX * 2;
727
850
  const rectHeight = lines.length * lineHeight + paddingY * 2;
728
851
  // Adjust rect position based on text anchor
@@ -735,17 +858,20 @@ export class SVGRenderer {
735
858
  }
736
859
  const rectY = y - lineHeight + paddingY;
737
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" />`;
738
- lines.forEach((line, i) => {
861
+ for (const [i, line] of lines.entries()) {
739
862
  const textY = y + i * lineHeight;
740
863
  result += `\n<text x="${x}" y="${textY}" class="endpoint-label" text-anchor="${anchor}">${this.escapeXml(line)}</text>`;
741
- });
864
+ }
742
865
  return result;
743
866
  }
744
867
  getLinkStrokeWidth(type) {
745
868
  switch (type) {
746
- case 'thick': return 3;
747
- case 'double': return 2;
748
- default: return 2;
869
+ case 'thick':
870
+ return 3;
871
+ case 'double':
872
+ return 2;
873
+ default:
874
+ return 2;
749
875
  }
750
876
  }
751
877
  /**
@@ -779,32 +905,42 @@ export class SVGRenderer {
779
905
  renderBandwidthLines(id, points, stroke, strokeWidth, dasharray, markerEnd, config, type) {
780
906
  const { lineCount } = config;
781
907
  const lineSpacing = 3; // Space between parallel lines
908
+ // Generate line paths
909
+ const lines = [];
910
+ const basePath = this.generatePath(points);
782
911
  if (lineCount === 1) {
783
912
  // Single line
784
- const path = this.generatePath(points);
785
- let result = `<path class="link" data-id="${id}" d="${path}"
786
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
787
- ${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
788
- ${markerEnd ? `marker-end="${markerEnd}"` : ''} />`;
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" />`;
789
917
  // Double line effect for redundancy types
790
918
  if (type === 'double') {
791
- result = `<path class="link-double-outer" d="${path}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" />
792
- <path class="link-double-inner" d="${path}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" />
793
- ${result}`;
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}`;
794
922
  }
795
- return result;
923
+ lines.push(linePath);
796
924
  }
797
- // Multiple parallel lines
798
- const paths = [];
799
- const offsets = this.calculateLineOffsets(lineCount, lineSpacing);
800
- for (const offset of offsets) {
801
- const offsetPoints = this.offsetPoints(points, offset);
802
- const path = this.generatePath(offsetPoints);
803
- paths.push(`<path class="link" data-id="${id}" d="${path}"
804
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
805
- ${dasharray ? `stroke-dasharray="${dasharray}"` : ''} />`);
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
+ }
806
935
  }
807
- return paths.join('\n');
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>`;
808
944
  }
809
945
  /**
810
946
  * Generate SVG path string from points with rounded corners
@@ -968,9 +1104,12 @@ ${result}`;
968
1104
  }
969
1105
  getLinkDasharray(type) {
970
1106
  switch (type) {
971
- case 'dashed': return '5 3';
972
- case 'invisible': return '0';
973
- default: return '';
1107
+ case 'dashed':
1108
+ return '5 3';
1109
+ case 'invisible':
1110
+ return '0';
1111
+ default:
1112
+ return '';
974
1113
  }
975
1114
  }
976
1115
  getMidPoint(points) {
@@ -1020,7 +1159,7 @@ ${result}`;
1020
1159
  let hash = 0;
1021
1160
  for (let i = 0; i < str.length; i++) {
1022
1161
  const char = str.charCodeAt(i);
1023
- hash = ((hash << 5) - hash) + char;
1162
+ hash = (hash << 5) - hash + char;
1024
1163
  hash = hash & hash; // Convert to 32-bit integer
1025
1164
  }
1026
1165
  return Math.abs(hash);