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