@shumoku/core 0.1.0 → 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.
Files changed (158) hide show
  1. package/README.md +56 -0
  2. package/dist/constants.d.ts +23 -0
  3. package/dist/constants.d.ts.map +1 -0
  4. package/dist/constants.js +25 -0
  5. package/dist/constants.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/layout/hierarchical.d.ts +12 -39
  11. package/dist/layout/hierarchical.d.ts.map +1 -1
  12. package/dist/layout/hierarchical.js +697 -1015
  13. package/dist/layout/hierarchical.js.map +1 -1
  14. package/dist/models/types.d.ts +30 -0
  15. package/dist/models/types.d.ts.map +1 -1
  16. package/dist/models/types.js.map +1 -1
  17. package/dist/renderer/svg.d.ts +31 -5
  18. package/dist/renderer/svg.d.ts.map +1 -1
  19. package/dist/renderer/svg.js +312 -85
  20. package/dist/renderer/svg.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/constants.ts +35 -0
  23. package/src/index.ts +3 -0
  24. package/src/layout/hierarchical.ts +805 -1127
  25. package/src/models/types.ts +37 -0
  26. package/src/renderer/svg.ts +368 -88
  27. package/dist/renderer/components/index.d.ts +0 -8
  28. package/dist/renderer/components/index.d.ts.map +0 -1
  29. package/dist/renderer/components/index.js +0 -8
  30. package/dist/renderer/components/index.js.map +0 -1
  31. package/dist/renderer/components/link-renderer.d.ts +0 -11
  32. package/dist/renderer/components/link-renderer.d.ts.map +0 -1
  33. package/dist/renderer/components/link-renderer.js +0 -340
  34. package/dist/renderer/components/link-renderer.js.map +0 -1
  35. package/dist/renderer/components/node-renderer.d.ts +0 -14
  36. package/dist/renderer/components/node-renderer.d.ts.map +0 -1
  37. package/dist/renderer/components/node-renderer.js +0 -242
  38. package/dist/renderer/components/node-renderer.js.map +0 -1
  39. package/dist/renderer/components/port-renderer.d.ts +0 -8
  40. package/dist/renderer/components/port-renderer.d.ts.map +0 -1
  41. package/dist/renderer/components/port-renderer.js +0 -85
  42. package/dist/renderer/components/port-renderer.js.map +0 -1
  43. package/dist/renderer/components/subgraph-renderer.d.ts +0 -13
  44. package/dist/renderer/components/subgraph-renderer.d.ts.map +0 -1
  45. package/dist/renderer/components/subgraph-renderer.js +0 -85
  46. package/dist/renderer/components/subgraph-renderer.js.map +0 -1
  47. package/dist/renderer/icon-registry/index.d.ts +0 -6
  48. package/dist/renderer/icon-registry/index.d.ts.map +0 -1
  49. package/dist/renderer/icon-registry/index.js +0 -5
  50. package/dist/renderer/icon-registry/index.js.map +0 -1
  51. package/dist/renderer/icon-registry/registry.d.ts +0 -25
  52. package/dist/renderer/icon-registry/registry.d.ts.map +0 -1
  53. package/dist/renderer/icon-registry/registry.js +0 -85
  54. package/dist/renderer/icon-registry/registry.js.map +0 -1
  55. package/dist/renderer/icon-registry/types.d.ts +0 -44
  56. package/dist/renderer/icon-registry/types.d.ts.map +0 -1
  57. package/dist/renderer/icon-registry/types.js +0 -5
  58. package/dist/renderer/icon-registry/types.js.map +0 -1
  59. package/dist/renderer/render-model/builder.d.ts +0 -43
  60. package/dist/renderer/render-model/builder.d.ts.map +0 -1
  61. package/dist/renderer/render-model/builder.js +0 -646
  62. package/dist/renderer/render-model/builder.js.map +0 -1
  63. package/dist/renderer/render-model/index.d.ts +0 -6
  64. package/dist/renderer/render-model/index.d.ts.map +0 -1
  65. package/dist/renderer/render-model/index.js +0 -5
  66. package/dist/renderer/render-model/index.js.map +0 -1
  67. package/dist/renderer/render-model/types.d.ts +0 -216
  68. package/dist/renderer/render-model/types.d.ts.map +0 -1
  69. package/dist/renderer/render-model/types.js +0 -6
  70. package/dist/renderer/render-model/types.js.map +0 -1
  71. package/dist/renderer/renderer-types.d.ts +0 -55
  72. package/dist/renderer/renderer-types.d.ts.map +0 -1
  73. package/dist/renderer/renderer-types.js +0 -5
  74. package/dist/renderer/renderer-types.js.map +0 -1
  75. package/dist/renderer/svg-builder.d.ts +0 -152
  76. package/dist/renderer/svg-builder.d.ts.map +0 -1
  77. package/dist/renderer/svg-builder.js +0 -176
  78. package/dist/renderer/svg-builder.js.map +0 -1
  79. package/dist/renderer/svg-dom/builders/defs.d.ts +0 -10
  80. package/dist/renderer/svg-dom/builders/defs.d.ts.map +0 -1
  81. package/dist/renderer/svg-dom/builders/defs.js +0 -82
  82. package/dist/renderer/svg-dom/builders/defs.js.map +0 -1
  83. package/dist/renderer/svg-dom/builders/index.d.ts +0 -9
  84. package/dist/renderer/svg-dom/builders/index.d.ts.map +0 -1
  85. package/dist/renderer/svg-dom/builders/index.js +0 -9
  86. package/dist/renderer/svg-dom/builders/index.js.map +0 -1
  87. package/dist/renderer/svg-dom/builders/link.d.ts +0 -18
  88. package/dist/renderer/svg-dom/builders/link.d.ts.map +0 -1
  89. package/dist/renderer/svg-dom/builders/link.js +0 -188
  90. package/dist/renderer/svg-dom/builders/link.js.map +0 -1
  91. package/dist/renderer/svg-dom/builders/node.d.ts +0 -15
  92. package/dist/renderer/svg-dom/builders/node.d.ts.map +0 -1
  93. package/dist/renderer/svg-dom/builders/node.js +0 -262
  94. package/dist/renderer/svg-dom/builders/node.js.map +0 -1
  95. package/dist/renderer/svg-dom/builders/subgraph.d.ts +0 -14
  96. package/dist/renderer/svg-dom/builders/subgraph.d.ts.map +0 -1
  97. package/dist/renderer/svg-dom/builders/subgraph.js +0 -63
  98. package/dist/renderer/svg-dom/builders/subgraph.js.map +0 -1
  99. package/dist/renderer/svg-dom/builders/utils.d.ts +0 -40
  100. package/dist/renderer/svg-dom/builders/utils.d.ts.map +0 -1
  101. package/dist/renderer/svg-dom/builders/utils.js +0 -79
  102. package/dist/renderer/svg-dom/builders/utils.js.map +0 -1
  103. package/dist/renderer/svg-dom/index.d.ts +0 -9
  104. package/dist/renderer/svg-dom/index.d.ts.map +0 -1
  105. package/dist/renderer/svg-dom/index.js +0 -7
  106. package/dist/renderer/svg-dom/index.js.map +0 -1
  107. package/dist/renderer/svg-dom/interaction.d.ts +0 -69
  108. package/dist/renderer/svg-dom/interaction.d.ts.map +0 -1
  109. package/dist/renderer/svg-dom/interaction.js +0 -296
  110. package/dist/renderer/svg-dom/interaction.js.map +0 -1
  111. package/dist/renderer/svg-dom/renderer.d.ts +0 -47
  112. package/dist/renderer/svg-dom/renderer.d.ts.map +0 -1
  113. package/dist/renderer/svg-dom/renderer.js +0 -188
  114. package/dist/renderer/svg-dom/renderer.js.map +0 -1
  115. package/dist/renderer/svg-string/builders/defs.d.ts +0 -10
  116. package/dist/renderer/svg-string/builders/defs.d.ts.map +0 -1
  117. package/dist/renderer/svg-string/builders/defs.js +0 -43
  118. package/dist/renderer/svg-string/builders/defs.js.map +0 -1
  119. package/dist/renderer/svg-string/builders/link.d.ts +0 -10
  120. package/dist/renderer/svg-string/builders/link.d.ts.map +0 -1
  121. package/dist/renderer/svg-string/builders/link.js +0 -149
  122. package/dist/renderer/svg-string/builders/link.js.map +0 -1
  123. package/dist/renderer/svg-string/builders/node.d.ts +0 -10
  124. package/dist/renderer/svg-string/builders/node.d.ts.map +0 -1
  125. package/dist/renderer/svg-string/builders/node.js +0 -134
  126. package/dist/renderer/svg-string/builders/node.js.map +0 -1
  127. package/dist/renderer/svg-string/builders/subgraph.d.ts +0 -10
  128. package/dist/renderer/svg-string/builders/subgraph.d.ts.map +0 -1
  129. package/dist/renderer/svg-string/builders/subgraph.js +0 -59
  130. package/dist/renderer/svg-string/builders/subgraph.js.map +0 -1
  131. package/dist/renderer/svg-string/index.d.ts +0 -5
  132. package/dist/renderer/svg-string/index.d.ts.map +0 -1
  133. package/dist/renderer/svg-string/index.js +0 -5
  134. package/dist/renderer/svg-string/index.js.map +0 -1
  135. package/dist/renderer/svg-string/renderer.d.ts +0 -17
  136. package/dist/renderer/svg-string/renderer.d.ts.map +0 -1
  137. package/dist/renderer/svg-string/renderer.js +0 -53
  138. package/dist/renderer/svg-string/renderer.js.map +0 -1
  139. package/dist/renderer/text-measurer/browser-measurer.d.ts +0 -25
  140. package/dist/renderer/text-measurer/browser-measurer.d.ts.map +0 -1
  141. package/dist/renderer/text-measurer/browser-measurer.js +0 -85
  142. package/dist/renderer/text-measurer/browser-measurer.js.map +0 -1
  143. package/dist/renderer/text-measurer/fallback-measurer.d.ts +0 -22
  144. package/dist/renderer/text-measurer/fallback-measurer.d.ts.map +0 -1
  145. package/dist/renderer/text-measurer/fallback-measurer.js +0 -113
  146. package/dist/renderer/text-measurer/fallback-measurer.js.map +0 -1
  147. package/dist/renderer/text-measurer/index.d.ts +0 -13
  148. package/dist/renderer/text-measurer/index.d.ts.map +0 -1
  149. package/dist/renderer/text-measurer/index.js +0 -35
  150. package/dist/renderer/text-measurer/index.js.map +0 -1
  151. package/dist/renderer/text-measurer/types.d.ts +0 -30
  152. package/dist/renderer/text-measurer/types.d.ts.map +0 -1
  153. package/dist/renderer/text-measurer/types.js +0 -5
  154. package/dist/renderer/text-measurer/types.js.map +0 -1
  155. package/dist/renderer/theme.d.ts +0 -29
  156. package/dist/renderer/theme.d.ts.map +0 -1
  157. package/dist/renderer/theme.js +0 -80
  158. package/dist/renderer/theme.js.map +0 -1
