@shumoku/core 0.1.1 → 0.2.1

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 (193) hide show
  1. package/dist/constants.d.ts +23 -0
  2. package/dist/constants.d.ts.map +1 -0
  3. package/dist/constants.js +25 -0
  4. package/dist/constants.js.map +1 -0
  5. package/dist/icons/build-icons.js +3 -3
  6. package/dist/icons/build-icons.js.map +1 -1
  7. package/dist/icons/generated-icons.js +10 -10
  8. package/dist/icons/generated-icons.js.map +1 -1
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -6
  12. package/dist/index.js.map +1 -1
  13. package/dist/layout/hierarchical.d.ts +13 -40
  14. package/dist/layout/hierarchical.d.ts.map +1 -1
  15. package/dist/layout/hierarchical.js +726 -1028
  16. package/dist/layout/hierarchical.js.map +1 -1
  17. package/dist/layout/index.d.ts +1 -1
  18. package/dist/layout/index.d.ts.map +1 -1
  19. package/dist/layout/index.js.map +1 -1
  20. package/dist/models/types.d.ts +30 -0
  21. package/dist/models/types.d.ts.map +1 -1
  22. package/dist/models/types.js +13 -13
  23. package/dist/models/types.js.map +1 -1
  24. package/dist/themes/dark.d.ts.map +1 -1
  25. package/dist/themes/dark.js +1 -1
  26. package/dist/themes/dark.js.map +1 -1
  27. package/dist/themes/index.d.ts +3 -3
  28. package/dist/themes/index.d.ts.map +1 -1
  29. package/dist/themes/index.js +4 -4
  30. package/dist/themes/index.js.map +1 -1
  31. package/dist/themes/modern.d.ts.map +1 -1
  32. package/dist/themes/modern.js.map +1 -1
  33. package/dist/themes/types.d.ts.map +1 -1
  34. package/dist/themes/utils.d.ts +1 -1
  35. package/dist/themes/utils.d.ts.map +1 -1
  36. package/dist/themes/utils.js +5 -4
  37. package/dist/themes/utils.js.map +1 -1
  38. package/package.json +88 -92
  39. package/src/constants.ts +35 -0
  40. package/src/icons/build-icons.ts +12 -6
  41. package/src/icons/generated-icons.ts +12 -12
  42. package/src/index.test.ts +66 -0
  43. package/src/index.ts +6 -10
  44. package/src/layout/hierarchical.ts +1251 -1543
  45. package/src/layout/index.ts +1 -1
  46. package/src/models/types.ts +84 -37
  47. package/src/themes/dark.ts +15 -15
  48. package/src/themes/index.ts +7 -7
  49. package/src/themes/modern.ts +22 -22
  50. package/src/themes/types.ts +26 -26
  51. package/src/themes/utils.ts +25 -24
  52. package/dist/renderer/components/index.d.ts +0 -8
  53. package/dist/renderer/components/index.d.ts.map +0 -1
  54. package/dist/renderer/components/index.js +0 -8
  55. package/dist/renderer/components/index.js.map +0 -1
  56. package/dist/renderer/components/link-renderer.d.ts +0 -11
  57. package/dist/renderer/components/link-renderer.d.ts.map +0 -1
  58. package/dist/renderer/components/link-renderer.js +0 -340
  59. package/dist/renderer/components/link-renderer.js.map +0 -1
  60. package/dist/renderer/components/node-renderer.d.ts +0 -14
  61. package/dist/renderer/components/node-renderer.d.ts.map +0 -1
  62. package/dist/renderer/components/node-renderer.js +0 -242
  63. package/dist/renderer/components/node-renderer.js.map +0 -1
  64. package/dist/renderer/components/port-renderer.d.ts +0 -8
  65. package/dist/renderer/components/port-renderer.d.ts.map +0 -1
  66. package/dist/renderer/components/port-renderer.js +0 -85
  67. package/dist/renderer/components/port-renderer.js.map +0 -1
  68. package/dist/renderer/components/subgraph-renderer.d.ts +0 -13
  69. package/dist/renderer/components/subgraph-renderer.d.ts.map +0 -1
  70. package/dist/renderer/components/subgraph-renderer.js +0 -85
  71. package/dist/renderer/components/subgraph-renderer.js.map +0 -1
  72. package/dist/renderer/icon-registry/index.d.ts +0 -6
  73. package/dist/renderer/icon-registry/index.d.ts.map +0 -1
  74. package/dist/renderer/icon-registry/index.js +0 -5
  75. package/dist/renderer/icon-registry/index.js.map +0 -1
  76. package/dist/renderer/icon-registry/registry.d.ts +0 -25
  77. package/dist/renderer/icon-registry/registry.d.ts.map +0 -1
  78. package/dist/renderer/icon-registry/registry.js +0 -85
  79. package/dist/renderer/icon-registry/registry.js.map +0 -1
  80. package/dist/renderer/icon-registry/types.d.ts +0 -44
  81. package/dist/renderer/icon-registry/types.d.ts.map +0 -1
  82. package/dist/renderer/icon-registry/types.js +0 -5
  83. package/dist/renderer/icon-registry/types.js.map +0 -1
  84. package/dist/renderer/index.d.ts +0 -6
  85. package/dist/renderer/index.d.ts.map +0 -1
  86. package/dist/renderer/index.js +0 -5
  87. package/dist/renderer/index.js.map +0 -1
  88. package/dist/renderer/render-model/builder.d.ts +0 -43
  89. package/dist/renderer/render-model/builder.d.ts.map +0 -1
  90. package/dist/renderer/render-model/builder.js +0 -646
  91. package/dist/renderer/render-model/builder.js.map +0 -1
  92. package/dist/renderer/render-model/index.d.ts +0 -6
  93. package/dist/renderer/render-model/index.d.ts.map +0 -1
  94. package/dist/renderer/render-model/index.js +0 -5
  95. package/dist/renderer/render-model/index.js.map +0 -1
  96. package/dist/renderer/render-model/types.d.ts +0 -216
  97. package/dist/renderer/render-model/types.d.ts.map +0 -1
  98. package/dist/renderer/render-model/types.js +0 -6
  99. package/dist/renderer/render-model/types.js.map +0 -1
  100. package/dist/renderer/renderer-types.d.ts +0 -55
  101. package/dist/renderer/renderer-types.d.ts.map +0 -1
  102. package/dist/renderer/renderer-types.js +0 -5
  103. package/dist/renderer/renderer-types.js.map +0 -1
  104. package/dist/renderer/svg-builder.d.ts +0 -152
  105. package/dist/renderer/svg-builder.d.ts.map +0 -1
  106. package/dist/renderer/svg-builder.js +0 -176
  107. package/dist/renderer/svg-builder.js.map +0 -1
  108. package/dist/renderer/svg-dom/builders/defs.d.ts +0 -10
  109. package/dist/renderer/svg-dom/builders/defs.d.ts.map +0 -1
  110. package/dist/renderer/svg-dom/builders/defs.js +0 -82
  111. package/dist/renderer/svg-dom/builders/defs.js.map +0 -1
  112. package/dist/renderer/svg-dom/builders/index.d.ts +0 -9
  113. package/dist/renderer/svg-dom/builders/index.d.ts.map +0 -1
  114. package/dist/renderer/svg-dom/builders/index.js +0 -9
  115. package/dist/renderer/svg-dom/builders/index.js.map +0 -1
  116. package/dist/renderer/svg-dom/builders/link.d.ts +0 -18
  117. package/dist/renderer/svg-dom/builders/link.d.ts.map +0 -1
  118. package/dist/renderer/svg-dom/builders/link.js +0 -188
  119. package/dist/renderer/svg-dom/builders/link.js.map +0 -1
  120. package/dist/renderer/svg-dom/builders/node.d.ts +0 -15
  121. package/dist/renderer/svg-dom/builders/node.d.ts.map +0 -1
  122. package/dist/renderer/svg-dom/builders/node.js +0 -262
  123. package/dist/renderer/svg-dom/builders/node.js.map +0 -1
  124. package/dist/renderer/svg-dom/builders/subgraph.d.ts +0 -14
  125. package/dist/renderer/svg-dom/builders/subgraph.d.ts.map +0 -1
  126. package/dist/renderer/svg-dom/builders/subgraph.js +0 -63
  127. package/dist/renderer/svg-dom/builders/subgraph.js.map +0 -1
  128. package/dist/renderer/svg-dom/builders/utils.d.ts +0 -40
  129. package/dist/renderer/svg-dom/builders/utils.d.ts.map +0 -1
  130. package/dist/renderer/svg-dom/builders/utils.js +0 -79
  131. package/dist/renderer/svg-dom/builders/utils.js.map +0 -1
  132. package/dist/renderer/svg-dom/index.d.ts +0 -9
  133. package/dist/renderer/svg-dom/index.d.ts.map +0 -1
  134. package/dist/renderer/svg-dom/index.js +0 -7
  135. package/dist/renderer/svg-dom/index.js.map +0 -1
  136. package/dist/renderer/svg-dom/interaction.d.ts +0 -69
  137. package/dist/renderer/svg-dom/interaction.d.ts.map +0 -1
  138. package/dist/renderer/svg-dom/interaction.js +0 -296
  139. package/dist/renderer/svg-dom/interaction.js.map +0 -1
  140. package/dist/renderer/svg-dom/renderer.d.ts +0 -47
  141. package/dist/renderer/svg-dom/renderer.d.ts.map +0 -1
  142. package/dist/renderer/svg-dom/renderer.js +0 -188
  143. package/dist/renderer/svg-dom/renderer.js.map +0 -1
  144. package/dist/renderer/svg-string/builders/defs.d.ts +0 -10
  145. package/dist/renderer/svg-string/builders/defs.d.ts.map +0 -1
  146. package/dist/renderer/svg-string/builders/defs.js +0 -43
  147. package/dist/renderer/svg-string/builders/defs.js.map +0 -1
  148. package/dist/renderer/svg-string/builders/link.d.ts +0 -10
  149. package/dist/renderer/svg-string/builders/link.d.ts.map +0 -1
  150. package/dist/renderer/svg-string/builders/link.js +0 -149
  151. package/dist/renderer/svg-string/builders/link.js.map +0 -1
  152. package/dist/renderer/svg-string/builders/node.d.ts +0 -10
  153. package/dist/renderer/svg-string/builders/node.d.ts.map +0 -1
  154. package/dist/renderer/svg-string/builders/node.js +0 -134
  155. package/dist/renderer/svg-string/builders/node.js.map +0 -1
  156. package/dist/renderer/svg-string/builders/subgraph.d.ts +0 -10
  157. package/dist/renderer/svg-string/builders/subgraph.d.ts.map +0 -1
  158. package/dist/renderer/svg-string/builders/subgraph.js +0 -59
  159. package/dist/renderer/svg-string/builders/subgraph.js.map +0 -1
  160. package/dist/renderer/svg-string/index.d.ts +0 -5
  161. package/dist/renderer/svg-string/index.d.ts.map +0 -1
  162. package/dist/renderer/svg-string/index.js +0 -5
  163. package/dist/renderer/svg-string/index.js.map +0 -1
  164. package/dist/renderer/svg-string/renderer.d.ts +0 -17
  165. package/dist/renderer/svg-string/renderer.d.ts.map +0 -1
  166. package/dist/renderer/svg-string/renderer.js +0 -53
  167. package/dist/renderer/svg-string/renderer.js.map +0 -1
  168. package/dist/renderer/svg.d.ts +0 -105
  169. package/dist/renderer/svg.d.ts.map +0 -1
  170. package/dist/renderer/svg.js +0 -804
  171. package/dist/renderer/svg.js.map +0 -1
  172. package/dist/renderer/text-measurer/browser-measurer.d.ts +0 -25
  173. package/dist/renderer/text-measurer/browser-measurer.d.ts.map +0 -1
  174. package/dist/renderer/text-measurer/browser-measurer.js +0 -85
  175. package/dist/renderer/text-measurer/browser-measurer.js.map +0 -1
  176. package/dist/renderer/text-measurer/fallback-measurer.d.ts +0 -22
  177. package/dist/renderer/text-measurer/fallback-measurer.d.ts.map +0 -1
  178. package/dist/renderer/text-measurer/fallback-measurer.js +0 -113
  179. package/dist/renderer/text-measurer/fallback-measurer.js.map +0 -1
  180. package/dist/renderer/text-measurer/index.d.ts +0 -13
  181. package/dist/renderer/text-measurer/index.d.ts.map +0 -1
  182. package/dist/renderer/text-measurer/index.js +0 -35
  183. package/dist/renderer/text-measurer/index.js.map +0 -1
  184. package/dist/renderer/text-measurer/types.d.ts +0 -30
  185. package/dist/renderer/text-measurer/types.d.ts.map +0 -1
  186. package/dist/renderer/text-measurer/types.js +0 -5
  187. package/dist/renderer/text-measurer/types.js.map +0 -1
  188. package/dist/renderer/theme.d.ts +0 -29
  189. package/dist/renderer/theme.d.ts.map +0 -1
  190. package/dist/renderer/theme.js +0 -80
  191. package/dist/renderer/theme.js.map +0 -1
  192. package/src/renderer/index.ts +0 -6
  193. package/src/renderer/svg.ts +0 -997
