@shumoku/renderer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/html/iife-entry.d.ts +9 -0
- package/dist/html/iife-entry.d.ts.map +1 -0
- package/dist/html/iife-entry.js +9 -0
- package/dist/html/iife-entry.js.map +1 -0
- package/dist/html/index.d.ts +23 -0
- package/dist/html/index.d.ts.map +1 -0
- package/dist/html/index.js +210 -0
- package/dist/html/index.js.map +1 -0
- package/dist/html/runtime.d.ts +6 -0
- package/dist/html/runtime.d.ts.map +1 -0
- package/dist/html/runtime.js +582 -0
- package/dist/html/runtime.js.map +1 -0
- package/dist/iife-string.js +2 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/shumoku-interactive.iife.js +38 -0
- package/dist/style.css +136 -0
- package/dist/svg.d.ts +158 -0
- package/dist/svg.d.ts.map +1 -0
- package/dist/svg.js +1174 -0
- package/dist/svg.js.map +1 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
- package/src/build-css.ts +159 -0
- package/src/build-iife-string.ts +19 -0
- package/src/build-iife.ts +24 -0
- package/src/html/iife-entry.ts +12 -0
- package/src/html/index.ts +226 -0
- package/src/html/runtime.ts +654 -0
- package/src/index.ts +22 -0
- package/src/svg.ts +1502 -0
- package/src/types.ts +125 -0
package/src/svg.ts
ADDED
|
@@ -0,0 +1,1502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG Renderer
|
|
3
|
+
* Renders NetworkGraph to SVG
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
IconThemeVariant,
|
|
8
|
+
LayoutLink,
|
|
9
|
+
LayoutNode,
|
|
10
|
+
LayoutPort,
|
|
11
|
+
LayoutResult,
|
|
12
|
+
LayoutSubgraph,
|
|
13
|
+
LegendSettings,
|
|
14
|
+
LinkBandwidth,
|
|
15
|
+
LinkType,
|
|
16
|
+
NetworkGraph,
|
|
17
|
+
Node,
|
|
18
|
+
NodeShape,
|
|
19
|
+
ThemeType,
|
|
20
|
+
} from '@shumoku/core'
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_ICON_SIZE,
|
|
23
|
+
getDeviceIcon,
|
|
24
|
+
getVendorIconEntry,
|
|
25
|
+
ICON_LABEL_GAP,
|
|
26
|
+
LABEL_LINE_HEIGHT,
|
|
27
|
+
MAX_ICON_WIDTH_RATIO,
|
|
28
|
+
} from '@shumoku/core'
|
|
29
|
+
|
|
30
|
+
import type { DataAttributeOptions, RenderMode } from './types.js'
|
|
31
|
+
|
|
32
|
+
// ============================================
|
|
33
|
+
// Theme Colors
|
|
34
|
+
// ============================================
|
|
35
|
+
|
|
36
|
+
interface ThemeColors {
|
|
37
|
+
backgroundColor: string
|
|
38
|
+
defaultNodeFill: string
|
|
39
|
+
defaultNodeStroke: string
|
|
40
|
+
defaultLinkStroke: string
|
|
41
|
+
labelColor: string
|
|
42
|
+
labelSecondaryColor: string
|
|
43
|
+
subgraphFill: string
|
|
44
|
+
subgraphStroke: string
|
|
45
|
+
subgraphLabelColor: string
|
|
46
|
+
portFill: string
|
|
47
|
+
portStroke: string
|
|
48
|
+
portLabelBg: string
|
|
49
|
+
portLabelColor: string
|
|
50
|
+
endpointLabelBg: string
|
|
51
|
+
endpointLabelStroke: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LIGHT_THEME: ThemeColors = {
|
|
55
|
+
backgroundColor: '#ffffff',
|
|
56
|
+
defaultNodeFill: '#e2e8f0',
|
|
57
|
+
defaultNodeStroke: '#64748b',
|
|
58
|
+
defaultLinkStroke: '#94a3b8',
|
|
59
|
+
labelColor: '#1e293b',
|
|
60
|
+
labelSecondaryColor: '#64748b',
|
|
61
|
+
subgraphFill: '#f8fafc',
|
|
62
|
+
subgraphStroke: '#cbd5e1',
|
|
63
|
+
subgraphLabelColor: '#374151',
|
|
64
|
+
portFill: '#475569',
|
|
65
|
+
portStroke: '#1e293b',
|
|
66
|
+
portLabelBg: '#1e293b',
|
|
67
|
+
portLabelColor: '#ffffff',
|
|
68
|
+
endpointLabelBg: '#ffffff',
|
|
69
|
+
endpointLabelStroke: '#cbd5e1',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DARK_THEME: ThemeColors = {
|
|
73
|
+
backgroundColor: '#1e293b',
|
|
74
|
+
defaultNodeFill: '#334155',
|
|
75
|
+
defaultNodeStroke: '#64748b',
|
|
76
|
+
defaultLinkStroke: '#64748b',
|
|
77
|
+
labelColor: '#f1f5f9',
|
|
78
|
+
labelSecondaryColor: '#94a3b8',
|
|
79
|
+
subgraphFill: '#0f172a',
|
|
80
|
+
subgraphStroke: '#475569',
|
|
81
|
+
subgraphLabelColor: '#e2e8f0',
|
|
82
|
+
portFill: '#64748b',
|
|
83
|
+
portStroke: '#94a3b8',
|
|
84
|
+
portLabelBg: '#0f172a',
|
|
85
|
+
portLabelColor: '#f1f5f9',
|
|
86
|
+
endpointLabelBg: '#1e293b',
|
|
87
|
+
endpointLabelStroke: '#475569',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================
|
|
91
|
+
// Renderer Options
|
|
92
|
+
// ============================================
|
|
93
|
+
|
|
94
|
+
export interface SVGRendererOptions {
|
|
95
|
+
/** Font family */
|
|
96
|
+
fontFamily?: string
|
|
97
|
+
/** Include interactive elements (deprecated, use renderMode) */
|
|
98
|
+
interactive?: boolean
|
|
99
|
+
/**
|
|
100
|
+
* Render mode
|
|
101
|
+
* - 'static': Pure SVG without interactive data attributes (default)
|
|
102
|
+
* - 'interactive': SVG with data attributes for runtime interactivity
|
|
103
|
+
*/
|
|
104
|
+
renderMode?: RenderMode
|
|
105
|
+
/**
|
|
106
|
+
* Data attributes to include in interactive mode
|
|
107
|
+
* Only used when renderMode is 'interactive'
|
|
108
|
+
*/
|
|
109
|
+
dataAttributes?: DataAttributeOptions
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DEFAULT_OPTIONS: Required<SVGRendererOptions> = {
|
|
113
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
114
|
+
interactive: true,
|
|
115
|
+
renderMode: 'static',
|
|
116
|
+
dataAttributes: { device: true, link: true, metadata: true },
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ============================================
|
|
120
|
+
// SVG Renderer
|
|
121
|
+
// ============================================
|
|
122
|
+
|
|
123
|
+
export class SVGRenderer {
|
|
124
|
+
private options: Required<SVGRendererOptions>
|
|
125
|
+
private themeColors: ThemeColors = LIGHT_THEME
|
|
126
|
+
private iconTheme: IconThemeVariant = 'default'
|
|
127
|
+
|
|
128
|
+
constructor(options?: SVGRendererOptions) {
|
|
129
|
+
this.options = { ...DEFAULT_OPTIONS, ...options }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Check if interactive mode is enabled */
|
|
133
|
+
private get isInteractive(): boolean {
|
|
134
|
+
return this.options.renderMode === 'interactive'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Get data attribute options with defaults */
|
|
138
|
+
private get dataAttrs(): Required<DataAttributeOptions> {
|
|
139
|
+
return {
|
|
140
|
+
device: this.options.dataAttributes?.device ?? true,
|
|
141
|
+
link: this.options.dataAttributes?.link ?? true,
|
|
142
|
+
metadata: this.options.dataAttributes?.metadata ?? true,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get theme colors based on theme type
|
|
148
|
+
*/
|
|
149
|
+
private getThemeColors(theme?: ThemeType): ThemeColors {
|
|
150
|
+
return theme === 'dark' ? DARK_THEME : LIGHT_THEME
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Get icon theme variant based on theme type
|
|
155
|
+
*/
|
|
156
|
+
private getIconTheme(theme?: ThemeType): IconThemeVariant {
|
|
157
|
+
return theme === 'dark' ? 'dark' : 'light'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
render(graph: NetworkGraph, layout: LayoutResult): string {
|
|
161
|
+
const { bounds } = layout
|
|
162
|
+
|
|
163
|
+
// Set theme colors based on graph settings
|
|
164
|
+
const theme = graph.settings?.theme
|
|
165
|
+
this.themeColors = this.getThemeColors(theme)
|
|
166
|
+
this.iconTheme = this.getIconTheme(theme)
|
|
167
|
+
|
|
168
|
+
// Calculate legend dimensions if enabled
|
|
169
|
+
const legendSettings = this.getLegendSettings(graph.settings?.legend)
|
|
170
|
+
let legendWidth = 0
|
|
171
|
+
let legendHeight = 0
|
|
172
|
+
if (legendSettings.enabled) {
|
|
173
|
+
const legendDims = this.calculateLegendDimensions(graph, legendSettings)
|
|
174
|
+
legendWidth = legendDims.width
|
|
175
|
+
legendHeight = legendDims.height
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Expand bounds to include legend with padding
|
|
179
|
+
const legendPadding = 20
|
|
180
|
+
const expandedBounds = {
|
|
181
|
+
x: bounds.x,
|
|
182
|
+
y: bounds.y,
|
|
183
|
+
width: bounds.width + (legendSettings.enabled && legendWidth > 0 ? legendPadding : 0),
|
|
184
|
+
height: bounds.height + (legendSettings.enabled && legendHeight > 0 ? legendPadding : 0),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const parts: string[] = []
|
|
188
|
+
|
|
189
|
+
// SVG header using expanded bounds
|
|
190
|
+
const viewBox = `${expandedBounds.x} ${expandedBounds.y} ${expandedBounds.width} ${expandedBounds.height}`
|
|
191
|
+
parts.push(this.renderHeader(expandedBounds.width, expandedBounds.height, viewBox))
|
|
192
|
+
|
|
193
|
+
// Defs (markers, gradients)
|
|
194
|
+
parts.push(this.renderDefs())
|
|
195
|
+
|
|
196
|
+
// Styles
|
|
197
|
+
parts.push(this.renderStyles())
|
|
198
|
+
|
|
199
|
+
// Layer 1: Subgraphs (background)
|
|
200
|
+
for (const sg of layout.subgraphs.values()) {
|
|
201
|
+
parts.push(this.renderSubgraph(sg))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Layer 2: Links (below nodes)
|
|
205
|
+
for (const link of layout.links.values()) {
|
|
206
|
+
parts.push(this.renderLink(link, layout.nodes))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Layer 3: Nodes (bg + fg as one unit, without ports)
|
|
210
|
+
for (const node of layout.nodes.values()) {
|
|
211
|
+
parts.push(this.renderNode(node))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Layer 4: Ports (separate layer on top of nodes)
|
|
215
|
+
for (const node of layout.nodes.values()) {
|
|
216
|
+
const portsRendered = this.renderPorts(node.id, node.position.x, node.position.y, node.ports)
|
|
217
|
+
if (portsRendered) {
|
|
218
|
+
parts.push(portsRendered)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Legend (if enabled) - use already calculated legendSettings
|
|
223
|
+
if (legendSettings.enabled && legendWidth > 0) {
|
|
224
|
+
parts.push(this.renderLegend(graph, layout, legendSettings))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Close SVG
|
|
228
|
+
parts.push('</svg>')
|
|
229
|
+
|
|
230
|
+
return parts.join('\n')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Calculate legend dimensions without rendering
|
|
235
|
+
*/
|
|
236
|
+
private calculateLegendDimensions(
|
|
237
|
+
graph: NetworkGraph,
|
|
238
|
+
settings: LegendSettings,
|
|
239
|
+
): { width: number; height: number } {
|
|
240
|
+
const lineHeight = 20
|
|
241
|
+
const padding = 12
|
|
242
|
+
const iconWidth = 30
|
|
243
|
+
const maxLabelWidth = 100
|
|
244
|
+
|
|
245
|
+
// Count items
|
|
246
|
+
let itemCount = 0
|
|
247
|
+
|
|
248
|
+
if (settings.showBandwidth) {
|
|
249
|
+
const usedBandwidths = new Set<LinkBandwidth>()
|
|
250
|
+
for (const link of graph.links) {
|
|
251
|
+
if (link.bandwidth) usedBandwidths.add(link.bandwidth)
|
|
252
|
+
}
|
|
253
|
+
itemCount += usedBandwidths.size
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (itemCount === 0) {
|
|
257
|
+
return { width: 0, height: 0 }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const width = iconWidth + maxLabelWidth + padding * 2
|
|
261
|
+
const height = itemCount * lineHeight + padding * 2 + 20 // +20 for title
|
|
262
|
+
|
|
263
|
+
return { width, height }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Parse legend settings from various input formats
|
|
268
|
+
*/
|
|
269
|
+
private getLegendSettings(
|
|
270
|
+
legend?: boolean | LegendSettings,
|
|
271
|
+
): LegendSettings & { enabled: boolean } {
|
|
272
|
+
if (legend === true) {
|
|
273
|
+
return {
|
|
274
|
+
enabled: true,
|
|
275
|
+
position: 'top-right',
|
|
276
|
+
showDeviceTypes: true,
|
|
277
|
+
showBandwidth: true,
|
|
278
|
+
showCableTypes: true,
|
|
279
|
+
showVlans: false,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (legend && typeof legend === 'object') {
|
|
284
|
+
return {
|
|
285
|
+
enabled: legend.enabled !== false,
|
|
286
|
+
position: legend.position ?? 'top-right',
|
|
287
|
+
showDeviceTypes: legend.showDeviceTypes ?? true,
|
|
288
|
+
showBandwidth: legend.showBandwidth ?? true,
|
|
289
|
+
showCableTypes: legend.showCableTypes ?? true,
|
|
290
|
+
showVlans: legend.showVlans ?? false,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return { enabled: false, position: 'top-right' }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Render legend showing visual elements used in the diagram
|
|
299
|
+
*/
|
|
300
|
+
private renderLegend(
|
|
301
|
+
graph: NetworkGraph,
|
|
302
|
+
layout: LayoutResult,
|
|
303
|
+
settings: LegendSettings,
|
|
304
|
+
): string {
|
|
305
|
+
const items: { icon: string; label: string }[] = []
|
|
306
|
+
const lineHeight = 20
|
|
307
|
+
const padding = 12
|
|
308
|
+
const iconWidth = 30
|
|
309
|
+
const maxLabelWidth = 100
|
|
310
|
+
|
|
311
|
+
// Collect used bandwidths
|
|
312
|
+
const usedBandwidths = new Set<LinkBandwidth>()
|
|
313
|
+
for (const link of graph.links) {
|
|
314
|
+
if (link.bandwidth) usedBandwidths.add(link.bandwidth)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Collect used device types
|
|
318
|
+
const usedDeviceTypes = new Set<string>()
|
|
319
|
+
for (const node of graph.nodes) {
|
|
320
|
+
if (node.type) usedDeviceTypes.add(node.type)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Build legend items
|
|
324
|
+
if (settings.showBandwidth && usedBandwidths.size > 0) {
|
|
325
|
+
const sortedBandwidths: LinkBandwidth[] = ['1G', '10G', '25G', '40G', '100G'].filter((b) =>
|
|
326
|
+
usedBandwidths.has(b as LinkBandwidth),
|
|
327
|
+
) as LinkBandwidth[]
|
|
328
|
+
|
|
329
|
+
for (const bw of sortedBandwidths) {
|
|
330
|
+
const config = this.getBandwidthConfig(bw)
|
|
331
|
+
items.push({
|
|
332
|
+
icon: this.renderBandwidthLegendIcon(config.lineCount),
|
|
333
|
+
label: bw,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (items.length === 0) return ''
|
|
339
|
+
|
|
340
|
+
// Calculate legend dimensions
|
|
341
|
+
const legendWidth = iconWidth + maxLabelWidth + padding * 2
|
|
342
|
+
const legendHeight = items.length * lineHeight + padding * 2 + 20 // +20 for title
|
|
343
|
+
|
|
344
|
+
// Position based on settings
|
|
345
|
+
const { bounds } = layout
|
|
346
|
+
let legendX = bounds.x + bounds.width - legendWidth - 10
|
|
347
|
+
let legendY = bounds.y + bounds.height - legendHeight - 10
|
|
348
|
+
|
|
349
|
+
switch (settings.position) {
|
|
350
|
+
case 'top-left':
|
|
351
|
+
legendX = bounds.x + 10
|
|
352
|
+
legendY = bounds.y + 10
|
|
353
|
+
break
|
|
354
|
+
case 'top-right':
|
|
355
|
+
legendX = bounds.x + bounds.width - legendWidth - 10
|
|
356
|
+
legendY = bounds.y + 10
|
|
357
|
+
break
|
|
358
|
+
case 'bottom-left':
|
|
359
|
+
legendX = bounds.x + 10
|
|
360
|
+
legendY = bounds.y + bounds.height - legendHeight - 10
|
|
361
|
+
break
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Render legend box
|
|
365
|
+
let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
|
|
366
|
+
<rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
|
|
367
|
+
fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
|
|
368
|
+
<text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`
|
|
369
|
+
|
|
370
|
+
// Render items
|
|
371
|
+
for (const [index, item] of items.entries()) {
|
|
372
|
+
const y = padding + 28 + index * lineHeight
|
|
373
|
+
svg += `\n <g transform="translate(${padding}, ${y})">`
|
|
374
|
+
svg += `\n ${item.icon}`
|
|
375
|
+
svg += `\n <text x="${iconWidth + 4}" y="4" class="node-label" font-size="10">${this.escapeXml(item.label)}</text>`
|
|
376
|
+
svg += '\n </g>'
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
svg += '\n</g>'
|
|
380
|
+
return svg
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Render bandwidth indicator for legend
|
|
385
|
+
*/
|
|
386
|
+
private renderBandwidthLegendIcon(lineCount: number): string {
|
|
387
|
+
const lineSpacing = 3
|
|
388
|
+
const lineWidth = 24
|
|
389
|
+
const strokeWidth = 2
|
|
390
|
+
const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
|
|
391
|
+
|
|
392
|
+
const lines = offsets.map((offset) => {
|
|
393
|
+
const y = offset
|
|
394
|
+
return `<line x1="0" y1="${y}" x2="${lineWidth}" y2="${y}" stroke="${this.themeColors.defaultLinkStroke}" stroke-width="${strokeWidth}" />`
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
return `<g transform="translate(0, 0)">${lines.join('')}</g>`
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private renderHeader(width: number, height: number, viewBox: string): string {
|
|
401
|
+
return `<svg xmlns="http://www.w3.org/2000/svg"
|
|
402
|
+
viewBox="${viewBox}"
|
|
403
|
+
width="${width}"
|
|
404
|
+
height="${height}"
|
|
405
|
+
style="background: ${this.themeColors.backgroundColor}">`
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private renderDefs(): string {
|
|
409
|
+
return `<defs>
|
|
410
|
+
<!-- Arrow marker -->
|
|
411
|
+
<marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
412
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
|
|
413
|
+
</marker>
|
|
414
|
+
<marker id="arrow-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
415
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
|
|
416
|
+
</marker>
|
|
417
|
+
|
|
418
|
+
<!-- Filters -->
|
|
419
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
420
|
+
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
|
|
421
|
+
</filter>
|
|
422
|
+
</defs>`
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private renderStyles(): string {
|
|
426
|
+
// CSS variables for interactive runtime theming
|
|
427
|
+
const cssVars = this.isInteractive
|
|
428
|
+
? `
|
|
429
|
+
:root {
|
|
430
|
+
--shumoku-bg: ${this.themeColors.backgroundColor};
|
|
431
|
+
--shumoku-surface: ${this.themeColors.subgraphFill};
|
|
432
|
+
--shumoku-text: ${this.themeColors.labelColor};
|
|
433
|
+
--shumoku-text-secondary: ${this.themeColors.labelSecondaryColor};
|
|
434
|
+
--shumoku-border: ${this.themeColors.subgraphStroke};
|
|
435
|
+
--shumoku-node-fill: ${this.themeColors.defaultNodeFill};
|
|
436
|
+
--shumoku-node-stroke: ${this.themeColors.defaultNodeStroke};
|
|
437
|
+
--shumoku-link-stroke: ${this.themeColors.defaultLinkStroke};
|
|
438
|
+
--shumoku-font: ${this.options.fontFamily};
|
|
439
|
+
}`
|
|
440
|
+
: ''
|
|
441
|
+
|
|
442
|
+
return `<style>${cssVars}
|
|
443
|
+
.node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
|
|
444
|
+
.node-label-bold { font-weight: bold; }
|
|
445
|
+
.node-icon { color: ${this.themeColors.labelSecondaryColor}; }
|
|
446
|
+
.subgraph-icon { opacity: 0.9; }
|
|
447
|
+
.subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
|
|
448
|
+
.link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
|
|
449
|
+
.endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
|
|
450
|
+
</style>`
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private renderSubgraph(sg: LayoutSubgraph): string {
|
|
454
|
+
const { bounds, subgraph } = sg
|
|
455
|
+
const style = subgraph.style || {}
|
|
456
|
+
|
|
457
|
+
const fill = style.fill || this.themeColors.subgraphFill
|
|
458
|
+
const stroke = style.stroke || this.themeColors.subgraphStroke
|
|
459
|
+
const strokeWidth = style.strokeWidth || 1
|
|
460
|
+
const strokeDasharray = style.strokeDasharray || ''
|
|
461
|
+
const labelPos = style.labelPosition || 'top'
|
|
462
|
+
|
|
463
|
+
const rx = 8 // Border radius
|
|
464
|
+
|
|
465
|
+
// Check if subgraph has vendor icon (service for cloud, model for hardware)
|
|
466
|
+
const iconKey = subgraph.service || subgraph.model
|
|
467
|
+
const hasIcon = subgraph.vendor && iconKey
|
|
468
|
+
const iconSize = 24
|
|
469
|
+
const iconPadding = 8
|
|
470
|
+
|
|
471
|
+
// Calculate icon position (top-left corner)
|
|
472
|
+
const iconX = bounds.x + iconPadding
|
|
473
|
+
const iconY = bounds.y + iconPadding
|
|
474
|
+
|
|
475
|
+
// Label position - shift right if there's an icon
|
|
476
|
+
let labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
|
|
477
|
+
let labelY = bounds.y + 20
|
|
478
|
+
const textAnchor = 'start'
|
|
479
|
+
|
|
480
|
+
if (labelPos === 'top') {
|
|
481
|
+
labelX = hasIcon ? bounds.x + iconSize + iconPadding * 2 : bounds.x + 10
|
|
482
|
+
labelY = bounds.y + 20
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Render vendor icon if available
|
|
486
|
+
let iconSvg = ''
|
|
487
|
+
if (hasIcon) {
|
|
488
|
+
const iconEntry = getVendorIconEntry(subgraph.vendor!, iconKey!, subgraph.resource)
|
|
489
|
+
if (iconEntry) {
|
|
490
|
+
const iconContent = iconEntry[this.iconTheme] || iconEntry.default
|
|
491
|
+
const viewBox = iconEntry.viewBox || '0 0 48 48'
|
|
492
|
+
|
|
493
|
+
// Check if icon is a nested SVG (PNG-based with custom viewBox in content)
|
|
494
|
+
if (iconContent.startsWith('<svg')) {
|
|
495
|
+
const viewBoxMatch = iconContent.match(/viewBox="0 0 (\d+) (\d+)"/)
|
|
496
|
+
if (viewBoxMatch) {
|
|
497
|
+
const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
|
|
498
|
+
const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
|
|
499
|
+
const aspectRatio = vbWidth / vbHeight
|
|
500
|
+
const iconWidth = Math.round(iconSize * aspectRatio)
|
|
501
|
+
iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
|
|
502
|
+
<svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
|
|
503
|
+
${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
|
|
504
|
+
</svg>
|
|
505
|
+
</g>`
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
// Use viewBox from entry
|
|
509
|
+
iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
|
|
510
|
+
<svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
|
|
511
|
+
${iconContent}
|
|
512
|
+
</svg>
|
|
513
|
+
</g>`
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return `<g class="subgraph" data-id="${sg.id}">
|
|
519
|
+
<rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
|
|
520
|
+
rx="${rx}" ry="${rx}"
|
|
521
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
|
|
522
|
+
${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
|
|
523
|
+
${iconSvg}
|
|
524
|
+
<text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
|
|
525
|
+
</g>`
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Render node background (shape only) */
|
|
529
|
+
/** Render complete node (bg + fg as one unit) */
|
|
530
|
+
private renderNode(layoutNode: LayoutNode): string {
|
|
531
|
+
const { id, node } = layoutNode
|
|
532
|
+
const dataAttrs = this.buildNodeDataAttributes(node)
|
|
533
|
+
const bg = this.renderNodeBackground(layoutNode)
|
|
534
|
+
const fg = this.renderNodeForeground(layoutNode)
|
|
535
|
+
|
|
536
|
+
return `<g class="node" data-id="${id}"${dataAttrs}>
|
|
537
|
+
${bg}
|
|
538
|
+
${fg}
|
|
539
|
+
</g>`
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private renderNodeBackground(layoutNode: LayoutNode): string {
|
|
543
|
+
const { id, position, size, node } = layoutNode
|
|
544
|
+
const x = position.x
|
|
545
|
+
const y = position.y
|
|
546
|
+
const w = size.width
|
|
547
|
+
const h = size.height
|
|
548
|
+
|
|
549
|
+
const style = node.style || {}
|
|
550
|
+
const fill = style.fill || this.themeColors.defaultNodeFill
|
|
551
|
+
const stroke = style.stroke || this.themeColors.defaultNodeStroke
|
|
552
|
+
const strokeWidth = style.strokeWidth || 1
|
|
553
|
+
const strokeDasharray = style.strokeDasharray || ''
|
|
554
|
+
|
|
555
|
+
const shape = this.renderNodeShape(
|
|
556
|
+
node.shape,
|
|
557
|
+
x,
|
|
558
|
+
y,
|
|
559
|
+
w,
|
|
560
|
+
h,
|
|
561
|
+
fill,
|
|
562
|
+
stroke,
|
|
563
|
+
strokeWidth,
|
|
564
|
+
strokeDasharray,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
// Build data attributes for interactive mode
|
|
568
|
+
const dataAttrs = this.buildNodeDataAttributes(node)
|
|
569
|
+
|
|
570
|
+
return `<g class="node-bg" data-id="${id}"${dataAttrs}>${shape}</g>`
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Build data attributes for a node (interactive mode only) */
|
|
574
|
+
private buildNodeDataAttributes(node: Node): string {
|
|
575
|
+
if (!this.isInteractive || !this.dataAttrs.device) return ''
|
|
576
|
+
|
|
577
|
+
const attrs: string[] = []
|
|
578
|
+
|
|
579
|
+
if (node.type) attrs.push(`data-device-type="${this.escapeXml(node.type)}"`)
|
|
580
|
+
if (node.vendor) attrs.push(`data-device-vendor="${this.escapeXml(node.vendor)}"`)
|
|
581
|
+
if (node.model) attrs.push(`data-device-model="${this.escapeXml(node.model)}"`)
|
|
582
|
+
if (node.service) attrs.push(`data-device-service="${this.escapeXml(node.service)}"`)
|
|
583
|
+
if (node.resource) attrs.push(`data-device-resource="${this.escapeXml(node.resource)}"`)
|
|
584
|
+
|
|
585
|
+
// Include full metadata as JSON
|
|
586
|
+
if (this.dataAttrs.metadata) {
|
|
587
|
+
const deviceInfo = {
|
|
588
|
+
id: node.id,
|
|
589
|
+
label: node.label,
|
|
590
|
+
type: node.type,
|
|
591
|
+
vendor: node.vendor,
|
|
592
|
+
model: node.model,
|
|
593
|
+
service: node.service,
|
|
594
|
+
resource: node.resource,
|
|
595
|
+
metadata: node.metadata,
|
|
596
|
+
}
|
|
597
|
+
attrs.push(`data-device-json="${this.escapeXml(JSON.stringify(deviceInfo))}"`)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return attrs.length > 0 ? ` ${attrs.join(' ')}` : ''
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Render node foreground (content only, ports rendered separately) */
|
|
604
|
+
private renderNodeForeground(layoutNode: LayoutNode): string {
|
|
605
|
+
const { id, position, size, node } = layoutNode
|
|
606
|
+
const x = position.x
|
|
607
|
+
const y = position.y
|
|
608
|
+
const w = size.width
|
|
609
|
+
|
|
610
|
+
const content = this.renderNodeContent(node, x, y, w)
|
|
611
|
+
|
|
612
|
+
// Include data attributes for interactive mode (same as node-bg)
|
|
613
|
+
const dataAttrs = this.buildNodeDataAttributes(node)
|
|
614
|
+
|
|
615
|
+
return `<g class="node-fg" data-id="${id}"${dataAttrs}>
|
|
616
|
+
${content}
|
|
617
|
+
</g>`
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Render ports on a node (as separate groups)
|
|
622
|
+
*/
|
|
623
|
+
private renderPorts(
|
|
624
|
+
nodeId: string,
|
|
625
|
+
nodeX: number,
|
|
626
|
+
nodeY: number,
|
|
627
|
+
ports?: Map<string, LayoutPort>,
|
|
628
|
+
): string {
|
|
629
|
+
if (!ports || ports.size === 0) return ''
|
|
630
|
+
|
|
631
|
+
const groups: string[] = []
|
|
632
|
+
|
|
633
|
+
for (const port of ports.values()) {
|
|
634
|
+
const px = nodeX + port.position.x
|
|
635
|
+
const py = nodeY + port.position.y
|
|
636
|
+
const pw = port.size.width
|
|
637
|
+
const ph = port.size.height
|
|
638
|
+
|
|
639
|
+
// Port data attribute for interactive mode
|
|
640
|
+
const portDeviceAttr = this.isInteractive ? ` data-port-device="${nodeId}"` : ''
|
|
641
|
+
|
|
642
|
+
const parts: string[] = []
|
|
643
|
+
|
|
644
|
+
// Port box
|
|
645
|
+
parts.push(`<rect class="port-box"
|
|
646
|
+
x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
|
|
647
|
+
fill="${this.themeColors.portFill}" stroke="${this.themeColors.portStroke}" stroke-width="1" rx="2" />`)
|
|
648
|
+
|
|
649
|
+
// Port label - position based on side
|
|
650
|
+
let labelX = px
|
|
651
|
+
let labelY = py
|
|
652
|
+
let textAnchor = 'middle'
|
|
653
|
+
const labelOffset = 12
|
|
654
|
+
|
|
655
|
+
switch (port.side) {
|
|
656
|
+
case 'top':
|
|
657
|
+
labelY = py - labelOffset
|
|
658
|
+
break
|
|
659
|
+
case 'bottom':
|
|
660
|
+
labelY = py + labelOffset + 4
|
|
661
|
+
break
|
|
662
|
+
case 'left':
|
|
663
|
+
labelX = px - labelOffset
|
|
664
|
+
textAnchor = 'end'
|
|
665
|
+
break
|
|
666
|
+
case 'right':
|
|
667
|
+
labelX = px + labelOffset
|
|
668
|
+
textAnchor = 'start'
|
|
669
|
+
break
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Port label with black background
|
|
673
|
+
const labelText = this.escapeXml(port.label)
|
|
674
|
+
const charWidth = 5.5
|
|
675
|
+
const labelWidth = labelText.length * charWidth + 4
|
|
676
|
+
const labelHeight = 12
|
|
677
|
+
|
|
678
|
+
// Calculate background rect position based on text anchor
|
|
679
|
+
let bgX = labelX - 2
|
|
680
|
+
if (textAnchor === 'middle') {
|
|
681
|
+
bgX = labelX - labelWidth / 2
|
|
682
|
+
} else if (textAnchor === 'end') {
|
|
683
|
+
bgX = labelX - labelWidth + 2
|
|
684
|
+
}
|
|
685
|
+
const bgY = labelY - labelHeight + 3
|
|
686
|
+
|
|
687
|
+
parts.push(
|
|
688
|
+
`<rect class="port-label-bg" x="${bgX}" y="${bgY}" width="${labelWidth}" height="${labelHeight}" rx="2" fill="${this.themeColors.portLabelBg}" />`,
|
|
689
|
+
)
|
|
690
|
+
parts.push(
|
|
691
|
+
`<text class="port-label" x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="9" fill="${this.themeColors.portLabelColor}">${labelText}</text>`,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
// Wrap in a group with data attributes
|
|
695
|
+
groups.push(`<g class="port" data-port="${port.id}"${portDeviceAttr}>
|
|
696
|
+
${parts.join('\n ')}
|
|
697
|
+
</g>`)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return groups.join('\n')
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private renderNodeShape(
|
|
704
|
+
shape: NodeShape,
|
|
705
|
+
x: number,
|
|
706
|
+
y: number,
|
|
707
|
+
w: number,
|
|
708
|
+
h: number,
|
|
709
|
+
fill: string,
|
|
710
|
+
stroke: string,
|
|
711
|
+
strokeWidth: number,
|
|
712
|
+
strokeDasharray: string,
|
|
713
|
+
): string {
|
|
714
|
+
const dashAttr = strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''
|
|
715
|
+
const halfW = w / 2
|
|
716
|
+
const halfH = h / 2
|
|
717
|
+
|
|
718
|
+
switch (shape) {
|
|
719
|
+
case 'rect':
|
|
720
|
+
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
|
|
721
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
|
|
722
|
+
|
|
723
|
+
case 'rounded':
|
|
724
|
+
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
|
|
725
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
|
|
726
|
+
|
|
727
|
+
case 'circle': {
|
|
728
|
+
const r = Math.min(halfW, halfH)
|
|
729
|
+
return `<circle cx="${x}" cy="${y}" r="${r}"
|
|
730
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
case 'diamond':
|
|
734
|
+
return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
|
|
735
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
|
|
736
|
+
|
|
737
|
+
case 'hexagon': {
|
|
738
|
+
const hx = halfW * 0.866
|
|
739
|
+
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}"
|
|
740
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
case 'cylinder': {
|
|
744
|
+
const ellipseH = h * 0.15
|
|
745
|
+
return `<g>
|
|
746
|
+
<ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
|
|
747
|
+
<rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
|
|
748
|
+
<line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
|
|
749
|
+
<line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
|
|
750
|
+
<ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />
|
|
751
|
+
</g>`
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
case 'stadium':
|
|
755
|
+
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
|
|
756
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
|
|
757
|
+
|
|
758
|
+
case 'trapezoid': {
|
|
759
|
+
const indent = w * 0.15
|
|
760
|
+
return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
|
|
761
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
default:
|
|
765
|
+
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
|
|
766
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Calculate icon dimensions for a node
|
|
772
|
+
*/
|
|
773
|
+
private calculateIconInfo(
|
|
774
|
+
node: Node,
|
|
775
|
+
w: number,
|
|
776
|
+
): { width: number; height: number; svg: string } | null {
|
|
777
|
+
// Cap icon width at MAX_ICON_WIDTH_RATIO of node width to leave room for ports
|
|
778
|
+
const maxIconWidth = Math.round(w * MAX_ICON_WIDTH_RATIO)
|
|
779
|
+
|
|
780
|
+
// Try vendor-specific icon first (service for cloud, model for hardware)
|
|
781
|
+
const iconKey = node.service || node.model
|
|
782
|
+
if (node.vendor && iconKey) {
|
|
783
|
+
const iconEntry = getVendorIconEntry(node.vendor, iconKey, node.resource)
|
|
784
|
+
if (iconEntry) {
|
|
785
|
+
const vendorIcon = iconEntry[this.iconTheme] || iconEntry.default
|
|
786
|
+
const viewBox = iconEntry.viewBox || '0 0 48 48'
|
|
787
|
+
|
|
788
|
+
// Check if icon is a nested SVG (PNG-based with custom viewBox in content)
|
|
789
|
+
if (vendorIcon.startsWith('<svg')) {
|
|
790
|
+
const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/)
|
|
791
|
+
if (viewBoxMatch) {
|
|
792
|
+
const vbWidth = Number.parseInt(viewBoxMatch[1], 10)
|
|
793
|
+
const vbHeight = Number.parseInt(viewBoxMatch[2], 10)
|
|
794
|
+
const aspectRatio = vbWidth / vbHeight
|
|
795
|
+
let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio)
|
|
796
|
+
let iconHeight = DEFAULT_ICON_SIZE
|
|
797
|
+
if (iconWidth > maxIconWidth) {
|
|
798
|
+
iconWidth = maxIconWidth
|
|
799
|
+
iconHeight = Math.round(maxIconWidth / aspectRatio)
|
|
800
|
+
}
|
|
801
|
+
const innerSvg = vendorIcon.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
|
802
|
+
return {
|
|
803
|
+
width: iconWidth,
|
|
804
|
+
height: iconHeight,
|
|
805
|
+
svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="0 0 ${vbWidth} ${vbHeight}">${innerSvg}</svg>`,
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Parse viewBox for aspect ratio calculation
|
|
811
|
+
const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
|
|
812
|
+
if (vbMatch) {
|
|
813
|
+
const vbWidth = Number.parseInt(vbMatch[3], 10)
|
|
814
|
+
const vbHeight = Number.parseInt(vbMatch[4], 10)
|
|
815
|
+
const aspectRatio = vbWidth / vbHeight
|
|
816
|
+
let iconWidth =
|
|
817
|
+
Math.abs(aspectRatio - 1) < 0.01
|
|
818
|
+
? DEFAULT_ICON_SIZE
|
|
819
|
+
: Math.round(DEFAULT_ICON_SIZE * aspectRatio)
|
|
820
|
+
let iconHeight = DEFAULT_ICON_SIZE
|
|
821
|
+
if (iconWidth > maxIconWidth) {
|
|
822
|
+
iconWidth = maxIconWidth
|
|
823
|
+
iconHeight = Math.round(maxIconWidth / aspectRatio)
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
width: iconWidth,
|
|
827
|
+
height: iconHeight,
|
|
828
|
+
svg: `<svg width="${iconWidth}" height="${iconHeight}" viewBox="${viewBox}">${vendorIcon}</svg>`,
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Fallback: use viewBox directly
|
|
833
|
+
return {
|
|
834
|
+
width: DEFAULT_ICON_SIZE,
|
|
835
|
+
height: DEFAULT_ICON_SIZE,
|
|
836
|
+
svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="${viewBox}">${vendorIcon}</svg>`,
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Fall back to device type icon
|
|
842
|
+
const iconPath = getDeviceIcon(node.type)
|
|
843
|
+
if (!iconPath) return null
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
width: DEFAULT_ICON_SIZE,
|
|
847
|
+
height: DEFAULT_ICON_SIZE,
|
|
848
|
+
svg: `<svg width="${DEFAULT_ICON_SIZE}" height="${DEFAULT_ICON_SIZE}" viewBox="0 0 24 24" fill="currentColor">${iconPath}</svg>`,
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Render node content (icon + label) with dynamic vertical centering
|
|
854
|
+
*/
|
|
855
|
+
private renderNodeContent(node: Node, x: number, y: number, w: number): string {
|
|
856
|
+
const iconInfo = this.calculateIconInfo(node, w)
|
|
857
|
+
const labels = Array.isArray(node.label) ? node.label : [node.label]
|
|
858
|
+
const labelHeight = labels.length * LABEL_LINE_HEIGHT
|
|
859
|
+
|
|
860
|
+
// Calculate total content height
|
|
861
|
+
const iconHeight = iconInfo?.height || 0
|
|
862
|
+
const gap = iconHeight > 0 ? ICON_LABEL_GAP : 0
|
|
863
|
+
const totalContentHeight = iconHeight + gap + labelHeight
|
|
864
|
+
|
|
865
|
+
// Center the content block vertically in the node
|
|
866
|
+
const contentTop = y - totalContentHeight / 2
|
|
867
|
+
|
|
868
|
+
const parts: string[] = []
|
|
869
|
+
|
|
870
|
+
// Render icon at top of content block
|
|
871
|
+
if (iconInfo) {
|
|
872
|
+
const iconY = contentTop
|
|
873
|
+
parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
|
|
874
|
+
${iconInfo.svg}
|
|
875
|
+
</g>`)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Render labels below icon
|
|
879
|
+
const labelStartY = contentTop + iconHeight + gap + LABEL_LINE_HEIGHT * 0.7 // 0.7 for text baseline adjustment
|
|
880
|
+
for (const [i, line] of labels.entries()) {
|
|
881
|
+
const isBold = line.includes('<b>') || line.includes('<strong>')
|
|
882
|
+
const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '')
|
|
883
|
+
const className = isBold ? 'node-label node-label-bold' : 'node-label'
|
|
884
|
+
parts.push(
|
|
885
|
+
`<text x="${x}" y="${labelStartY + i * LABEL_LINE_HEIGHT}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`,
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return parts.join('\n ')
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private renderLink(layoutLink: LayoutLink, nodes: Map<string, LayoutNode>): string {
|
|
893
|
+
const { id, points, link, fromEndpoint, toEndpoint } = layoutLink
|
|
894
|
+
const label = link.label
|
|
895
|
+
|
|
896
|
+
// Auto-apply styles based on redundancy type
|
|
897
|
+
const type = link.type || this.getDefaultLinkType(link.redundancy)
|
|
898
|
+
const arrow = link.arrow ?? this.getDefaultArrowType(link.redundancy)
|
|
899
|
+
|
|
900
|
+
const stroke =
|
|
901
|
+
link.style?.stroke || this.getVlanStroke(link.vlan) || this.themeColors.defaultLinkStroke
|
|
902
|
+
const dasharray = link.style?.strokeDasharray || this.getLinkDasharray(type)
|
|
903
|
+
const markerEnd = arrow !== 'none' ? 'url(#arrow)' : ''
|
|
904
|
+
|
|
905
|
+
// Get bandwidth rendering config
|
|
906
|
+
const bandwidthConfig = this.getBandwidthConfig(link.bandwidth)
|
|
907
|
+
const strokeWidth =
|
|
908
|
+
link.style?.strokeWidth || bandwidthConfig.strokeWidth || this.getLinkStrokeWidth(type)
|
|
909
|
+
|
|
910
|
+
// Render link lines based on bandwidth (single or multiple parallel lines)
|
|
911
|
+
let result = this.renderBandwidthLines(
|
|
912
|
+
id,
|
|
913
|
+
points,
|
|
914
|
+
stroke,
|
|
915
|
+
strokeWidth,
|
|
916
|
+
dasharray,
|
|
917
|
+
markerEnd,
|
|
918
|
+
bandwidthConfig,
|
|
919
|
+
type,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
// Center label and VLANs
|
|
923
|
+
const midPoint = this.getMidPoint(points)
|
|
924
|
+
let labelYOffset = -8
|
|
925
|
+
|
|
926
|
+
if (label) {
|
|
927
|
+
const labelText = Array.isArray(label) ? label.join(' / ') : label
|
|
928
|
+
result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(labelText)}</text>`
|
|
929
|
+
labelYOffset += 12
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// VLANs (link-level, applies to both endpoints)
|
|
933
|
+
if (link.vlan && link.vlan.length > 0) {
|
|
934
|
+
const vlanText =
|
|
935
|
+
link.vlan.length === 1 ? `VLAN ${link.vlan[0]}` : `VLAN ${link.vlan.join(', ')}`
|
|
936
|
+
result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(vlanText)}</text>`
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Get node center positions for label placement
|
|
940
|
+
const fromNode = nodes.get(fromEndpoint.node)
|
|
941
|
+
const toNode = nodes.get(toEndpoint.node)
|
|
942
|
+
const fromNodeCenterX = fromNode ? fromNode.position.x : points[0].x
|
|
943
|
+
const toNodeCenterX = toNode ? toNode.position.x : points[points.length - 1].x
|
|
944
|
+
|
|
945
|
+
// Endpoint labels (port/ip at both ends) - positioned along the line
|
|
946
|
+
const fromLabels = this.formatEndpointLabels(fromEndpoint)
|
|
947
|
+
const toLabels = this.formatEndpointLabels(toEndpoint)
|
|
948
|
+
|
|
949
|
+
if (fromLabels.length > 0 && points.length > 1) {
|
|
950
|
+
const portName = fromEndpoint.port || ''
|
|
951
|
+
const labelPos = this.getEndpointLabelPosition(points, 'start', fromNodeCenterX, portName)
|
|
952
|
+
result += this.renderEndpointLabels(fromLabels, labelPos.x, labelPos.y, labelPos.anchor)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (toLabels.length > 0 && points.length > 1) {
|
|
956
|
+
const portName = toEndpoint.port || ''
|
|
957
|
+
const labelPos = this.getEndpointLabelPosition(points, 'end', toNodeCenterX, portName)
|
|
958
|
+
result += this.renderEndpointLabels(toLabels, labelPos.x, labelPos.y, labelPos.anchor)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Build data attributes for interactive mode
|
|
962
|
+
const dataAttrs = this.buildLinkDataAttributes(layoutLink)
|
|
963
|
+
|
|
964
|
+
return `<g class="link-group" data-link-id="${id}"${dataAttrs}>\n${result}\n</g>`
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Build data attributes for a link (interactive mode only) */
|
|
968
|
+
private buildLinkDataAttributes(layoutLink: LayoutLink): string {
|
|
969
|
+
if (!this.isInteractive || !this.dataAttrs.link) return ''
|
|
970
|
+
|
|
971
|
+
const { link, fromEndpoint, toEndpoint } = layoutLink
|
|
972
|
+
const attrs: string[] = []
|
|
973
|
+
|
|
974
|
+
// Basic link attributes
|
|
975
|
+
if (link.bandwidth) attrs.push(`data-link-bandwidth="${this.escapeXml(link.bandwidth)}"`)
|
|
976
|
+
if (link.vlan && link.vlan.length > 0) {
|
|
977
|
+
attrs.push(`data-link-vlan="${link.vlan.join(',')}"`)
|
|
978
|
+
}
|
|
979
|
+
if (link.redundancy) attrs.push(`data-link-redundancy="${this.escapeXml(link.redundancy)}"`)
|
|
980
|
+
|
|
981
|
+
// Endpoint info
|
|
982
|
+
const fromStr = fromEndpoint.port
|
|
983
|
+
? `${fromEndpoint.node}:${fromEndpoint.port}`
|
|
984
|
+
: fromEndpoint.node
|
|
985
|
+
const toStr = toEndpoint.port ? `${toEndpoint.node}:${toEndpoint.port}` : toEndpoint.node
|
|
986
|
+
attrs.push(`data-link-from="${this.escapeXml(fromStr)}"`)
|
|
987
|
+
attrs.push(`data-link-to="${this.escapeXml(toStr)}"`)
|
|
988
|
+
|
|
989
|
+
// Include full metadata as JSON
|
|
990
|
+
if (this.dataAttrs.metadata) {
|
|
991
|
+
const linkInfo = {
|
|
992
|
+
id: layoutLink.id,
|
|
993
|
+
from: fromEndpoint,
|
|
994
|
+
to: toEndpoint,
|
|
995
|
+
bandwidth: link.bandwidth,
|
|
996
|
+
vlan: link.vlan,
|
|
997
|
+
redundancy: link.redundancy,
|
|
998
|
+
label: link.label,
|
|
999
|
+
}
|
|
1000
|
+
attrs.push(`data-link-json="${this.escapeXml(JSON.stringify(linkInfo))}"`)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return attrs.length > 0 ? ` ${attrs.join(' ')}` : ''
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
private formatEndpointLabels(endpoint: { node: string; port?: string; ip?: string }): string[] {
|
|
1007
|
+
const parts: string[] = []
|
|
1008
|
+
// Port is now rendered on the node itself, so don't include it here
|
|
1009
|
+
if (endpoint.ip) parts.push(endpoint.ip)
|
|
1010
|
+
return parts
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Calculate position for endpoint label near the port (not along the line)
|
|
1015
|
+
* This avoids label clustering at the center of links
|
|
1016
|
+
* Labels are placed based on port position relative to node center
|
|
1017
|
+
*/
|
|
1018
|
+
private getEndpointLabelPosition(
|
|
1019
|
+
points: { x: number; y: number }[],
|
|
1020
|
+
which: 'start' | 'end',
|
|
1021
|
+
nodeCenterX: number,
|
|
1022
|
+
portName: string,
|
|
1023
|
+
): { x: number; y: number; anchor: string } {
|
|
1024
|
+
// Get the endpoint position (port position)
|
|
1025
|
+
const endpointIdx = which === 'start' ? 0 : points.length - 1
|
|
1026
|
+
const endpoint = points[endpointIdx]
|
|
1027
|
+
|
|
1028
|
+
// Get the next/prev point to determine line direction
|
|
1029
|
+
const nextIdx = which === 'start' ? 1 : points.length - 2
|
|
1030
|
+
const nextPoint = points[nextIdx]
|
|
1031
|
+
|
|
1032
|
+
// Calculate direction from endpoint toward the line
|
|
1033
|
+
const dx = nextPoint.x - endpoint.x
|
|
1034
|
+
const dy = nextPoint.y - endpoint.y
|
|
1035
|
+
const len = Math.sqrt(dx * dx + dy * dy)
|
|
1036
|
+
|
|
1037
|
+
// Normalize direction
|
|
1038
|
+
const nx = len > 0 ? dx / len : 0
|
|
1039
|
+
const ny = len > 0 ? dy / len : 1
|
|
1040
|
+
|
|
1041
|
+
const isVertical = Math.abs(dy) > Math.abs(dx)
|
|
1042
|
+
|
|
1043
|
+
// Hash port name as fallback
|
|
1044
|
+
const portHash = this.hashString(portName)
|
|
1045
|
+
const hashDirection = portHash % 2 === 0 ? 1 : -1
|
|
1046
|
+
|
|
1047
|
+
// Port position relative to node center determines label side
|
|
1048
|
+
const portOffsetFromCenter = endpoint.x - nodeCenterX
|
|
1049
|
+
|
|
1050
|
+
let sideMultiplier: number
|
|
1051
|
+
|
|
1052
|
+
if (isVertical) {
|
|
1053
|
+
if (Math.abs(portOffsetFromCenter) > 5) {
|
|
1054
|
+
// Port is on one side of node - place label outward
|
|
1055
|
+
sideMultiplier = portOffsetFromCenter > 0 ? 1 : -1
|
|
1056
|
+
} else {
|
|
1057
|
+
// Center port - use small hash-based offset to avoid overlap
|
|
1058
|
+
sideMultiplier = hashDirection * 0.2
|
|
1059
|
+
}
|
|
1060
|
+
} else {
|
|
1061
|
+
// Horizontal link: place label above/below based on which end
|
|
1062
|
+
const isStart = which === 'start'
|
|
1063
|
+
sideMultiplier = isStart ? -1 : 1
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const offsetDist = 30 // Distance along line direction
|
|
1067
|
+
const perpDist = 20 // Perpendicular offset (fixed)
|
|
1068
|
+
|
|
1069
|
+
// Position: offset along line direction + fixed horizontal offset for vertical links
|
|
1070
|
+
let x: number
|
|
1071
|
+
let y: number
|
|
1072
|
+
|
|
1073
|
+
let anchor: string
|
|
1074
|
+
|
|
1075
|
+
if (isVertical) {
|
|
1076
|
+
// For vertical links, use fixed horizontal offset (simpler and consistent)
|
|
1077
|
+
x = endpoint.x + perpDist * sideMultiplier
|
|
1078
|
+
y = endpoint.y + ny * offsetDist
|
|
1079
|
+
|
|
1080
|
+
// Text anchor based on final position relative to endpoint
|
|
1081
|
+
anchor = 'middle'
|
|
1082
|
+
const labelDx = x - endpoint.x
|
|
1083
|
+
if (Math.abs(labelDx) > 8) {
|
|
1084
|
+
anchor = labelDx > 0 ? 'start' : 'end'
|
|
1085
|
+
}
|
|
1086
|
+
} else {
|
|
1087
|
+
// For horizontal links, position label near the port (not toward center)
|
|
1088
|
+
// Keep x near the endpoint, offset y below the line
|
|
1089
|
+
x = endpoint.x
|
|
1090
|
+
y = endpoint.y + perpDist // Always below the line
|
|
1091
|
+
|
|
1092
|
+
// Text anchor: extend toward the center of the link
|
|
1093
|
+
// Start endpoint extends right (start), end endpoint extends left (end)
|
|
1094
|
+
// Check direction: if nextPoint is to the left, we're on the right side
|
|
1095
|
+
anchor = nx < 0 ? 'end' : 'start'
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
return { x, y, anchor }
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Render endpoint labels (IP) with white background
|
|
1103
|
+
*/
|
|
1104
|
+
private renderEndpointLabels(lines: string[], x: number, y: number, anchor: string): string {
|
|
1105
|
+
if (lines.length === 0) return ''
|
|
1106
|
+
|
|
1107
|
+
const lineHeight = 11
|
|
1108
|
+
const paddingX = 2
|
|
1109
|
+
const paddingY = 2
|
|
1110
|
+
const charWidth = 4.8 // Approximate character width for 9px font
|
|
1111
|
+
|
|
1112
|
+
// Calculate dimensions
|
|
1113
|
+
const maxLen = Math.max(...lines.map((l) => l.length))
|
|
1114
|
+
const rectWidth = maxLen * charWidth + paddingX * 2
|
|
1115
|
+
const rectHeight = lines.length * lineHeight + paddingY * 2
|
|
1116
|
+
|
|
1117
|
+
// Adjust rect position based on text anchor
|
|
1118
|
+
let rectX = x - paddingX
|
|
1119
|
+
if (anchor === 'middle') {
|
|
1120
|
+
rectX = x - rectWidth / 2
|
|
1121
|
+
} else if (anchor === 'end') {
|
|
1122
|
+
rectX = x - rectWidth + paddingX
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const rectY = y - lineHeight + paddingY
|
|
1126
|
+
|
|
1127
|
+
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" />`
|
|
1128
|
+
|
|
1129
|
+
for (const [i, line] of lines.entries()) {
|
|
1130
|
+
const textY = y + i * lineHeight
|
|
1131
|
+
result += `\n<text x="${x}" y="${textY}" class="endpoint-label" text-anchor="${anchor}">${this.escapeXml(line)}</text>`
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return result
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private getLinkStrokeWidth(type: LinkType): number {
|
|
1138
|
+
switch (type) {
|
|
1139
|
+
case 'thick':
|
|
1140
|
+
return 3
|
|
1141
|
+
case 'double':
|
|
1142
|
+
return 2
|
|
1143
|
+
default:
|
|
1144
|
+
return 2
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Bandwidth rendering configuration - line count represents speed
|
|
1150
|
+
* 1G → 1 line
|
|
1151
|
+
* 10G → 2 lines
|
|
1152
|
+
* 25G → 3 lines
|
|
1153
|
+
* 40G → 4 lines
|
|
1154
|
+
* 100G → 5 lines
|
|
1155
|
+
*/
|
|
1156
|
+
private getBandwidthConfig(bandwidth?: string): { lineCount: number; strokeWidth: number } {
|
|
1157
|
+
const strokeWidth = 2
|
|
1158
|
+
switch (bandwidth) {
|
|
1159
|
+
case '1G':
|
|
1160
|
+
return { lineCount: 1, strokeWidth }
|
|
1161
|
+
case '10G':
|
|
1162
|
+
return { lineCount: 2, strokeWidth }
|
|
1163
|
+
case '25G':
|
|
1164
|
+
return { lineCount: 3, strokeWidth }
|
|
1165
|
+
case '40G':
|
|
1166
|
+
return { lineCount: 4, strokeWidth }
|
|
1167
|
+
case '100G':
|
|
1168
|
+
return { lineCount: 5, strokeWidth }
|
|
1169
|
+
default:
|
|
1170
|
+
return { lineCount: 1, strokeWidth }
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Render bandwidth lines (single or multiple parallel lines)
|
|
1176
|
+
*/
|
|
1177
|
+
private renderBandwidthLines(
|
|
1178
|
+
id: string,
|
|
1179
|
+
points: { x: number; y: number }[],
|
|
1180
|
+
stroke: string,
|
|
1181
|
+
strokeWidth: number,
|
|
1182
|
+
dasharray: string,
|
|
1183
|
+
markerEnd: string,
|
|
1184
|
+
config: { lineCount: number; strokeWidth: number },
|
|
1185
|
+
type: LinkType,
|
|
1186
|
+
): string {
|
|
1187
|
+
const { lineCount } = config
|
|
1188
|
+
const lineSpacing = 3 // Space between parallel lines
|
|
1189
|
+
|
|
1190
|
+
// Generate line paths
|
|
1191
|
+
const lines: string[] = []
|
|
1192
|
+
const basePath = this.generatePath(points)
|
|
1193
|
+
|
|
1194
|
+
if (lineCount === 1) {
|
|
1195
|
+
// Single line
|
|
1196
|
+
let linePath = `<path class="link" data-id="${id}" d="${basePath}"
|
|
1197
|
+
fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
|
|
1198
|
+
${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
|
|
1199
|
+
${markerEnd ? `marker-end="${markerEnd}"` : ''} pointer-events="none" />`
|
|
1200
|
+
|
|
1201
|
+
// Double line effect for redundancy types
|
|
1202
|
+
if (type === 'double') {
|
|
1203
|
+
linePath = `<path class="link-double-outer" d="${basePath}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" pointer-events="none" />
|
|
1204
|
+
<path class="link-double-inner" d="${basePath}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" pointer-events="none" />
|
|
1205
|
+
${linePath}`
|
|
1206
|
+
}
|
|
1207
|
+
lines.push(linePath)
|
|
1208
|
+
} else {
|
|
1209
|
+
// Multiple parallel lines
|
|
1210
|
+
const offsets = this.calculateLineOffsets(lineCount, lineSpacing)
|
|
1211
|
+
for (const offset of offsets) {
|
|
1212
|
+
const offsetPoints = this.offsetPoints(points, offset)
|
|
1213
|
+
const path = this.generatePath(offsetPoints)
|
|
1214
|
+
lines.push(`<path class="link" data-id="${id}" d="${path}"
|
|
1215
|
+
fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
|
|
1216
|
+
${dasharray ? `stroke-dasharray="${dasharray}"` : ''} pointer-events="none" />`)
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Calculate hit area width (same as actual lines, hidden underneath)
|
|
1221
|
+
const hitWidth = lineCount === 1 ? strokeWidth : (lineCount - 1) * lineSpacing + strokeWidth
|
|
1222
|
+
|
|
1223
|
+
// Wrap all lines in a group with transparent hit area on top
|
|
1224
|
+
return `<g class="link-lines">
|
|
1225
|
+
${lines.join('\n')}
|
|
1226
|
+
<path class="link-hit-area" d="${basePath}"
|
|
1227
|
+
fill="none" stroke="${stroke}" stroke-width="${hitWidth}" opacity="0" />
|
|
1228
|
+
</g>`
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Generate SVG path string from points with rounded corners
|
|
1233
|
+
*/
|
|
1234
|
+
private generatePath(points: { x: number; y: number }[], cornerRadius = 8): string {
|
|
1235
|
+
if (points.length < 2) return ''
|
|
1236
|
+
if (points.length === 2) {
|
|
1237
|
+
return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const parts: string[] = [`M ${points[0].x} ${points[0].y}`]
|
|
1241
|
+
|
|
1242
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
1243
|
+
const prev = points[i - 1]
|
|
1244
|
+
const curr = points[i]
|
|
1245
|
+
const next = points[i + 1]
|
|
1246
|
+
|
|
1247
|
+
// Calculate distances to prev and next points
|
|
1248
|
+
const distPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y)
|
|
1249
|
+
const distNext = Math.hypot(next.x - curr.x, next.y - curr.y)
|
|
1250
|
+
|
|
1251
|
+
// Limit radius to half the shortest segment
|
|
1252
|
+
const maxRadius = Math.min(distPrev, distNext) / 2
|
|
1253
|
+
const radius = Math.min(cornerRadius, maxRadius)
|
|
1254
|
+
|
|
1255
|
+
if (radius < 1) {
|
|
1256
|
+
// Too small for rounding, just use straight line
|
|
1257
|
+
parts.push(`L ${curr.x} ${curr.y}`)
|
|
1258
|
+
continue
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Calculate direction vectors
|
|
1262
|
+
const dirPrev = { x: (curr.x - prev.x) / distPrev, y: (curr.y - prev.y) / distPrev }
|
|
1263
|
+
const dirNext = { x: (next.x - curr.x) / distNext, y: (next.y - curr.y) / distNext }
|
|
1264
|
+
|
|
1265
|
+
// Points where curve starts and ends
|
|
1266
|
+
const startCurve = { x: curr.x - dirPrev.x * radius, y: curr.y - dirPrev.y * radius }
|
|
1267
|
+
const endCurve = { x: curr.x + dirNext.x * radius, y: curr.y + dirNext.y * radius }
|
|
1268
|
+
|
|
1269
|
+
// Line to start of curve, then quadratic bezier through corner
|
|
1270
|
+
parts.push(`L ${startCurve.x} ${startCurve.y}`)
|
|
1271
|
+
parts.push(`Q ${curr.x} ${curr.y} ${endCurve.x} ${endCurve.y}`)
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Line to final point
|
|
1275
|
+
const last = points[points.length - 1]
|
|
1276
|
+
parts.push(`L ${last.x} ${last.y}`)
|
|
1277
|
+
|
|
1278
|
+
return parts.join(' ')
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Calculate offsets for parallel lines (centered around 0)
|
|
1283
|
+
*/
|
|
1284
|
+
private calculateLineOffsets(lineCount: number, spacing: number): number[] {
|
|
1285
|
+
const offsets: number[] = []
|
|
1286
|
+
const totalWidth = (lineCount - 1) * spacing
|
|
1287
|
+
const startOffset = -totalWidth / 2
|
|
1288
|
+
|
|
1289
|
+
for (let i = 0; i < lineCount; i++) {
|
|
1290
|
+
offsets.push(startOffset + i * spacing)
|
|
1291
|
+
}
|
|
1292
|
+
return offsets
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Offset points perpendicular to line direction, handling each segment properly
|
|
1297
|
+
* For orthogonal paths, this maintains parallel lines through bends
|
|
1298
|
+
*/
|
|
1299
|
+
private offsetPoints(
|
|
1300
|
+
points: { x: number; y: number }[],
|
|
1301
|
+
offset: number,
|
|
1302
|
+
): { x: number; y: number }[] {
|
|
1303
|
+
if (points.length < 2) return points
|
|
1304
|
+
|
|
1305
|
+
const result: { x: number; y: number }[] = []
|
|
1306
|
+
|
|
1307
|
+
for (let i = 0; i < points.length; i++) {
|
|
1308
|
+
const p = points[i]
|
|
1309
|
+
|
|
1310
|
+
if (i === 0) {
|
|
1311
|
+
// First point: use direction to next point
|
|
1312
|
+
const next = points[i + 1]
|
|
1313
|
+
const perp = this.getPerpendicular(p, next)
|
|
1314
|
+
result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
|
|
1315
|
+
} else if (i === points.length - 1) {
|
|
1316
|
+
// Last point: use direction from previous point
|
|
1317
|
+
const prev = points[i - 1]
|
|
1318
|
+
const perp = this.getPerpendicular(prev, p)
|
|
1319
|
+
result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
|
|
1320
|
+
} else {
|
|
1321
|
+
// Middle point (bend): offset based on incoming segment direction
|
|
1322
|
+
const prev = points[i - 1]
|
|
1323
|
+
const perp = this.getPerpendicular(prev, p)
|
|
1324
|
+
result.push({ x: p.x + perp.x * offset, y: p.y + perp.y * offset })
|
|
1325
|
+
|
|
1326
|
+
// Also add a point for the outgoing segment if direction changes
|
|
1327
|
+
const next = points[i + 1]
|
|
1328
|
+
const perpNext = this.getPerpendicular(p, next)
|
|
1329
|
+
|
|
1330
|
+
// Check if direction changed (bend point)
|
|
1331
|
+
if (Math.abs(perp.x - perpNext.x) > 0.01 || Math.abs(perp.y - perpNext.y) > 0.01) {
|
|
1332
|
+
result.push({ x: p.x + perpNext.x * offset, y: p.y + perpNext.y * offset })
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
return result
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Get perpendicular unit vector for a line segment
|
|
1342
|
+
*/
|
|
1343
|
+
private getPerpendicular(
|
|
1344
|
+
from: { x: number; y: number },
|
|
1345
|
+
to: { x: number; y: number },
|
|
1346
|
+
): { x: number; y: number } {
|
|
1347
|
+
const dx = to.x - from.x
|
|
1348
|
+
const dy = to.y - from.y
|
|
1349
|
+
const len = Math.sqrt(dx * dx + dy * dy)
|
|
1350
|
+
|
|
1351
|
+
if (len === 0) return { x: 0, y: 0 }
|
|
1352
|
+
|
|
1353
|
+
// Perpendicular unit vector (rotate 90 degrees)
|
|
1354
|
+
return { x: -dy / len, y: dx / len }
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Get default link type based on redundancy
|
|
1359
|
+
*/
|
|
1360
|
+
private getDefaultLinkType(redundancy?: string): LinkType {
|
|
1361
|
+
switch (redundancy) {
|
|
1362
|
+
case 'ha':
|
|
1363
|
+
case 'vc':
|
|
1364
|
+
case 'vss':
|
|
1365
|
+
case 'vpc':
|
|
1366
|
+
case 'mlag':
|
|
1367
|
+
return 'double'
|
|
1368
|
+
case 'stack':
|
|
1369
|
+
return 'thick'
|
|
1370
|
+
default:
|
|
1371
|
+
return 'solid'
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Get default arrow type based on redundancy
|
|
1377
|
+
*/
|
|
1378
|
+
private getDefaultArrowType(_redundancy?: string): 'none' | 'forward' | 'back' | 'both' {
|
|
1379
|
+
// Network diagrams typically show bidirectional connections, so no arrow by default
|
|
1380
|
+
return 'none'
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* VLAN color palette - distinct colors for different VLANs
|
|
1385
|
+
*/
|
|
1386
|
+
private static readonly VLAN_COLORS = [
|
|
1387
|
+
'#dc2626', // Red
|
|
1388
|
+
'#ea580c', // Orange
|
|
1389
|
+
'#ca8a04', // Yellow
|
|
1390
|
+
'#16a34a', // Green
|
|
1391
|
+
'#0891b2', // Cyan
|
|
1392
|
+
'#2563eb', // Blue
|
|
1393
|
+
'#7c3aed', // Violet
|
|
1394
|
+
'#c026d3', // Magenta
|
|
1395
|
+
'#db2777', // Pink
|
|
1396
|
+
'#059669', // Emerald
|
|
1397
|
+
'#0284c7', // Light Blue
|
|
1398
|
+
'#4f46e5', // Indigo
|
|
1399
|
+
]
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Get stroke color based on VLANs
|
|
1403
|
+
*/
|
|
1404
|
+
private getVlanStroke(vlan?: number[]): string | undefined {
|
|
1405
|
+
if (!vlan || vlan.length === 0) {
|
|
1406
|
+
return undefined
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (vlan.length === 1) {
|
|
1410
|
+
// Single VLAN: use color based on VLAN ID
|
|
1411
|
+
const colorIndex = vlan[0] % SVGRenderer.VLAN_COLORS.length
|
|
1412
|
+
return SVGRenderer.VLAN_COLORS[colorIndex]
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// Multiple VLANs (trunk): use a combined hash color
|
|
1416
|
+
const hash = vlan.reduce((acc, v) => acc + v, 0)
|
|
1417
|
+
const colorIndex = hash % SVGRenderer.VLAN_COLORS.length
|
|
1418
|
+
return SVGRenderer.VLAN_COLORS[colorIndex]
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
private getLinkDasharray(type: LinkType): string {
|
|
1422
|
+
switch (type) {
|
|
1423
|
+
case 'dashed':
|
|
1424
|
+
return '5 3'
|
|
1425
|
+
case 'invisible':
|
|
1426
|
+
return '0'
|
|
1427
|
+
default:
|
|
1428
|
+
return ''
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
private getMidPoint(points: { x: number; y: number }[]): { x: number; y: number } {
|
|
1433
|
+
if (points.length === 4) {
|
|
1434
|
+
// Cubic bezier curve midpoint at t=0.5
|
|
1435
|
+
const t = 0.5
|
|
1436
|
+
const mt = 1 - t
|
|
1437
|
+
const x =
|
|
1438
|
+
mt * mt * mt * points[0].x +
|
|
1439
|
+
3 * mt * mt * t * points[1].x +
|
|
1440
|
+
3 * mt * t * t * points[2].x +
|
|
1441
|
+
t * t * t * points[3].x
|
|
1442
|
+
const y =
|
|
1443
|
+
mt * mt * mt * points[0].y +
|
|
1444
|
+
3 * mt * mt * t * points[1].y +
|
|
1445
|
+
3 * mt * t * t * points[2].y +
|
|
1446
|
+
t * t * t * points[3].y
|
|
1447
|
+
return { x, y }
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
if (points.length === 2) {
|
|
1451
|
+
// Simple midpoint between two points
|
|
1452
|
+
return {
|
|
1453
|
+
x: (points[0].x + points[1].x) / 2,
|
|
1454
|
+
y: (points[0].y + points[1].y) / 2,
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// For polylines, find the middle segment and get its midpoint
|
|
1459
|
+
const midIndex = Math.floor(points.length / 2)
|
|
1460
|
+
if (midIndex > 0 && midIndex < points.length) {
|
|
1461
|
+
return {
|
|
1462
|
+
x: (points[midIndex - 1].x + points[midIndex].x) / 2,
|
|
1463
|
+
y: (points[midIndex - 1].y + points[midIndex].y) / 2,
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
return points[midIndex] || points[0]
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
private escapeXml(str: string): string {
|
|
1471
|
+
return str
|
|
1472
|
+
.replace(/&/g, '&')
|
|
1473
|
+
.replace(/</g, '<')
|
|
1474
|
+
.replace(/>/g, '>')
|
|
1475
|
+
.replace(/"/g, '"')
|
|
1476
|
+
.replace(/'/g, ''')
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Simple string hash for consistent but varied label placement
|
|
1481
|
+
*/
|
|
1482
|
+
private hashString(str: string): number {
|
|
1483
|
+
let hash = 0
|
|
1484
|
+
for (let i = 0; i < str.length; i++) {
|
|
1485
|
+
const char = str.charCodeAt(i)
|
|
1486
|
+
hash = (hash << 5) - hash + char
|
|
1487
|
+
hash = hash & hash // Convert to 32-bit integer
|
|
1488
|
+
}
|
|
1489
|
+
return Math.abs(hash)
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// Namespace-style API
|
|
1494
|
+
export interface RenderOptions extends SVGRendererOptions {}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* Render NetworkGraph to SVG string
|
|
1498
|
+
*/
|
|
1499
|
+
export function render(graph: NetworkGraph, layout: LayoutResult, options?: RenderOptions): string {
|
|
1500
|
+
const renderer = new SVGRenderer(options)
|
|
1501
|
+
return renderer.render(graph, layout)
|
|
1502
|
+
}
|