@@ -3,6 +3,7 @@
3
3
  * Renders NetworkGraph to SVG
4
4
  */
5
5
  import { getDeviceIcon, getVendorIconEntry } from '../icons/index.js';
6
+ import { DEFAULT_ICON_SIZE, ICON_LABEL_GAP, LABEL_LINE_HEIGHT, MAX_ICON_WIDTH_RATIO, } from '../constants.js';
6
7
  const LIGHT_THEME = {
7
8
  backgroundColor: '#ffffff',
8
9
  defaultNodeFill: '#e2e8f0',
@@ -69,30 +70,191 @@ export class SVGRenderer {
69
70
  const theme = graph.settings?.theme;
70
71
  this.themeColors = this.getThemeColors(theme);
71
72
  this.iconTheme = this.getIconTheme(theme);
73
+ // Calculate legend dimensions if enabled
74
+ const legendSettings = this.getLegendSettings(graph.settings?.legend);
75
+ let legendWidth = 0;
76
+ let legendHeight = 0;
77
+ if (legendSettings.enabled) {
78
+ const legendDims = this.calculateLegendDimensions(graph, legendSettings);
79
+ legendWidth = legendDims.width;
80
+ legendHeight = legendDims.height;
81
+ }
82
+ // Expand bounds to include legend with padding
83
+ const legendPadding = 20;
84
+ const expandedBounds = {
85
+ x: bounds.x,
86
+ y: bounds.y,
87
+ width: bounds.width + (legendSettings.enabled && legendWidth > 0 ? legendPadding : 0),
88
+ height: bounds.height + (legendSettings.enabled && legendHeight > 0 ? legendPadding : 0),
89
+ };
72
90
  const parts = [];
73
- // SVG header using content bounds
74
- const viewBox = `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`;
75
- parts.push(this.renderHeader(bounds.width, bounds.height, viewBox));
91
+ // SVG header using expanded bounds
92
+ const viewBox = `${expandedBounds.x} ${expandedBounds.y} ${expandedBounds.width} ${expandedBounds.height}`;
93
+ parts.push(this.renderHeader(expandedBounds.width, expandedBounds.height, viewBox));
76
94
  // Defs (markers, gradients)
77
95
  parts.push(this.renderDefs());
78
96
  // Styles
79
97
  parts.push(this.renderStyles());
80
- // Subgraphs (background, render first)
98
+ // Layer 1: Subgraphs (background)
81
99
  layout.subgraphs.forEach((sg) => {
82
100
  parts.push(this.renderSubgraph(sg));
83
101
  });
84
- // Links
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)
85
107
  layout.links.forEach((link) => {
86
108
  parts.push(this.renderLink(link, layout.nodes));
87
109
  });
88
- // Nodes
110
+ // Layer 4: Node foregrounds (content + ports, on top of links)
89
111
  layout.nodes.forEach((node) => {
90
- parts.push(this.renderNode(node));
112
+ parts.push(this.renderNodeForeground(node));
91
113
  });
114
+ // Legend (if enabled) - use already calculated legendSettings
115
+ if (legendSettings.enabled && legendWidth > 0) {
116
+ parts.push(this.renderLegend(graph, layout, legendSettings));
117
+ }
92
118
  // Close SVG
93
119
  parts.push('</svg>');
94
120
  return parts.join('\n');
95
121
  }