@@ -1,997 +0,0 @@
1
- /**
2
- * SVG Renderer
3
- * Renders NetworkGraph to SVG
4
- */
5
-
6
- import type {
7
- NetworkGraph,
8
- LayoutResult,
9
- LayoutNode,
10
- LayoutLink,
11
- LayoutSubgraph,
12
- LayoutPort,
13
- Node,
14
- NodeShape,
15
- LinkType,
16
- ThemeType,
17
- } from '../models/index.js'
18
- import { getDeviceIcon, getVendorIconEntry, type IconThemeVariant } from '../icons/index.js'
19
-
20
- // ============================================
21
- // Theme Colors
22
- // ============================================
23
-
24
- interface ThemeColors {
25
- backgroundColor: string
26
- defaultNodeFill: string
27
- defaultNodeStroke: string
28
- defaultLinkStroke: string
29
- labelColor: string
30
- labelSecondaryColor: string
31
- subgraphFill: string
32
- subgraphStroke: string
33
- subgraphLabelColor: string
34
- portFill: string
35
- portStroke: string
36
- portLabelBg: string
37
- portLabelColor: string
38
- endpointLabelBg: string
39
- endpointLabelStroke: string
40
- }
41
-
42
- const LIGHT_THEME: ThemeColors = {
43
- backgroundColor: '#ffffff',
44
- defaultNodeFill: '#e2e8f0',
45
- defaultNodeStroke: '#64748b',
46
- defaultLinkStroke: '#94a3b8',
47
- labelColor: '#1e293b',
48
- labelSecondaryColor: '#64748b',
49
- subgraphFill: '#f8fafc',
50
- subgraphStroke: '#cbd5e1',
51
- subgraphLabelColor: '#374151',
52
- portFill: '#475569',
53
- portStroke: '#1e293b',
54
- portLabelBg: '#1e293b',
55
- portLabelColor: '#ffffff',
56
- endpointLabelBg: '#ffffff',
57
- endpointLabelStroke: '#cbd5e1',
58
- }
59
-
60
- const DARK_THEME: ThemeColors = {
61
- backgroundColor: '#1e293b',
62
- defaultNodeFill: '#334155',
63
- defaultNodeStroke: '#64748b',
64
- defaultLinkStroke: '#64748b',
65
- labelColor: '#f1f5f9',
66
- labelSecondaryColor: '#94a3b8',
67
- subgraphFill: '#0f172a',
68
- subgraphStroke: '#475569',
69
- subgraphLabelColor: '#e2e8f0',
70
- portFill: '#64748b',
71
- portStroke: '#94a3b8',
72
- portLabelBg: '#0f172a',
73
- portLabelColor: '#f1f5f9',
74
- endpointLabelBg: '#1e293b',
75
- endpointLabelStroke: '#475569',
76
- }
77
-
78
- // ============================================
79
- // Renderer Options
80
- // ============================================
81
-
82
- export interface SVGRendererOptions {
83
- /** Font family */
84
- fontFamily?: string
85
- /** Include interactive elements */
86
- interactive?: boolean
87
- }
88
-
89
- const DEFAULT_OPTIONS: Required<SVGRendererOptions> = {
90
- fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
91
- interactive: true,
92
- }
93
-
94
- // ============================================
95
- // SVG Renderer
96
- // ============================================
97
-
98
- export class SVGRenderer {
99
- private options: Required<SVGRendererOptions>
100
- private themeColors: ThemeColors = LIGHT_THEME
101
- private iconTheme: IconThemeVariant = 'default'
102
-
103
- constructor(options?: SVGRendererOptions) {
104
- this.options = { ...DEFAULT_OPTIONS, ...options }
105
- }
106
-
107
- /**
108
- * Get theme colors based on theme type
109
- */
110
- private getThemeColors(theme?: ThemeType): ThemeColors {
111
- return theme === 'dark' ? DARK_THEME : LIGHT_THEME
112
- }
113
-
114
- /**
115
- * Get icon theme variant based on theme type
116
- */
117
- private getIconTheme(theme?: ThemeType): IconThemeVariant {
118
- return theme === 'dark' ? 'dark' : 'light'
119
- }
120
-
121
- render(graph: NetworkGraph, layout: LayoutResult): string {
122
- const { bounds } = layout
123
-
124
- // Set theme colors based on graph settings
125
- const theme = graph.settings?.theme
126
- this.themeColors = this.getThemeColors(theme)
127
- this.iconTheme = this.getIconTheme(theme)
128
-
129
- const parts: string[] = []
130
-
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))
134
-
135
- // Defs (markers, gradients)
136
- parts.push(this.renderDefs())
137
-
138
- // Styles
139
- parts.push(this.renderStyles())
140
-
141
- // Subgraphs (background, render first)
142
- layout.subgraphs.forEach((sg) => {
143
- parts.push(this.renderSubgraph(sg))
144
- })
145
-
146
- // Links
147
- layout.links.forEach((link) => {
148
- parts.push(this.renderLink(link, layout.nodes))
149
- })
150
-
151
- // Nodes
152
- layout.nodes.forEach((node) => {
153
- parts.push(this.renderNode(node))
154
- })
155
-
156
- // Close SVG
157
- parts.push('</svg>')
158
-
159
- return parts.join('\n')
160
- }
161
-
162
- private renderHeader(width: number, height: number, viewBox: string): string {
163
- return `<svg xmlns="http://www.w3.org/2000/svg"
164
- viewBox="${viewBox}"
165
- width="${width}"
166
- height="${height}"
167
- style="background: ${this.themeColors.backgroundColor}">`
168
- }
169
-
170
- private renderDefs(): string {
171
- return `<defs>
172
- <!-- Arrow marker -->
173
- <marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
174
- <polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
175
- </marker>
176
- <marker id="arrow-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
177
- <polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
178
- </marker>
179
-
180
- <!-- Filters -->
181
- <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
182
- <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
183
- </filter>
184
- </defs>`
185
- }
186
-
187
- private renderStyles(): string {
188
- return `<style>
189
- .node { cursor: pointer; }
190
- .node:hover rect, .node:hover circle, .node:hover polygon { filter: brightness(0.95); }
191
- .node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
192
- .node-label-bold { font-weight: bold; }
193
- .node-icon { color: ${this.themeColors.labelSecondaryColor}; }
194
- .subgraph-icon { opacity: 0.9; }
195
- .subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
196
- .link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
197
- .endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
198
- </style>`
199
- }
200
-
201
- private renderSubgraph(sg: LayoutSubgraph): string {
202
- const { bounds, subgraph } = sg
203
- const style = subgraph.style || {}
204
-
205
- const fill = style.fill || this.themeColors.subgraphFill
206
- const stroke = style.stroke || this.themeColors.subgraphStroke
207
- const strokeWidth = style.strokeWidth || 1
208
- const strokeDasharray = style.strokeDasharray || ''
209
- const labelPos = style.labelPosition || 'top'
210
-
211
- const rx = 8 // Border radius
212
-
213
- // Check if subgraph has vendor icon (service for cloud, model for hardware)
214
- const iconKey = subgraph.service || subgraph.model
215
- const hasIcon = subgraph.vendor && iconKey
216
- const iconSize = 24
217
- const iconPadding = 8
218
-
219
- // Calculate icon position (top-left corner)
220
- const iconX = bounds.x + iconPadding
221
- const iconY = bounds.y + iconPadding
222
-
223
- // Label position - shift right if there's an icon
224
- let labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
225
- let labelY = bounds.y + 20
226
- const textAnchor = 'start'
227
-
228
- if (labelPos === 'top') {
229
- labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
230
- labelY = bounds.y + 20
231
- }
232
-
233
- // Render vendor icon if available
234
- let iconSvg = ''
235
- if (hasIcon) {
236
- const iconEntry = getVendorIconEntry(subgraph.vendor!, iconKey!, subgraph.resource)
237
- if (iconEntry) {
238
- const iconContent = iconEntry[this.iconTheme] || iconEntry.default
239
- const viewBox = iconEntry.viewBox || '0 0 48 48'
240
-
241
- // Check if icon is a nested SVG (PNG-based with custom viewBox in content)
242
- if (iconContent.startsWith('<svg')) {
243
- const viewBoxMatch = iconContent.match(/viewBox="0 0 (\d+) (\d+)"/)
244
- if (viewBoxMatch) {
245
- const vbWidth = parseInt(viewBoxMatch[1])
246
- const vbHeight = parseInt(viewBoxMatch[2])
247
- const aspectRatio = vbWidth / vbHeight
248
- const iconWidth = Math.round(iconSize * aspectRatio)
249
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
250
- <svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
251
- ${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
252
- </svg>
253
- </g>`
254
- }
255
- } else {
256
- // Use viewBox from entry
257
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
258
- <svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
259
- ${iconContent}
260
- </svg>
261
- </g>`
262
- }
263
- }
264
- }
265
-
266
- return `<g class="subgraph" data-id="${sg.id}">
267
- <rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
268
- rx="${rx}" ry="${rx}"
269
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
270
- ${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
271
- ${iconSvg}
272
- <text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
273
- </g>`
274
- }
275
-
276
- private renderNode(layoutNode: LayoutNode): string {
277
- const { id, position, size, node, ports } = layoutNode
278
- const x = position.x
279
- const y = position.y
280
- const w = size.width
281
- const h = size.height
282
-
283
- const style = node.style || {}
284
- const fill = style.fill || this.themeColors.defaultNodeFill
285
- const stroke = style.stroke || this.themeColors.defaultNodeStroke
286
- const strokeWidth = style.strokeWidth || 1
287
- const strokeDasharray = style.strokeDasharray || ''
288
-
289
- 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)
292
- const portsRendered = this.renderPorts(x, y, ports)
293
-
294
- return `<g class="node" data-id="${id}" transform="translate(0,0)">
295
- ${shape}
296
- ${iconResult.svg}
297
- ${label}
298
- ${portsRendered}
299
- </g>`
300
- }
301
-
302
- /**
303
- * Render ports on a node
304
- */
305
- private renderPorts(
306
- nodeX: number,
307
- nodeY: number,
308
- ports?: Map<string, LayoutPort>
309
- ): string {
310
- if (!ports || ports.size === 0) return ''
311
-
312
- const parts: string[] = []
313
-
314
- ports.forEach((port) => {
315
- const px = nodeX + port.position.x
316
- const py = nodeY + port.position.y
317
- const pw = port.size.width
318
- const ph = port.size.height
319
-
320
- // Port box
321
- parts.push(`<rect class="port" data-port="${port.id}"
322
- x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
323
- fill="${this.themeColors.portFill}" stroke="${this.themeColors.portStroke}" stroke-width="1" rx="2" />`)
324
-
325
- // Port label - position based on side
326
- let labelX = px
327
- let labelY = py
328
- let textAnchor = 'middle'
329
- const labelOffset = 12
330
-
331
- switch (port.side) {
332
- case 'top':
333
- labelY = py - labelOffset
334
- break
335
- case 'bottom':
336
- labelY = py + labelOffset + 4
337
- break
338
- case 'left':
339
- labelX = px - labelOffset
340
- textAnchor = 'end'
341
- break
342
- case 'right':
343
- labelX = px + labelOffset
344
- textAnchor = 'start'
345
- break
346
- }
347
-
348
- // Port label with black background
349
- const labelText = this.escapeXml(port.label)
350
- const charWidth = 5.5
351
- const labelWidth = labelText.length * charWidth + 4
352
- const labelHeight = 12
353
-
354
- // Calculate background rect position based on text anchor
355
- let bgX = labelX - 2
356
- if (textAnchor === 'middle') {
357
- bgX = labelX - labelWidth / 2
358
- } else if (textAnchor === 'end') {
359
- bgX = labelX - labelWidth + 2
360
- }
361
- const bgY = labelY - labelHeight + 3
362
-
363
- parts.push(`<rect class="port-label-bg" x="${bgX}" y="${bgY}" width="${labelWidth}" height="${labelHeight}" rx="2" fill="${this.themeColors.portLabelBg}" />`)
364
- parts.push(`<text class="port-label" x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="9" fill="${this.themeColors.portLabelColor}">${labelText}</text>`)
365
- })
366
-
367
- return parts.join('\n ')
368
- }
369
-
370
- private renderNodeShape(
371
- shape: NodeShape,
372
- x: number, y: number,
373
- w: number, h: number,
374
- fill: string, stroke: string,
375
- strokeWidth: number, strokeDasharray: string
376
- ): string {
377
- const dashAttr = strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''
378
- const halfW = w / 2
379
- const halfH = h / 2
380
-
381
- switch (shape) {
382
- case 'rect':
383
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
384
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
385
-
386
- case 'rounded':
387
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
388
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
389
-
390
- case 'circle':
391
- const r = Math.min(halfW, halfH)
392
- return `<circle cx="${x}" cy="${y}" r="${r}"
393
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
394
-
395
- case 'diamond':
396
- return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
397
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
398
-
399
- case 'hexagon':
400
- const hx = halfW * 0.866
401
- 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}"
402
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
403
-
404
- case 'cylinder':
405
- const ellipseH = h * 0.15
406
- return `<g>
407
- <ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
408
- <rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
409
- <line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
410
- <line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
411
- <ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />
412
- </g>`
413
-
414
- case 'stadium':
415
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
416
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
417
-
418
- case 'trapezoid':
419
- const indent = w * 0.15
420
- return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
421
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
422
-
423
- default:
424
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
425
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
426
- }
427
- }
428
-
429
- /**
430
- * Calculate icon dimensions for a node
431
- */
432
- 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
436
-
437
- // Try vendor-specific icon first (service for cloud, model for hardware)
438
- const iconKey = node.service || node.model
439
- if (node.vendor && iconKey) {
440
- const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
441
- if (iconEntry) {
442
- const vendorIcon = iconEntry[this.iconTheme] || iconEntry.default
443
- const viewBox = iconEntry.viewBox || '0 0 48 48'
444
-
445
- // Check if icon is a nested SVG (PNG-based with custom viewBox in content)
446
- if (vendorIcon.startsWith('<svg')) {
447
- const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
448
- if (viewBoxMatch) {
449
- const vbWidth = parseInt(viewBoxMatch[1])
450
- const vbHeight = parseInt(viewBoxMatch[2])
451
- const aspectRatio = vbWidth / vbHeight
452
- let iconWidth = Math.round(defaultIconSize * aspectRatio)
453
- let iconHeight = defaultIconSize
454
- if (iconWidth > maxIconWidth) {
455
- iconWidth = maxIconWidth
456
- iconHeight = Math.round(maxIconWidth / aspectRatio)
457
- }
458
- const innerSvg = vendorIcon.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
459
- return {
460
- width: iconWidth,
461
- height: iconHeight,
462
- svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="0 0 ${vbWidth} ${vbHeight}">${innerSvg}</svg>`,
463
- }
464
- }
465
- }
466
-
467
- // Parse viewBox for aspect ratio calculation
468
- const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
469
- if (vbMatch) {
470
- const vbWidth = parseInt(vbMatch[3])
471
- const vbHeight = parseInt(vbMatch[4])
472
- const aspectRatio = vbWidth / vbHeight
473
- let iconWidth = Math.abs(aspectRatio - 1) < 0.01 ? defaultIconSize : Math.round(defaultIconSize * aspectRatio)
474
- let iconHeight = defaultIconSize
475
- if (iconWidth > maxIconWidth) {
476
- iconWidth = maxIconWidth
477
- iconHeight = Math.round(maxIconWidth / aspectRatio)
478
- }
479
- return {
480
- width: iconWidth,
481
- height: iconHeight,
482
- svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="${viewBox}">${vendorIcon}</svg>`,
483
- }
484
- }
485
-
486
- // Fallback: use viewBox directly
487
- return {
488
- width: defaultIconSize,
489
- height: defaultIconSize,
490
- svg: `<svg width="${defaultIconSize}" height="${defaultIconSize}" viewBox="${viewBox}">${vendorIcon}</svg>`,
491
- }
492
- }
493
- }
494
-
495
- // Fall back to device type icon
496
- const iconPath = getDeviceIcon(node.type)
497
- if (!iconPath) return null
498
-
499
- return {
500
- width: defaultIconSize,
501
- height: defaultIconSize,
502
- svg: `<svg width="${defaultIconSize}" height="${defaultIconSize}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
503
- }
504
- }
505
-
506
- private renderNodeIcon(node: Node, x: number, y: number, w: number, h: number): { svg: string; height: number } {
507
- const iconInfo = this.calculateIconInfo(node, w)
508
- if (!iconInfo) return { svg: '', height: 0 }
509
-
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
- }
516
-
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
521
-
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
525
-
526
- const lines = labels.map((line, i) => {
527
- // Parse simple HTML tags
528
- const isBold = line.includes('<b>') || line.includes('<strong>')
529
- const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '')
530
- 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>`
533
- })
534
-
535
- return lines.join('\n ')
536
- }
537
-
538
- private renderLink(layoutLink: LayoutLink, nodes: Map<string, LayoutNode>): string {
539
- const { id, points, link, fromEndpoint, toEndpoint } = layoutLink
540
- const label = link.label
541
-
542
- // Auto-apply styles based on redundancy type
543
- const type = link.type || this.getDefaultLinkType(link.redundancy)
544
- const arrow = link.arrow ?? this.getDefaultArrowType(link.redundancy)
545
-
546
- const stroke = link.style?.stroke || this.getVlanStroke(link.vlan) || this.themeColors.defaultLinkStroke
547
- const dasharray = link.style?.strokeDasharray || this.getLinkDasharray(type)
548
- const markerEnd = arrow !== 'none' ? 'url(#arrow)' : ''
549
-
550
- // Get bandwidth rendering config
551
- const bandwidthConfig = this.getBandwidthConfig(link.bandwidth)
552
- const strokeWidth = link.style?.strokeWidth || bandwidthConfig.strokeWidth || this.getLinkStrokeWidth(type)
553
-
554
- // Render link lines based on bandwidth (single or multiple parallel lines)
555
- let result = this.renderBandwidthLines(
556
- id, points, stroke, strokeWidth, dasharray, markerEnd, bandwidthConfig, type
557
- )
558
-
559
- // Center label and VLANs
560
- const midPoint = this.getMidPoint(points)
561
- let labelYOffset = -8
562
-
563
- if (label) {
564
- const labelText = Array.isArray(label) ? label.join(' / ') : label
565
- result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(labelText)}</text>`
566
- labelYOffset += 12
567
- }
568
-
569
- // VLANs (link-level, applies to both endpoints)
570
- if (link.vlan && link.vlan.length > 0) {
571
- const vlanText = link.vlan.length === 1
572
- ? `VLAN ${link.vlan[0]}`
573
- : `VLAN ${link.vlan.join(', ')}`
574
- result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(vlanText)}</text>`
575
- }
576
-
577
- // Get node center positions for label placement
578
- const fromNode = nodes.get(fromEndpoint.node)
579
- const toNode = nodes.get(toEndpoint.node)
580
- const fromNodeCenterX = fromNode ? fromNode.position.x : points[0].x
581
- const toNodeCenterX = toNode ? toNode.position.x : points[points.length - 1].x
582
-
583
- // Endpoint labels (port/ip at both ends) - positioned along the line
584
- const fromLabels = this.formatEndpointLabels(fromEndpoint)
585
- const toLabels = this.formatEndpointLabels(toEndpoint)
586
-
587
- if (fromLabels.length > 0 && points.length > 1) {
588
- const portName = fromEndpoint.port || ''
589
- const labelPos = this.getEndpointLabelPosition(points, 'start', fromNodeCenterX, portName)
590
- result += this.renderEndpointLabels(fromLabels, labelPos.x, labelPos.y, labelPos.anchor)
591
- }
592
-
593
- if (toLabels.length > 0 && points.length > 1) {
594
- const portName = toEndpoint.port || ''
595
- const labelPos = this.getEndpointLabelPosition(points, 'end', toNodeCenterX, portName)
596
- result += this.renderEndpointLabels(toLabels, labelPos.x, labelPos.y, labelPos.anchor)
597
- }
598
-
599
- return `<g class="link-group">\n${result}\n</g>`
600
- }
601
-
602
- private formatEndpointLabels(endpoint: { node: string; port?: string; ip?: string }): string[] {
603
- const parts: string[] = []
604
- // Port is now rendered on the node itself, so don't include it here
605
- if (endpoint.ip) parts.push(endpoint.ip)
606
- return parts
607
- }
608
-
609
- /**
610
- * Calculate position for endpoint label near the port (not along the line)
611
- * This avoids label clustering at the center of links
612
- * Labels are placed based on port position relative to node center
613
- */
614
- private getEndpointLabelPosition(
615
- points: { x: number; y: number }[],
616
- which: 'start' | 'end',
617
- nodeCenterX: number,
618
- portName: string
619
- ): { x: number; y: number; anchor: string } {
620
- // Get the endpoint position (port position)
621
- const endpointIdx = which === 'start' ? 0 : points.length - 1
622
- const endpoint = points[endpointIdx]
623
-
624
- // Get the next/prev point to determine line direction
625
- const nextIdx = which === 'start' ? 1 : points.length - 2
626
- const nextPoint = points[nextIdx]
627
-
628
- // Calculate direction from endpoint toward the line
629
- const dx = nextPoint.x - endpoint.x
630
- const dy = nextPoint.y - endpoint.y
631
- const len = Math.sqrt(dx * dx + dy * dy)
632
-
633
- // Normalize direction
634
- const nx = len > 0 ? dx / len : 0
635
- const ny = len > 0 ? dy / len : 1
636
-
637
- // Perpendicular direction (90 degrees rotated)
638
- const perpX = -ny
639
- const perpY = nx
640
-
641
- const isVertical = Math.abs(dy) > Math.abs(dx)
642
-
643
- // Hash port name as fallback
644
- const portHash = this.hashString(portName)
645
- const hashDirection = portHash % 2 === 0 ? 1 : -1
646
-
647
- // Port position relative to node center determines label side
648
- const portOffsetFromCenter = endpoint.x - nodeCenterX
649
-
650
- let sideMultiplier: number
651
-
652
- if (isVertical) {
653
- if (Math.abs(portOffsetFromCenter) > 5) {
654
- // Port is on one side of node - place label outward
655
- sideMultiplier = portOffsetFromCenter > 0 ? 1 : -1
656
- } else {
657
- // Center port - use small hash-based offset to avoid overlap
658
- sideMultiplier = hashDirection * 0.2
659
- }
660
- } else {
661
- // Horizontal link: place label above/below based on which end
662
- const isStart = which === 'start'
663
- sideMultiplier = isStart ? -1 : 1
664
- }
665
-
666
- const offsetDist = 30 // Distance along line direction
667
- const perpDist = 20 // Perpendicular offset (fixed)
668
-
669
- // Position: offset along line direction + fixed horizontal offset for vertical links
670
- let x: number
671
- let y: number
672
-
673
- if (isVertical) {
674
- // For vertical links, use fixed horizontal offset (simpler and consistent)
675
- x = endpoint.x + perpDist * sideMultiplier
676
- 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
-
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'
688
- }
689
-
690
- return { x, y, anchor }
691
- }
692
-
693
- /**
694
- * Render endpoint labels (IP) with white background
695
- */
696
- private renderEndpointLabels(lines: string[], x: number, y: number, anchor: string): string {
697
- if (lines.length === 0) return ''
698
-
699
- const lineHeight = 11
700
- const paddingX = 2
701
- const paddingY = 2
702
- const charWidth = 4.8 // Approximate character width for 9px font
703
-
704
- // Calculate dimensions
705
- const maxLen = Math.max(...lines.map(l => l.length))
706
- const rectWidth = maxLen * charWidth + paddingX * 2
707
- const rectHeight = lines.length * lineHeight + paddingY * 2
708
-
709
- // Adjust rect position based on text anchor
710
- let rectX = x - paddingX
711
- if (anchor === 'middle') {
712
- rectX = x - rectWidth / 2
713
- } else if (anchor === 'end') {
714
- rectX = x - rectWidth + paddingX
715
- }
716
-
717
- const rectY = y - lineHeight + paddingY
718
-
719
- 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" />`
720
-
721
- lines.forEach((line, i) => {
722
- const textY = y + i * lineHeight
723
- result += `\n<text x="${x}" y="${textY}" class="endpoint-label" text-anchor="${anchor}">${this.escapeXml(line)}</text>`
724
- })
725
-
726
- return result
727
- }
728
-
729
- private getLinkStrokeWidth(type: LinkType): number {
730
- switch (type) {
731
- case 'thick': return 3
732
- case 'double': return 2
733
- default: return 1.5
734
- }
735
- }
736
-
737
- /**
738
- * Bandwidth rendering configuration - line count represents speed
739
- * 1G → 1 line
740
- * 10G → 2 lines
741
- * 25G → 3 lines
742
- * 40G → 4 lines
743
- * 100G → 5 lines
744
- */
745
- private getBandwidthConfig(bandwidth?: string): { lineCount: number; strokeWidth: number } {
746
- const strokeWidth = 1.5
747
- switch (bandwidth) {
748
- case '1G':
749
- return { lineCount: 1, strokeWidth }
750
- case '10G':
751
- return { lineCount: 2, strokeWidth }
752
- case '25G':
753
- return { lineCount: 3, strokeWidth }
754
- case '40G':
755
- return { lineCount: 4, strokeWidth }
756
- case '100G':
757
- return { lineCount: 5, strokeWidth }
758
- default:
759
- return { lineCount: 1, strokeWidth }
760
- }
761
- }
762
-
763
- /**
764
- * Render bandwidth lines (single or multiple parallel lines)
765
- */
766
- private renderBandwidthLines(
767
- id: string,
768
- points: { x: number; y: number }[],
769
- stroke: string,
770
- strokeWidth: number,
771
- dasharray: string,
772
- markerEnd: string,
773
- config: { lineCount: number; strokeWidth: number },
774
- type: LinkType
775
- ): string {
776
- const { lineCount } = config
777
- const lineSpacing = 3 // Space between parallel lines
778
-
779
- if (lineCount === 1) {
780
- // Single line
781
- const path = this.generatePath(points)
782
- let result = `<path class="link" data-id="${id}" d="${path}"
783
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
784
- ${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
785
- ${markerEnd ? `marker-end="${markerEnd}"` : ''} />`
786
-
787
- // Double line effect for redundancy types
788
- if (type === 'double') {
789
- result = `<path class="link-double-outer" d="${path}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" />
790
- <path class="link-double-inner" d="${path}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" />
791
- ${result}`
792
- }
793
- return result
794
- }
795
-
796
- // Multiple parallel lines
797
- const paths: string[] = []
798
- const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
799
-
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}"` : ''} />`)
806
- }
807
-
808
- return paths.join('\n')
809
- }
810
-
811
- /**
812
- * Generate SVG path string from points
813
- */
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
820
- 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
- }
825
- }
826
-
827
- /**
828
- * Calculate offsets for parallel lines (centered around 0)
829
- */
830
- private calculateLineOffsets(lineCount: number, spacing: number): number[] {
831
- const offsets: number[] = []
832
- const totalWidth = (lineCount - 1) * spacing
833
- const startOffset = -totalWidth / 2
834
-
835
- for (let i = 0; i < lineCount; i++) {
836
- offsets.push(startOffset + i * spacing)
837
- }
838
- return offsets
839
- }
840
-
841
- /**
842
- * Offset points perpendicular to the line direction
843
- */
844
- private offsetPoints(points: { x: number; y: number }[], offset: number): { x: number; y: number }[] {
845
- if (points.length < 2) return points
846
-
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)
851
-
852
- if (len === 0) return points
853
-
854
- // Perpendicular unit vector
855
- const perpX = -dy / len
856
- const perpY = dx / len
857
-
858
- // Offset all points
859
- return points.map(p => ({
860
- x: p.x + perpX * offset,
861
- y: p.y + perpY * offset,
862
- }))
863
- }
864
-
865
- /**
866
- * Get default link type based on redundancy
867
- */
868
- private getDefaultLinkType(redundancy?: string): LinkType {
869
- switch (redundancy) {
870
- case 'ha':
871
- case 'vc':
872
- case 'vss':
873
- case 'vpc':
874
- case 'mlag':
875
- return 'double'
876
- case 'stack':
877
- return 'thick'
878
- default:
879
- return 'solid'
880
- }
881
- }
882
-
883
- /**
884
- * Get default arrow type based on redundancy
885
- */
886
- private getDefaultArrowType(_redundancy?: string): 'none' | 'forward' | 'back' | 'both' {
887
- // Network diagrams typically show bidirectional connections, so no arrow by default
888
- return 'none'
889
- }
890
-
891
- /**
892
- * VLAN color palette - distinct colors for different VLANs
893
- */
894
- private static readonly VLAN_COLORS = [
895
- '#dc2626', // Red
896
- '#ea580c', // Orange
897
- '#ca8a04', // Yellow
898
- '#16a34a', // Green
899
- '#0891b2', // Cyan
900
- '#2563eb', // Blue
901
- '#7c3aed', // Violet
902
- '#c026d3', // Magenta
903
- '#db2777', // Pink
904
- '#059669', // Emerald
905
- '#0284c7', // Light Blue
906
- '#4f46e5', // Indigo
907
- ]
908
-
909
- /**
910
- * Get stroke color based on VLANs
911
- */
912
- private getVlanStroke(vlan?: number[]): string | undefined {
913
- if (!vlan || vlan.length === 0) {
914
- return undefined
915
- }
916
-
917
- if (vlan.length === 1) {
918
- // Single VLAN: use color based on VLAN ID
919
- const colorIndex = vlan[0] % SVGRenderer.VLAN_COLORS.length
920
- return SVGRenderer.VLAN_COLORS[colorIndex]
921
- }
922
-
923
- // Multiple VLANs (trunk): use a combined hash color
924
- const hash = vlan.reduce((acc, v) => acc + v, 0)
925
- const colorIndex = hash % SVGRenderer.VLAN_COLORS.length
926
- return SVGRenderer.VLAN_COLORS[colorIndex]
927
- }
928
-
929
- private getLinkDasharray(type: LinkType): string {
930
- switch (type) {
931
- case 'dashed': return '5 3'
932
- case 'invisible': return '0'
933
- default: return ''
934
- }
935
- }
936
-
937
- private getMidPoint(points: { x: number; y: number }[]): { x: number; y: number } {
938
- if (points.length === 4) {
939
- // Cubic bezier curve midpoint at t=0.5
940
- const t = 0.5
941
- const mt = 1 - t
942
- const x = mt * mt * mt * points[0].x +
943
- 3 * mt * mt * t * points[1].x +
944
- 3 * mt * t * t * points[2].x +
945
- t * t * t * points[3].x
946
- const y = mt * mt * mt * points[0].y +
947
- 3 * mt * mt * t * points[1].y +
948
- 3 * mt * t * t * points[2].y +
949
- t * t * t * points[3].y
950
- return { x, y }
951
- }
952
-
953
- if (points.length === 2) {
954
- // Simple midpoint between two points
955
- return {
956
- x: (points[0].x + points[1].x) / 2,
957
- y: (points[0].y + points[1].y) / 2,
958
- }
959
- }
960
-
961
- // For polylines, find the middle segment and get its midpoint
962
- const midIndex = Math.floor(points.length / 2)
963
- if (midIndex > 0 && midIndex < points.length) {
964
- return {
965
- x: (points[midIndex - 1].x + points[midIndex].x) / 2,
966
- y: (points[midIndex - 1].y + points[midIndex].y) / 2,
967
- }
968
- }
969
-
970
- return points[midIndex] || points[0]
971
- }
972
-
973
- private escapeXml(str: string): string {
974
- return str
975
- .replace(/&/g, '&amp;')
976
- .replace(/</g, '&lt;')
977
- .replace(/>/g, '&gt;')
978
- .replace(/"/g, '&quot;')
979
- .replace(/'/g, '&#39;')
980
- }
981
-
982
- /**
983
- * Simple string hash for consistent but varied label placement
984
- */
985
- private hashString(str: string): number {
986
- let hash = 0
987
- for (let i = 0; i < str.length; i++) {
988
- const char = str.charCodeAt(i)
989
- hash = ((hash << 5) - hash) + char
990
- hash = hash & hash // Convert to 32-bit integer
991
- }
992
- return Math.abs(hash)
993
- }
994
- }
995
-
996
- // Default instance
997
- export const svgRenderer = new SVGRenderer()