@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
@@ -378,6 +378,38 @@ export function paperSizeToPixels(
378
378
  */
379
379
  export type ThemeType = 'light' | 'dark'
380
380
 
381
+ export interface LegendSettings {
382
+ /**
383
+ * Show legend in the diagram
384
+ */
385
+ enabled?: boolean
386
+
387
+ /**
388
+ * Legend position
389
+ */
390
+ position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
391
+
392
+ /**
393
+ * Show device type icons
394
+ */
395
+ showDeviceTypes?: boolean
396
+
397
+ /**
398
+ * Show bandwidth indicators
399
+ */
400
+ showBandwidth?: boolean
401
+
402
+ /**
403
+ * Show cable/link types
404
+ */
405
+ showCableTypes?: boolean
406
+
407
+ /**
408
+ * Show VLAN colors
409
+ */
410
+ showVlans?: boolean
411
+ }
412
+
381
413
  export interface GraphSettings {
382
414
  /**
383
415
  * Default layout direction
@@ -408,6 +440,11 @@ export interface GraphSettings {
408
440
  * Canvas/sheet size settings
409
441
  */
410
442
  canvas?: CanvasSettings
443
+
444
+ /**
445
+ * Legend configuration
446
+ */
447
+ legend?: boolean | LegendSettings
411
448
  }
412
449
 
413
450
  export interface NetworkGraph {
@@ -14,8 +14,16 @@ import type {
14
14
  NodeShape,
15
15
  LinkType,
16
16
  ThemeType,
17
+ LegendSettings,
18
+ LinkBandwidth,
17
19
  } from '../models/index.js'
18
20
  import { getDeviceIcon, getVendorIconEntry, type IconThemeVariant } from '../icons/index.js'
21
+ import {
22
+ DEFAULT_ICON_SIZE,
23
+ ICON_LABEL_GAP,
24
+ LABEL_LINE_HEIGHT,
25
+ MAX_ICON_WIDTH_RATIO,
26
+ } from '../constants.js'
19
27
 
20
28
  // ============================================
21
29
  // Theme Colors
@@ -126,11 +134,30 @@ export class SVGRenderer {
126
134
  this.themeColors = this.getThemeColors(theme)
127
135
  this.iconTheme = this.getIconTheme(theme)
128
136
 
137
+ // Calculate legend dimensions if enabled
138
+ const legendSettings = this.getLegendSettings(graph.settings?.legend)
139
+ let legendWidth = 0
140
+ let legendHeight = 0
141
+ if (legendSettings.enabled) {
142
+ const legendDims = this.calculateLegendDimensions(graph, legendSettings)
143
+ legendWidth = legendDims.width
144
+ legendHeight = legendDims.height
145
+ }
146
+
147
+ // Expand bounds to include legend with padding
148
+ const legendPadding = 20
149
+ const expandedBounds = {
150
+ x: bounds.x,
151
+ y: bounds.y,
152
+ width: bounds.width + (legendSettings.enabled && legendWidth > 0 ? legendPadding : 0),
153
+ height: bounds.height + (legendSettings.enabled && legendHeight > 0 ? legendPadding : 0),
154
+ }
155
+
129
156
  const parts: string[] = []
130
157
 
131
- // SVG header using content bounds
132
- const viewBox = `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`
133
- parts.push(this.renderHeader(bounds.width, bounds.height, viewBox))
158
+ // SVG header using expanded bounds
159
+ const viewBox = `${expandedBounds.x} ${expandedBounds.y} ${expandedBounds.width} ${expandedBounds.height}`
160
+ parts.push(this.renderHeader(expandedBounds.width, expandedBounds.height, viewBox))
134
161
 
135
162
  // Defs (markers, gradients)
136
163
  parts.push(this.renderDefs())
@@ -138,27 +165,195 @@ export class SVGRenderer {
138
165
  // Styles
139
166
  parts.push(this.renderStyles())
140
167
 
141
- // Subgraphs (background, render first)
168
+ // Layer 1: Subgraphs (background)
142
169
  layout.subgraphs.forEach((sg) => {
143
170
  parts.push(this.renderSubgraph(sg))
144
171
  })
145
172
 
146
- // Links
173
+ // Layer 2: Node backgrounds (shapes)
174
+ layout.nodes.forEach((node) => {
175
+ parts.push(this.renderNodeBackground(node))
176
+ })
177
+
178
+ // Layer 3: Links (on top of node backgrounds)
147
179
  layout.links.forEach((link) => {
148
180
  parts.push(this.renderLink(link, layout.nodes))
149
181
  })
150
182
 
151
- // Nodes
183
+ // Layer 4: Node foregrounds (content + ports, on top of links)
152
184
  layout.nodes.forEach((node) => {
153
- parts.push(this.renderNode(node))
185
+ parts.push(this.renderNodeForeground(node))
154
186
  })
155
187
 
188
+ // Legend (if enabled) - use already calculated legendSettings
189
+ if (legendSettings.enabled && legendWidth > 0) {
190
+ parts.push(this.renderLegend(graph, layout, legendSettings))
191
+ }
192
+
156
193
  // Close SVG
157
194
  parts.push('</svg>')
158
195
 
159
196
  return parts.join('\n')
160
197
  }
161
198
 
199
+ /**
200
+ * Calculate legend dimensions without rendering
201
+ */
202
+ private calculateLegendDimensions(graph: NetworkGraph, settings: LegendSettings): { width: number; height: number } {
203
+ const lineHeight = 20
204
+ const padding = 12
205
+ const iconWidth = 30
206
+ const maxLabelWidth = 100
207
+
208
+ // Count items
209
+ let itemCount = 0
210
+
211
+ if (settings.showBandwidth) {
212
+ const usedBandwidths = new Set<LinkBandwidth>()
213
+ graph.links.forEach(link => {
214
+ if (link.bandwidth) usedBandwidths.add(link.bandwidth)
215
+ })
216
+ itemCount += usedBandwidths.size
217
+ }
218
+
219
+ if (itemCount === 0) {
220
+ return { width: 0, height: 0 }
221
+ }
222
+
223
+ const width = iconWidth + maxLabelWidth + padding * 2
224
+ const height = itemCount * lineHeight + padding * 2 + 20 // +20 for title
225
+
226
+ return { width, height }
227
+ }
228
+
229
+ /**
230
+ * Parse legend settings from various input formats
231
+ */
232
+ private getLegendSettings(legend?: boolean | LegendSettings): LegendSettings & { enabled: boolean } {
233
+ if (legend === true) {
234
+ return {
235
+ enabled: true,
236
+ position: 'top-right',
237
+ showDeviceTypes: true,
238
+ showBandwidth: true,
239
+ showCableTypes: true,
240
+ showVlans: false,
241
+ }
242
+ }
243
+
244
+ if (legend && typeof legend === 'object') {
245
+ return {
246
+ enabled: legend.enabled !== false,
247
+ position: legend.position ?? 'top-right',
248
+ showDeviceTypes: legend.showDeviceTypes ?? true,
249
+ showBandwidth: legend.showBandwidth ?? true,
250
+ showCableTypes: legend.showCableTypes ?? true,
251
+ showVlans: legend.showVlans ?? false,
252
+ }
253
+ }
254
+
255
+ return { enabled: false, position: 'top-right' }
256
+ }
257
+
258
+ /**
259
+ * Render legend showing visual elements used in the diagram
260
+ */
261
+ private renderLegend(graph: NetworkGraph, layout: LayoutResult, settings: LegendSettings): string {
262
+ const items: { icon: string; label: string }[] = []
263
+ const lineHeight = 20
264
+ const padding = 12
265
+ const iconWidth = 30
266
+ const maxLabelWidth = 100
267
+
268
+ // Collect used bandwidths
269
+ const usedBandwidths = new Set<LinkBandwidth>()
270
+ graph.links.forEach(link => {
271
+ if (link.bandwidth) usedBandwidths.add(link.bandwidth)
272
+ })
273
+
274
+ // Collect used device types
275
+ const usedDeviceTypes = new Set<string>()
276
+ graph.nodes.forEach(node => {
277
+ if (node.type) usedDeviceTypes.add(node.type)
278
+ })
279
+
280
+ // Build legend items
281
+ if (settings.showBandwidth && usedBandwidths.size > 0) {
282
+ const sortedBandwidths: LinkBandwidth[] = ['1G', '10G', '25G', '40G', '100G'].filter(
283
+ b => usedBandwidths.has(b as LinkBandwidth)
284
+ ) as LinkBandwidth[]
285
+
286
+ for (const bw of sortedBandwidths) {
287
+ const config = this.getBandwidthConfig(bw)
288
+ items.push({
289
+ icon: this.renderBandwidthLegendIcon(config.lineCount),
290
+ label: bw,
291
+ })
292
+ }
293
+ }
294
+
295
+ if (items.length === 0) return ''
296
+
297
+ // Calculate legend dimensions
298
+ const legendWidth = iconWidth + maxLabelWidth + padding * 2
299
+ const legendHeight = items.length * lineHeight + padding * 2 + 20 // +20 for title
300
+
301
+ // Position based on settings
302
+ const { bounds } = layout
303
+ let legendX = bounds.x + bounds.width - legendWidth - 10
304
+ let legendY = bounds.y + bounds.height - legendHeight - 10
305
+
306
+ switch (settings.position) {
307
+ case 'top-left':
308
+ legendX = bounds.x + 10
309
+ legendY = bounds.y + 10
310
+ break
311
+ case 'top-right':
312
+ legendX = bounds.x + bounds.width - legendWidth - 10
313
+ legendY = bounds.y + 10
314
+ break
315
+ case 'bottom-left':
316
+ legendX = bounds.x + 10
317
+ legendY = bounds.y + bounds.height - legendHeight - 10
318
+ break
319
+ }
320
+
321
+ // Render legend box
322
+ let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
323
+ <rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
324
+ fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
325
+ <text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`
326
+
327
+ // Render items
328
+ items.forEach((item, index) => {
329
+ const y = padding + 28 + index * lineHeight
330
+ svg += `\n <g transform="translate(${padding}, ${y})">`
331
+ svg += `\n ${item.icon}`
332
+ svg += `\n <text x="${iconWidth + 4}" y="4" class="node-label" font-size="10">${this.escapeXml(item.label)}</text>`
333
+ svg += `\n </g>`
334
+ })
335
+
336
+ svg += '\n</g>'
337
+ return svg
338
+ }
339
+
340
+ /**
341
+ * Render bandwidth indicator for legend
342
+ */
343
+ private renderBandwidthLegendIcon(lineCount: number): string {
344
+ const lineSpacing = 3
345
+ const lineWidth = 24
346
+ const strokeWidth = 2
347
+ const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
348
+
349
+ const lines = offsets.map(offset => {
350
+ const y = offset
351
+ return `<line x1="0" y1="${y}" x2="${lineWidth}" y2="${y}" stroke="${this.themeColors.defaultLinkStroke}" stroke-width="${strokeWidth}" />`
352
+ })
353
+
354
+ return `<g transform="translate(0, 0)">${lines.join('')}</g>`
355
+ }
356
+
162
357
  private renderHeader(width: number, height: number, viewBox: string): string {
163
358
  return `<svg xmlns="http://www.w3.org/2000/svg"
164
359
  viewBox="${viewBox}"
@@ -273,8 +468,9 @@ export class SVGRenderer {
273
468
  </g>`
274
469
  }
275
470
 
276
- private renderNode(layoutNode: LayoutNode): string {
277
- const { id, position, size, node, ports } = layoutNode
471
+ /** Render node background (shape only) */
472
+ private renderNodeBackground(layoutNode: LayoutNode): string {
473
+ const { id, position, size, node } = layoutNode
278
474
  const x = position.x
279
475
  const y = position.y
280
476
  const w = size.width
@@ -287,18 +483,27 @@ export class SVGRenderer {
287
483
  const strokeDasharray = style.strokeDasharray || ''
288
484
 
289
485
  const shape = this.renderNodeShape(node.shape, x, y, w, h, fill, stroke, strokeWidth, strokeDasharray)
290
- const iconResult = this.renderNodeIcon(node, x, y, w, h)
291
- const label = this.renderNodeLabel(node, x, y, h, iconResult.height)
486
+
487
+ return `<g class="node-bg" data-id="${id}">${shape}</g>`
488
+ }
489
+
490
+ /** Render node foreground (content + ports) */
491
+ private renderNodeForeground(layoutNode: LayoutNode): string {
492
+ const { id, position, size, node, ports } = layoutNode
493
+ const x = position.x
494
+ const y = position.y
495
+ const w = size.width
496
+
497
+ const content = this.renderNodeContent(node, x, y, w)
292
498
  const portsRendered = this.renderPorts(x, y, ports)
293
499
 
294
- return `<g class="node" data-id="${id}" transform="translate(0,0)">
295
- ${shape}
296
- ${iconResult.svg}
297
- ${label}
500
+ return `<g class="node-fg" data-id="${id}">
501
+ ${content}
298
502
  ${portsRendered}
299
503
  </g>`
300
504
  }
301
505
 
506
+
302
507
  /**
303
508
  * Render ports on a node
304
509
  */
@@ -430,9 +635,8 @@ export class SVGRenderer {
430
635
  * Calculate icon dimensions for a node
431
636
  */
432
637
  private calculateIconInfo(node: Node, w: number): { width: number; height: number; svg: string } | null {
433
- const defaultIconSize = 40
434
- const iconPadding = 16
435
- const maxIconWidth = w - iconPadding
638
+ // Cap icon width at MAX_ICON_WIDTH_RATIO of node width to leave room for ports
639
+ const maxIconWidth = Math.round(w * MAX_ICON_WIDTH_RATIO)
436
640
 
437
641
  // Try vendor-specific icon first (service for cloud, model for hardware)
438
642
  const iconKey = node.service || node.model
@@ -449,8 +653,8 @@ export class SVGRenderer {
449
653
  const vbWidth = parseInt(viewBoxMatch[1])
450
654
  const vbHeight = parseInt(viewBoxMatch[2])
451
655
  const aspectRatio = vbWidth / vbHeight
452
- let iconWidth = Math.round(defaultIconSize * aspectRatio)
453
- let iconHeight = defaultIconSize
656
+ let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
657
+ let iconHeight = DEFAULT_ICON_SIZE
454
658
  if (iconWidth > maxIconWidth) {
455
659
  iconWidth = maxIconWidth
456
660
  iconHeight = Math.round(maxIconWidth / aspectRatio)
@@ -470,8 +674,8 @@ export class SVGRenderer {
470
674
  const vbWidth = parseInt(vbMatch[3])
471
675
  const vbHeight = parseInt(vbMatch[4])
472
676
  const aspectRatio = vbWidth / vbHeight
473
- let iconWidth = Math.abs(aspectRatio - 1) < 0.01 ? defaultIconSize : Math.round(defaultIconSize * aspectRatio)
474
- let iconHeight = defaultIconSize
677
+ let iconWidth = Math.abs(aspectRatio - 1) < 0.01 ? DEFAULT_ICON_SIZE : Math.round(DEFAULT_ICON_SIZE * aspectRatio)
678
+ let iconHeight = DEFAULT_ICON_SIZE
475
679
  if (iconWidth > maxIconWidth) {
476
680
  iconWidth = maxIconWidth
477
681
  iconHeight = Math.round(maxIconWidth / aspectRatio)
@@ -485,9 +689,9 @@ export class SVGRenderer {
485
689
 
486
690
  // Fallback: use viewBox directly
487
691
  return {
488
- width: defaultIconSize,
489
- height: defaultIconSize,
490
- svg: `<svg width="${defaultIconSize}" height="${defaultIconSize}" viewBox="${viewBox}">${vendorIcon}</svg>`,
692
+ width: DEFAULT_ICON_SIZE,
693
+ height: DEFAULT_ICON_SIZE,
694
+ svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="${viewBox}">${vendorIcon}</svg>`,
491
695
  }
492
696
  }
493
697
  }
@@ -497,42 +701,48 @@ export class SVGRenderer {
497
701
  if (!iconPath) return null
498
702
 
499
703
  return {
500
- width: defaultIconSize,
501
- height: defaultIconSize,
502
- svg: `<svg width="${defaultIconSize}" height="${defaultIconSize}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
704
+ width: DEFAULT_ICON_SIZE,
705
+ height: DEFAULT_ICON_SIZE,
706
+ svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
503
707
  }
504
708
  }
505
709
 
506
- private renderNodeIcon(node: Node, x: number, y: number, w: number, h: number): { svg: string; height: number } {
710
+ /**
711
+ * Render node content (icon + label) with dynamic vertical centering
712
+ */
713
+ private renderNodeContent(node: Node, x: number, y: number, w: number): string {
507
714
  const iconInfo = this.calculateIconInfo(node, w)
508
- if (!iconInfo) return { svg: '', height: 0 }
715
+ const labels = Array.isArray(node.label) ? node.label : [node.label]
716
+ const labelHeight = labels.length * LABEL_LINE_HEIGHT
509
717
 
510
- const iconY = y - h / 2 + 12 // Position near top of node
511
- const svg = `<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
512
- ${iconInfo.svg}
513
- </g>`
514
- return { svg, height: iconInfo.height }
515
- }
718
+ // Calculate total content height
719
+ const iconHeight = iconInfo?.height || 0
720
+ const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
721
+ const totalContentHeight = iconHeight + gap + labelHeight
516
722
 
517
- private renderNodeLabel(node: Node, x: number, y: number, _h: number, iconHeight: number): string {
518
- const labels = Array.isArray(node.label) ? node.label : [node.label]
519
- const lineHeight = 16
520
- const totalHeight = labels.length * lineHeight
723
+ // Center the content block vertically in the node
724
+ const contentTop = y - totalContentHeight / 2
521
725
 
522
- // Shift labels down based on actual icon height (with small padding)
523
- const iconOffset = iconHeight > 0 ? iconHeight - 8 : 0
524
- const startY = y - totalHeight / 2 + lineHeight / 2 + 4 + iconOffset
726
+ const parts: string[] = []
727
+
728
+ // Render icon at top of content block
729
+ if (iconInfo) {
730
+ const iconY = contentTop
731
+ parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
732
+ ${iconInfo.svg}
733
+ </g>`)
734
+ }
525
735
 
526
- const lines = labels.map((line, i) => {
527
- // Parse simple HTML tags
736
+ // Render labels below icon
737
+ const labelStartY = contentTop + iconHeight + gap + LABEL_LINE_HEIGHT * 0.7 // 0.7 for text baseline adjustment
738
+ labels.forEach((line, i) => {
528
739
  const isBold = line.includes('<b>') || line.includes('<strong>')
529
740
  const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '')
530
741
  const className = isBold ? 'node-label node-label-bold' : 'node-label'
531
-
532
- return `<text x="${x}" y="${startY + i * lineHeight}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`
742
+ parts.push(`<text x="${x}" y="${labelStartY + i * LABEL_LINE_HEIGHT}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`)
533
743
  })
534
744
 
535
- return lines.join('\n ')
745
+ return parts.join('\n ')
536
746
  }
537
747
 
538
748
  private renderLink(layoutLink: LayoutLink, nodes: Map<string, LayoutNode>): string {
@@ -634,10 +844,6 @@ export class SVGRenderer {
634
844
  const nx = len > 0 ? dx / len : 0
635
845
  const ny = len > 0 ? dy / len : 1
636
846
 
637
- // Perpendicular direction (90 degrees rotated)
638
- const perpX = -ny
639
- const perpY = nx
640
-
641
847
  const isVertical = Math.abs(dy) > Math.abs(dx)
642
848
 
643
849
  // Hash port name as fallback
@@ -670,21 +876,29 @@ export class SVGRenderer {
670
876
  let x: number
671
877
  let y: number
672
878
 
879
+ let anchor: string
880
+
673
881
  if (isVertical) {
674
882
  // For vertical links, use fixed horizontal offset (simpler and consistent)
675
883
  x = endpoint.x + perpDist * sideMultiplier
676
884
  y = endpoint.y + ny * offsetDist
677
- } else {
678
- // For horizontal links, use perpendicular calculation
679
- x = endpoint.x + nx * offsetDist + perpX * perpDist * sideMultiplier
680
- y = endpoint.y + ny * offsetDist + perpY * perpDist * sideMultiplier
681
- }
682
885
 
683
- // Text anchor based on final position relative to endpoint
684
- let anchor = 'middle'
685
- const labelDx = x - endpoint.x
686
- if (Math.abs(labelDx) > 8) {
687
- anchor = labelDx > 0 ? 'start' : 'end'
886
+ // Text anchor based on final position relative to endpoint
887
+ anchor = 'middle'
888
+ const labelDx = x - endpoint.x
889
+ if (Math.abs(labelDx) > 8) {
890
+ anchor = labelDx > 0 ? 'start' : 'end'
891
+ }
892
+ } else {
893
+ // For horizontal links, position label near the port (not toward center)
894
+ // Keep x near the endpoint, offset y below the line
895
+ x = endpoint.x
896
+ y = endpoint.y + perpDist // Always below the line
897
+
898
+ // Text anchor: extend toward the center of the link
899
+ // Start endpoint extends right (start), end endpoint extends left (end)
900
+ // Check direction: if nextPoint is to the left, we're on the right side
901
+ anchor = nx < 0 ? 'end' : 'start'
688
902
  }
689
903
 
690
904
  return { x, y, anchor }
@@ -730,7 +944,7 @@ export class SVGRenderer {
730
944
  switch (type) {
731
945
  case 'thick': return 3
732
946
  case 'double': return 2
733
- default: return 1.5
947
+ default: return 2
734
948
  }
735
949
  }
736
950
 
@@ -743,7 +957,7 @@ export class SVGRenderer {
743
957
  * 100G → 5 lines
744
958
  */
745
959
  private getBandwidthConfig(bandwidth?: string): { lineCount: number; strokeWidth: number } {
746
- const strokeWidth = 1.5
960
+ const strokeWidth = 2
747
961
  switch (bandwidth) {
748
962
  case '1G':
749
963
  return { lineCount: 1, strokeWidth }
@@ -809,19 +1023,53 @@ ${result}`
809
1023
  }
810
1024
 
811
1025
  /**
812
- * Generate SVG path string from points
1026
+ * Generate SVG path string from points with rounded corners
813
1027
  */
814
- private generatePath(points: { x: number; y: number }[]): string {
815
- if (points.length === 4) {
816
- // Cubic bezier curve
817
- 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}`
818
- } else if (points.length === 2) {
819
- // Straight line
1028
+ private generatePath(points: { x: number; y: number }[], cornerRadius: number = 8): string {
1029
+ if (points.length < 2) return ''
1030
+ if (points.length === 2) {
820
1031
  return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`
821
- } else {
822
- // Polyline
823
- return `M ${points[0].x} ${points[0].y} ` + points.slice(1).map(p => `L ${p.x} ${p.y}`).join(' ')
824
1032
  }
1033
+
1034
+ const parts: string[] = [`M ${points[0].x} ${points[0].y}`]
1035
+
1036
+ for (let i = 1; i < points.length - 1; i++) {
1037
+ const prev = points[i - 1]
1038
+ const curr = points[i]
1039
+ const next = points[i + 1]
1040
+
1041
+ // Calculate distances to prev and next points
1042
+ const distPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y)
1043
+ const distNext = Math.hypot(next.x - curr.x, next.y - curr.y)
1044
+
1045
+ // Limit radius to half the shortest segment
1046
+ const maxRadius = Math.min(distPrev, distNext) / 2
1047
+ const radius = Math.min(cornerRadius, maxRadius)
1048
+
1049
+ if (radius < 1) {
1050
+ // Too small for rounding, just use straight line
1051
+ parts.push(`L ${curr.x} ${curr.y}`)
1052
+ continue
1053
+ }
1054
+
1055
+ // Calculate direction vectors
1056
+ const dirPrev = { x: (curr.x - prev.x) / distPrev, y: (curr.y - prev.y) / distPrev }
1057
+ const dirNext = { x: (next.x - curr.x) / distNext, y: (next.y - curr.y) / distNext }
1058
+
1059
+ // Points where curve starts and ends
1060
+ const startCurve = { x: curr.x - dirPrev.x * radius, y: curr.y - dirPrev.y * radius }
1061
+ const endCurve = { x: curr.x + dirNext.x * radius, y: curr.y + dirNext.y * radius }
1062
+
1063
+ // Line to start of curve, then quadratic bezier through corner
1064
+ parts.push(`L ${startCurve.x} ${startCurve.y}`)
1065
+ parts.push(`Q ${curr.x} ${curr.y} ${endCurve.x} ${endCurve.y}`)
1066
+ }
1067
+
1068
+ // Line to final point
1069
+ const last = points[points.length - 1]
1070
+ parts.push(`L ${last.x} ${last.y}`)
1071
+
1072
+ return parts.join(' ')
825
1073
  }
826
1074
 
827
1075
  /**
@@ -839,27 +1087,59 @@ ${result}`
839
1087
  }
840
1088
 
841
1089
  /**
842
- * Offset points perpendicular to the line direction
1090
+ * Offset points perpendicular to line direction, handling each segment properly
1091
+ * For orthogonal paths, this maintains parallel lines through bends
843
1092
  */
844
1093
  private offsetPoints(points: { x: number; y: number }[], offset: number): { x: number; y: number }[] {
845
1094
  if (points.length < 2) return points
846
1095
 
847
- // Calculate perpendicular direction from first to last point
848
- const dx = points[points.length - 1].x - points[0].x
849
- const dy = points[points.length - 1].y - points[0].y
850
- const len = Math.sqrt(dx * dx + dy * dy)
1096
+ const result: { x: number; y: number }[] = []
1097
+
1098
+ for (let i = 0; i < points.length; i++) {
1099
+ const p = points[i]
1100
+
1101
+ if (i === 0) {
1102
+ // First point: use direction to next point
1103
+ const next = points[i + 1]
1104
+ const perp = this.getPerpendicular(p, next)
1105
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1106
+ } else if (i === points.length - 1) {
1107
+ // Last point: use direction from previous point
1108
+ const prev = points[i - 1]
1109
+ const perp = this.getPerpendicular(prev, p)
1110
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1111
+ } else {
1112
+ // Middle point (bend): offset based on incoming segment direction
1113
+ const prev = points[i - 1]
1114
+ const perp = this.getPerpendicular(prev, p)
1115
+ result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
1116
+
1117
+ // Also add a point for the outgoing segment if direction changes
1118
+ const next = points[i + 1]
1119
+ const perpNext = this.getPerpendicular(p, next)
1120
+
1121
+ // Check if direction changed (bend point)
1122
+ if (Math.abs(perp.x - perpNext.x) > 0.01 || Math.abs(perp.y - perpNext.y) > 0.01) {
1123
+ result.push({ x: p.x + perpNext.x * offset, y: p.y + perpNext.y * offset })
1124
+ }
1125
+ }
1126
+ }
851
1127
 
852
- if (len === 0) return points
1128
+ return result
1129
+ }
1130
+
1131
+ /**
1132
+ * Get perpendicular unit vector for a line segment
1133
+ */
1134
+ private getPerpendicular(from: { x: number; y: number }, to: { x: number; y: number }): { x: number; y: number } {
1135
+ const dx = to.x - from.x
1136
+ const dy = to.y - from.y
1137
+ const len = Math.sqrt(dx * dx + dy * dy)
853
1138
 
854
- // Perpendicular unit vector
855
- const perpX = -dy / len
856
- const perpY = dx / len
1139
+ if (len === 0) return { x: 0, y: 0 }
857
1140
 
858
- // Offset all points
859
- return points.map(p => ({
860
- x: p.x + perpX * offset,
861
- y: p.y + perpY * offset,
862
- }))
1141
+ // Perpendicular unit vector (rotate 90 degrees)
1142
+ return { x: -dy / len, y: dx / len }
863
1143
  }
864
1144
 
865
1145
  /**
@@ -1,8 +0,0 @@
1
- /**
2
- * Renderer Components
3
- */
4
- export { renderNode, type NodeRendererContext } from './node-renderer.js';
5
- export { renderLink, type LinkRendererContext } from './link-renderer.js';
6
- export { renderPorts } from './port-renderer.js';
7
- export { renderSubgraph, type SubgraphRendererContext } from './subgraph-renderer.js';
8
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/renderer/components/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AACzE,OAAO,EAAE,UAAU,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AACzE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAAE,cAAc,EAAE,KAAK,uBAAuB,EAAE,MAAM,wBAAwB,CAAA"}