122
+ /**
123
+ * Calculate legend dimensions without rendering
124
+ */
125
+ calculateLegendDimensions(graph, settings) {
126
+ const lineHeight = 20;
127
+ const padding = 12;
128
+ const iconWidth = 30;
129
+ const maxLabelWidth = 100;
130
+ // Count items
131
+ let itemCount = 0;
132
+ if (settings.showBandwidth) {
133
+ const usedBandwidths = new Set();
134
+ graph.links.forEach(link => {
135
+ if (link.bandwidth)
136
+ usedBandwidths.add(link.bandwidth);
137
+ });
138
+ itemCount += usedBandwidths.size;
139
+ }
140
+ if (itemCount === 0) {
141
+ return { width: 0, height: 0 };
142
+ }
143
+ const width = iconWidth + maxLabelWidth + padding * 2;
144
+ const height = itemCount * lineHeight + padding * 2 + 20; // +20 for title
145
+ return { width, height };
146
+ }
147
+ /**
148
+ * Parse legend settings from various input formats
149
+ */
150
+ getLegendSettings(legend) {
151
+ if (legend === true) {
152
+ return {
153
+ enabled: true,
154
+ position: 'top-right',
155
+ showDeviceTypes: true,
156
+ showBandwidth: true,
157
+ showCableTypes: true,
158
+ showVlans: false,
159
+ };
160
+ }
161
+ if (legend && typeof legend === 'object') {
162
+ return {
163
+ enabled: legend.enabled !== false,
164
+ position: legend.position ?? 'top-right',
165
+ showDeviceTypes: legend.showDeviceTypes ?? true,
166
+ showBandwidth: legend.showBandwidth ?? true,
167
+ showCableTypes: legend.showCableTypes ?? true,
168
+ showVlans: legend.showVlans ?? false,
169
+ };
170
+ }
171
+ return { enabled: false, position: 'top-right' };
172
+ }
173
+ /**
174
+ * Render legend showing visual elements used in the diagram
175
+ */
176
+ renderLegend(graph, layout, settings) {
177
+ const items = [];
178
+ const lineHeight = 20;
179
+ const padding = 12;
180
+ const iconWidth = 30;
181
+ const maxLabelWidth = 100;
182
+ // Collect used bandwidths
183
+ const usedBandwidths = new Set();
184
+ graph.links.forEach(link => {
185
+ if (link.bandwidth)
186
+ usedBandwidths.add(link.bandwidth);
187
+ });
188
+ // Collect used device types
189
+ const usedDeviceTypes = new Set();
190
+ graph.nodes.forEach(node => {
191
+ if (node.type)
192
+ usedDeviceTypes.add(node.type);
193
+ });
194
+ // Build legend items
195
+ if (settings.showBandwidth && usedBandwidths.size > 0) {
196
+ const sortedBandwidths = ['1G', '10G', '25G', '40G', '100G'].filter(b => usedBandwidths.has(b));
197
+ for (const bw of sortedBandwidths) {
198
+ const config = this.getBandwidthConfig(bw);
199
+ items.push({
200
+ icon: this.renderBandwidthLegendIcon(config.lineCount),
201
+ label: bw,
202
+ });
203
+ }
204
+ }
205
+ if (items.length === 0)
206
+ return '';
207
+ // Calculate legend dimensions
208
+ const legendWidth = iconWidth + maxLabelWidth + padding * 2;
209
+ const legendHeight = items.length * lineHeight + padding * 2 + 20; // +20 for title
210
+ // Position based on settings
211
+ const { bounds } = layout;
212
+ let legendX = bounds.x + bounds.width - legendWidth - 10;
213
+ let legendY = bounds.y + bounds.height - legendHeight - 10;
214
+ switch (settings.position) {
215
+ case 'top-left':
216
+ legendX = bounds.x + 10;
217
+ legendY = bounds.y + 10;
218
+ break;
219
+ case 'top-right':
220
+ legendX = bounds.x + bounds.width - legendWidth - 10;
221
+ legendY = bounds.y + 10;
222
+ break;
223
+ case 'bottom-left':
224
+ legendX = bounds.x + 10;
225
+ legendY = bounds.y + bounds.height - legendHeight - 10;
226
+ break;
227
+ }
228
+ // 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" />
232
+ <text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`;
233
+ // Render items
234
+ items.forEach((item, index) => {
235
+ const y = padding + 28 + index * lineHeight;
236
+ svg += `\n <g transform="translate(${padding}, ${y})">`;
237
+ svg += `\n ${item.icon}`;
238
+ svg += `\n <text x="${iconWidth + 4}" y="4" class="node-label" font-size="10">${this.escapeXml(item.label)}</text>`;
239
+ svg += `\n </g>`;
240
+ });
241
+ svg += '\n</g>';
242
+ return svg;
243
+ }
244
+ /**
245
+ * Render bandwidth indicator for legend
246
+ */
247
+ renderBandwidthLegendIcon(lineCount) {
248
+ const lineSpacing = 3;
249
+ const lineWidth = 24;
250
+ const strokeWidth = 2;
251
+ const offsets = this.calculateLineOffsets(lineCount, lineSpacing);
252
+ const lines = offsets.map(offset => {
253
+ const y = offset;
254
+ return `<line x1="0" y1="${y}" x2="${lineWidth}" y2="${y}" stroke="${this.themeColors.defaultLinkStroke}" stroke-width="${strokeWidth}" />`;
255
+ });
256
+ return `<g transform="translate(0, 0)">${lines.join('')}</g>`;
257
+ }
96
258
  renderHeader(width, height, viewBox) {
97
259
  return `<svg xmlns="http://www.w3.org/2000/svg"
98
260
  viewBox="${viewBox}"
@@ -195,8 +357,9 @@ export class SVGRenderer {
195
357
  <text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
196
358
  </g>`;
197
359
  }
198
- renderNode(layoutNode) {
199
- const { id, position, size, node, ports } = layoutNode;
360
+ /** Render node background (shape only) */
361
+ renderNodeBackground(layoutNode) {
362
+ const { id, position, size, node } = layoutNode;
200
363
  const x = position.x;
201
364
  const y = position.y;
202
365
  const w = size.width;
@@ -207,13 +370,18 @@ export class SVGRenderer {
207
370
  const strokeWidth = style.strokeWidth || 1;
208
371
  const strokeDasharray = style.strokeDasharray || '';
209
372
  const shape = this.renderNodeShape(node.shape, x, y, w, h, fill, stroke, strokeWidth, strokeDasharray);
210
- const iconResult = this.renderNodeIcon(node, x, y, w, h);
211
- const label = this.renderNodeLabel(node, x, y, h, iconResult.height);
373
+ return `<g class="node-bg" data-id="${id}">${shape}</g>`;
374
+ }
375
+ /** Render node foreground (content + ports) */
376
+ renderNodeForeground(layoutNode) {
377
+ const { id, position, size, node, ports } = layoutNode;
378
+ const x = position.x;
379
+ const y = position.y;
380
+ const w = size.width;
381
+ const content = this.renderNodeContent(node, x, y, w);
212
382
  const portsRendered = this.renderPorts(x, y, ports);
213
- return `<g class="node" data-id="${id}" transform="translate(0,0)">
214
- ${shape}
215
- ${iconResult.svg}
216
- ${label}
383
+ return `<g class="node-fg" data-id="${id}">
384
+ ${content}
217
385
  ${portsRendered}
218
386
  </g>`;
219
387
  }
@@ -320,9 +488,8 @@ export class SVGRenderer {
320
488
  * Calculate icon dimensions for a node
321
489
  */
322
490
  calculateIconInfo(node, w) {
323
- const defaultIconSize = 40;
324
- const iconPadding = 16;
325
- const maxIconWidth = w - iconPadding;
491
+ // Cap icon width at MAX_ICON_WIDTH_RATIO of node width to leave room for ports
492
+ const maxIconWidth = Math.round(w * MAX_ICON_WIDTH_RATIO);
326
493
  // Try vendor-specific icon first (service for cloud, model for hardware)
327
494
  const iconKey = node.service || node.model;
328
495
  if (node.vendor && iconKey) {
@@ -337,8 +504,8 @@ export class SVGRenderer {
337
504
  const vbWidth = parseInt(viewBoxMatch[1]);
338
505
  const vbHeight = parseInt(viewBoxMatch[2]);
339
506
  const aspectRatio = vbWidth / vbHeight;
340
- let iconWidth = Math.round(defaultIconSize * aspectRatio);
341
- let iconHeight = defaultIconSize;
507
+ let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio);
508
+ let iconHeight = DEFAULT_ICON_SIZE;
342
509
  if (iconWidth > maxIconWidth) {
343
510
  iconWidth = maxIconWidth;
344
511
  iconHeight = Math.round(maxIconWidth / aspectRatio);
@@ -357,8 +524,8 @@ export class SVGRenderer {
357
524
  const vbWidth = parseInt(vbMatch[3]);
358
525
  const vbHeight = parseInt(vbMatch[4]);
359
526
  const aspectRatio = vbWidth / vbHeight;
360
- let iconWidth = Math.abs(aspectRatio - 1) < 0.01 ? defaultIconSize : Math.round(defaultIconSize * aspectRatio);
361
- let iconHeight = defaultIconSize;
527
+ let iconWidth = Math.abs(aspectRatio - 1) < 0.01 ? DEFAULT_ICON_SIZE : Math.round(DEFAULT_ICON_SIZE * aspectRatio);
528
+ let iconHeight = DEFAULT_ICON_SIZE;
362
529
  if (iconWidth > maxIconWidth) {
363
530
  iconWidth = maxIconWidth;
364
531
  iconHeight = Math.round(maxIconWidth / aspectRatio);
@@ -371,9 +538,9 @@ export class SVGRenderer {
371
538
  }
372
539
  // Fallback: use viewBox directly
373
540
  return {
374
- width: defaultIconSize,
375
- height: defaultIconSize,
376
- svg: `<svg width="${defaultIconSize}" height="${defaultIconSize}" viewBox="${viewBox}">${vendorIcon}</svg>`,
541
+ width: DEFAULT_ICON_SIZE,
542
+ height: DEFAULT_ICON_SIZE,
543
+ svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="${viewBox}">${vendorIcon}</svg>`,
377
544
  };
378
545
  }
379
546
  }
@@ -382,36 +549,41 @@ export class SVGRenderer {
382
549
  if (!iconPath)
383
550
  return null;
384
551
  return {
385
- width: defaultIconSize,
386
- height: defaultIconSize,
387
- svg: `<svg width="${defaultIconSize}" height="${defaultIconSize}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
552
+ width: DEFAULT_ICON_SIZE,
553
+ height: DEFAULT_ICON_SIZE,
554
+ svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
388
555
  };
389
556
  }
390
- renderNodeIcon(node, x, y, w, h) {
557
+ /**
558
+ * Render node content (icon + label) with dynamic vertical centering
559
+ */
560
+ renderNodeContent(node, x, y, w) {
391
561
  const iconInfo = this.calculateIconInfo(node, w);
392
- if (!iconInfo)
393
- return { svg: '', height: 0 };
394
- const iconY = y - h / 2 + 12; // Position near top of node
395
- const svg = `<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
396
- ${iconInfo.svg}
397
- </g>`;
398
- return { svg, height: iconInfo.height };
399
- }
400
- renderNodeLabel(node, x, y, _h, iconHeight) {
401
562
  const labels = Array.isArray(node.label) ? node.label : [node.label];
402
- const lineHeight = 16;
403
- const totalHeight = labels.length * lineHeight;
404
- // Shift labels down based on actual icon height (with small padding)
405
- const iconOffset = iconHeight > 0 ? iconHeight - 8 : 0;
406
- const startY = y - totalHeight / 2 + lineHeight / 2 + 4 + iconOffset;
407
- const lines = labels.map((line, i) => {
408
- // Parse simple HTML tags
563
+ const labelHeight = labels.length * LABEL_LINE_HEIGHT;
564
+ // Calculate total content height
565
+ const iconHeight = iconInfo?.height || 0;
566
+ const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0;
567
+ const totalContentHeight = iconHeight + gap + labelHeight;
568
+ // Center the content block vertically in the node
569
+ const contentTop = y - totalContentHeight / 2;
570
+ const parts = [];
571
+ // Render icon at top of content block
572
+ if (iconInfo) {
573
+ const iconY = contentTop;
574
+ parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
575
+ ${iconInfo.svg}
576
+ </g>`);
577
+ }
578
+ // Render labels below icon
579
+ const labelStartY = contentTop + iconHeight + gap + LABEL_LINE_HEIGHT * 0.7; // 0.7 for text baseline adjustment
580
+ labels.forEach((line, i) => {
409
581
  const isBold = line.includes('<b>') || line.includes('<strong>');
410
582
  const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '');
411
583
  const className = isBold ? 'node-label node-label-bold' : 'node-label';
412
- return `<text x="${x}" y="${startY + i * lineHeight}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`;
584
+ parts.push(`<text x="${x}" y="${labelStartY + i * LABEL_LINE_HEIGHT}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`);
413
585
  });
414
- return lines.join('\n ');
586
+ return parts.join('\n ');
415
587
  }
416
588
  renderLink(layoutLink, nodes) {
417
589
  const { id, points, link, fromEndpoint, toEndpoint } = layoutLink;
@@ -488,9 +660,6 @@ export class SVGRenderer {
488
660
  // Normalize direction
489
661
  const nx = len > 0 ? dx / len : 0;
490
662
  const ny = len > 0 ? dy / len : 1;
491
- // Perpendicular direction (90 degrees rotated)
492
- const perpX = -ny;
493
- const perpY = nx;
494
663
  const isVertical = Math.abs(dy) > Math.abs(dx);
495
664
  // Hash port name as fallback
496
665
  const portHash = this.hashString(portName);
@@ -518,21 +687,27 @@ export class SVGRenderer {
518
687
  // Position: offset along line direction + fixed horizontal offset for vertical links
519
688
  let x;
520
689
  let y;
690
+ let anchor;
521
691
  if (isVertical) {
522
692
  // For vertical links, use fixed horizontal offset (simpler and consistent)
523
693
  x = endpoint.x + perpDist * sideMultiplier;
524
694
  y = endpoint.y + ny * offsetDist;
695
+ // Text anchor based on final position relative to endpoint
696
+ anchor = 'middle';
697
+ const labelDx = x - endpoint.x;
698
+ if (Math.abs(labelDx) > 8) {
699
+ anchor = labelDx > 0 ? 'start' : 'end';
700
+ }
525
701
  }
526
702
  else {
527
- // For horizontal links, use perpendicular calculation
528
- x = endpoint.x + nx * offsetDist + perpX * perpDist * sideMultiplier;
529
- y = endpoint.y + ny * offsetDist + perpY * perpDist * sideMultiplier;
530
- }
531
- // Text anchor based on final position relative to endpoint
532
- let anchor = 'middle';
533
- const labelDx = x - endpoint.x;
534
- if (Math.abs(labelDx) > 8) {
535
- anchor = labelDx > 0 ? 'start' : 'end';
703
+ // For horizontal links, position label near the port (not toward center)
704
+ // Keep x near the endpoint, offset y below the line
705
+ x = endpoint.x;
706
+ y = endpoint.y + perpDist; // Always below the line
707
+ // Text anchor: extend toward the center of the link
708
+ // Start endpoint extends right (start), end endpoint extends left (end)
709
+ // Check direction: if nextPoint is to the left, we're on the right side
710
+ anchor = nx < 0 ? 'end' : 'start';
536
711
  }
537
712
  return { x, y, anchor };
538
713
  }
@@ -570,7 +745,7 @@ export class SVGRenderer {
570
745
  switch (type) {
571
746
  case 'thick': return 3;
572
747
  case 'double': return 2;
573
- default: return 1.5;
748
+ default: return 2;
574
749
  }
575
750
  }
576
751
  /**
@@ -582,7 +757,7 @@ export class SVGRenderer {
582
757
  * 100G → 5 lines
583
758
  */
584
759
  getBandwidthConfig(bandwidth) {
585
- const strokeWidth = 1.5;
760
+ const strokeWidth = 2;
586
761
  switch (bandwidth) {
587
762
  case '1G':
588
763
  return { lineCount: 1, strokeWidth };
@@ -632,21 +807,44 @@ ${result}`;
632
807
  return paths.join('\n');
633
808
  }
634
809
  /**
635
- * Generate SVG path string from points
810
+ * Generate SVG path string from points with rounded corners
636
811
  */
637
- generatePath(points) {
638
- if (points.length === 4) {
639
- // Cubic bezier curve
640
- return `M ${points[0].x} ${points[0].y} C ${points[1].x} ${points[1].y}, ${points[2].x} ${points[2].y}, ${points[3].x} ${points[3].y}`;
641
- }
642
- else if (points.length === 2) {
643
- // Straight line
812
+ generatePath(points, cornerRadius = 8) {
813
+ if (points.length < 2)
814
+ return '';
815
+ if (points.length === 2) {
644
816
  return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
645
817
  }
646
- else {
647
- // Polyline
648
- return `M ${points[0].x} ${points[0].y} ` + points.slice(1).map(p => `L ${p.x} ${p.y}`).join(' ');
818
+ const parts = [`M ${points[0].x} ${points[0].y}`];
819
+ for (let i = 1; i < points.length - 1; i++) {
820
+ const prev = points[i - 1];
821
+ const curr = points[i];
822
+ const next = points[i + 1];
823
+ // Calculate distances to prev and next points
824
+ const distPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y);
825
+ const distNext = Math.hypot(next.x - curr.x, next.y - curr.y);
826
+ // Limit radius to half the shortest segment
827
+ const maxRadius = Math.min(distPrev, distNext) / 2;
828
+ const radius = Math.min(cornerRadius, maxRadius);
829
+ if (radius < 1) {
830
+ // Too small for rounding, just use straight line
831
+ parts.push(`L ${curr.x} ${curr.y}`);
832
+ continue;
833
+ }
834
+ // Calculate direction vectors
835
+ const dirPrev = { x: (curr.x - prev.x) / distPrev, y: (curr.y - prev.y) / distPrev };
836
+ const dirNext = { x: (next.x - curr.x) / distNext, y: (next.y - curr.y) / distNext };
837
+ // Points where curve starts and ends
838
+ const startCurve = { x: curr.x - dirPrev.x * radius, y: curr.y - dirPrev.y * radius };
839
+ const endCurve = { x: curr.x + dirNext.x * radius, y: curr.y + dirNext.y * radius };
840
+ // Line to start of curve, then quadratic bezier through corner
841
+ parts.push(`L ${startCurve.x} ${startCurve.y}`);
842
+ parts.push(`Q ${curr.x} ${curr.y} ${endCurve.x} ${endCurve.y}`);
649
843
  }
844
+ // Line to final point
845
+ const last = points[points.length - 1];
846
+ parts.push(`L ${last.x} ${last.y}`);
847
+ return parts.join(' ');
650
848
  }
651
849
  /**
652
850
  * Calculate offsets for parallel lines (centered around 0)
@@ -661,25 +859,54 @@ ${result}`;
661
859
  return offsets;
662
860
  }
663
861
  /**
664
- * Offset points perpendicular to the line direction
862
+ * Offset points perpendicular to line direction, handling each segment properly
863
+ * For orthogonal paths, this maintains parallel lines through bends
665
864
  */
666
865
  offsetPoints(points, offset) {
667
866
  if (points.length < 2)
668
867
  return points;
669
- // Calculate perpendicular direction from first to last point
670
- const dx = points[points.length - 1].x - points[0].x;
671
- const dy = points[points.length - 1].y - points[0].y;
868
+ const result = [];
869
+ for (let i = 0; i < points.length; i++) {
870
+ const p = points[i];
871
+ if (i === 0) {
872
+ // First point: use direction to next point
873
+ const next = points[i + 1];
874
+ const perp = this.getPerpendicular(p, next);
875
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset });
876
+ }
877
+ else if (i === points.length - 1) {
878
+ // Last point: use direction from previous point
879
+ const prev = points[i - 1];
880
+ const perp = this.getPerpendicular(prev, p);
881
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset });
882
+ }
883
+ else {
884
+ // Middle point (bend): offset based on incoming segment direction
885
+ const prev = points[i - 1];
886
+ const perp = this.getPerpendicular(prev, p);
887
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset });
888
+ // Also add a point for the outgoing segment if direction changes
889
+ const next = points[i + 1];
890
+ const perpNext = this.getPerpendicular(p, next);
891
+ // Check if direction changed (bend point)
892
+ if (Math.abs(perp.x - perpNext.x) > 0.01 || Math.abs(perp.y - perpNext.y) > 0.01) {
893
+ result.push({ x: p.x + perpNext.x * offset, y: p.y + perpNext.y * offset });
894
+ }
895
+ }
896
+ }
897
+ return result;
898
+ }
899
+ /**
900
+ * Get perpendicular unit vector for a line segment
901
+ */
902
+ getPerpendicular(from, to) {
903
+ const dx = to.x - from.x;
904
+ const dy = to.y - from.y;
672
905
  const len = Math.sqrt(dx * dx + dy * dy);
673
906
  if (len === 0)
674
- return points;
675
- // Perpendicular unit vector
676
- const perpX = -dy / len;
677
- const perpY = dx / len;
678
- // Offset all points
679
- return points.map(p => ({
680
- x: p.x + perpX * offset,
681
- y: p.y + perpY * offset,
682
- }));
907
+ return { x: 0, y: 0 };
908
+ // Perpendicular unit vector (rotate 90 degrees)
909
+ return { x: -dy / len, y: dx / len };
683
910
  }
684
911
  /**
685
912
  * Get default link type based on redundancy