@shumoku/core 0.2.0 → 0.2.3
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/icons/build-icons.js +3 -3
- package/dist/icons/build-icons.js.map +1 -1
- package/dist/icons/generated-icons.js +10 -10
- package/dist/icons/generated-icons.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -8
- package/dist/index.js.map +1 -1
- package/dist/layout/hierarchical.d.ts +1 -1
- package/dist/layout/hierarchical.d.ts.map +1 -1
- package/dist/layout/hierarchical.js +82 -66
- package/dist/layout/hierarchical.js.map +1 -1
- package/dist/layout/index.d.ts +1 -1
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js.map +1 -1
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +13 -13
- package/dist/models/types.js.map +1 -1
- package/dist/renderer/index.d.ts +4 -3
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +3 -2
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/svg.d.ts +26 -4
- package/dist/renderer/svg.d.ts.map +1 -1
- package/dist/renderer/svg.js +286 -147
- package/dist/renderer/svg.js.map +1 -1
- package/dist/renderer/types.d.ts +70 -0
- package/dist/renderer/types.d.ts.map +1 -0
- package/dist/renderer/types.js +6 -0
- package/dist/renderer/types.js.map +1 -0
- package/dist/themes/dark.d.ts.map +1 -1
- package/dist/themes/dark.js +1 -1
- package/dist/themes/dark.js.map +1 -1
- package/dist/themes/index.d.ts +3 -3
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +4 -4
- package/dist/themes/index.js.map +1 -1
- package/dist/themes/modern.d.ts.map +1 -1
- package/dist/themes/modern.js.map +1 -1
- package/dist/themes/types.d.ts.map +1 -1
- package/dist/themes/utils.d.ts +1 -1
- package/dist/themes/utils.d.ts.map +1 -1
- package/dist/themes/utils.js +5 -4
- package/dist/themes/utils.js.map +1 -1
- package/package.json +88 -92
- package/src/constants.ts +35 -35
- package/src/icons/build-icons.ts +12 -6
- package/src/icons/generated-icons.ts +12 -12
- package/src/index.test.ts +66 -0
- package/src/index.ts +6 -13
- package/src/layout/hierarchical.ts +1251 -1221
- package/src/layout/index.ts +1 -1
- package/src/models/types.ts +47 -37
- package/src/themes/dark.ts +15 -15
- package/src/themes/index.ts +7 -7
- package/src/themes/modern.ts +22 -22
- package/src/themes/types.ts +26 -26
- package/src/themes/utils.ts +25 -24
- package/src/renderer/index.ts +0 -6
- package/src/renderer/svg.ts +0 -1277
package/dist/renderer/svg.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* SVG Renderer
|
|
3
3
|
* Renders NetworkGraph to SVG
|
|
4
4
|
*/
|
|
5
|
-
import { getDeviceIcon, getVendorIconEntry } from '../icons/index.js';
|
|
6
5
|
import { DEFAULT_ICON_SIZE, ICON_LABEL_GAP, LABEL_LINE_HEIGHT, MAX_ICON_WIDTH_RATIO, } from '../constants.js';
|
|
6
|
+
import { getDeviceIcon, getVendorIconEntry } from '../icons/index.js';
|
|
7
7
|
const LIGHT_THEME = {
|
|
8
8
|
backgroundColor: '#ffffff',
|
|
9
9
|
defaultNodeFill: '#e2e8f0',
|
|
@@ -41,6 +41,8 @@ const DARK_THEME = {
|
|
|
41
41
|
const DEFAULT_OPTIONS = {
|
|
42
42
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
43
43
|
interactive: true,
|
|
44
|
+
renderMode: 'static',
|
|
45
|
+
dataAttributes: { device: true, link: true, metadata: true },
|
|
44
46
|
};
|
|
45
47
|
// ============================================
|
|
46
48
|
// SVG Renderer
|
|
@@ -52,6 +54,18 @@ export class SVGRenderer {
|
|
|
52
54
|
constructor(options) {
|
|
53
55
|
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
54
56
|
}
|
|
57
|
+
/** Check if interactive mode is enabled */
|
|
58
|
+
get isInteractive() {
|
|
59
|
+
return this.options.renderMode === 'interactive';
|
|
60
|
+
}
|
|
61
|
+
/** Get data attribute options with defaults */
|
|
62
|
+
get dataAttrs() {
|
|
63
|
+
return {
|
|
64
|
+
device: this.options.dataAttributes?.device ?? true,
|
|
65
|
+
link: this.options.dataAttributes?.link ?? true,
|
|
66
|
+
metadata: this.options.dataAttributes?.metadata ?? true,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
55
69
|
/**
|
|
56
70
|
* Get theme colors based on theme type
|
|
57
71
|
*/
|
|
@@ -96,21 +110,24 @@ export class SVGRenderer {
|
|
|
96
110
|
// Styles
|
|
97
111
|
parts.push(this.renderStyles());
|
|
98
112
|
// Layer 1: Subgraphs (background)
|
|
99
|
-
layout.subgraphs.
|
|
113
|
+
for (const sg of layout.subgraphs.values()) {
|
|
100
114
|
parts.push(this.renderSubgraph(sg));
|
|
101
|
-
}
|
|
102
|
-
// Layer 2:
|
|
103
|
-
layout.
|
|
104
|
-
parts.push(this.renderNodeBackground(node));
|
|
105
|
-
});
|
|
106
|
-
// Layer 3: Links (on top of node backgrounds)
|
|
107
|
-
layout.links.forEach((link) => {
|
|
115
|
+
}
|
|
116
|
+
// Layer 2: Links (below nodes)
|
|
117
|
+
for (const link of layout.links.values()) {
|
|
108
118
|
parts.push(this.renderLink(link, layout.nodes));
|
|
109
|
-
}
|
|
110
|
-
// Layer
|
|
111
|
-
layout.nodes.
|
|
112
|
-
parts.push(this.
|
|
113
|
-
}
|
|
119
|
+
}
|
|
120
|
+
// Layer 3: Nodes (bg + fg as one unit, without ports)
|
|
121
|
+
for (const node of layout.nodes.values()) {
|
|
122
|
+
parts.push(this.renderNode(node));
|
|
123
|
+
}
|
|
124
|
+
// Layer 4: Ports (separate layer on top of nodes)
|
|
125
|
+
for (const node of layout.nodes.values()) {
|
|
126
|
+
const portsRendered = this.renderPorts(node.id, node.position.x, node.position.y, node.ports);
|
|
127
|
+
if (portsRendered) {
|
|
128
|
+
parts.push(portsRendered);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
114
131
|
// Legend (if enabled) - use already calculated legendSettings
|
|
115
132
|
if (legendSettings.enabled && legendWidth > 0) {
|
|
116
133
|
parts.push(this.renderLegend(graph, layout, legendSettings));
|
|
@@ -131,10 +148,10 @@ export class SVGRenderer {
|
|
|
131
148
|
let itemCount = 0;
|
|
132
149
|
if (settings.showBandwidth) {
|
|
133
150
|
const usedBandwidths = new Set();
|
|
134
|
-
graph.links
|
|
151
|
+
for (const link of graph.links) {
|
|
135
152
|
if (link.bandwidth)
|
|
136
153
|
usedBandwidths.add(link.bandwidth);
|
|
137
|
-
}
|
|
154
|
+
}
|
|
138
155
|
itemCount += usedBandwidths.size;
|
|
139
156
|
}
|
|
140
157
|
if (itemCount === 0) {
|
|
@@ -181,19 +198,19 @@ export class SVGRenderer {
|
|
|
181
198
|
const maxLabelWidth = 100;
|
|
182
199
|
// Collect used bandwidths
|
|
183
200
|
const usedBandwidths = new Set();
|
|
184
|
-
graph.links
|
|
201
|
+
for (const link of graph.links) {
|
|
185
202
|
if (link.bandwidth)
|
|
186
203
|
usedBandwidths.add(link.bandwidth);
|
|
187
|
-
}
|
|
204
|
+
}
|
|
188
205
|
// Collect used device types
|
|
189
206
|
const usedDeviceTypes = new Set();
|
|
190
|
-
graph.nodes
|
|
207
|
+
for (const node of graph.nodes) {
|
|
191
208
|
if (node.type)
|
|
192
209
|
usedDeviceTypes.add(node.type);
|
|
193
|
-
}
|
|
210
|
+
}
|
|
194
211
|
// Build legend items
|
|
195
212
|
if (settings.showBandwidth && usedBandwidths.size > 0) {
|
|
196
|
-
const sortedBandwidths = ['1G', '10G', '25G', '40G', '100G'].filter(b => usedBandwidths.has(b));
|
|
213
|
+
const sortedBandwidths = ['1G', '10G', '25G', '40G', '100G'].filter((b) => usedBandwidths.has(b));
|
|
197
214
|
for (const bw of sortedBandwidths) {
|
|
198
215
|
const config = this.getBandwidthConfig(bw);
|
|
199
216
|
items.push({
|
|
@@ -226,18 +243,18 @@ export class SVGRenderer {
|
|
|
226
243
|
break;
|
|
227
244
|
}
|
|
228
245
|
// Render legend box
|
|
229
|
-
let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
|
|
230
|
-
<rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
|
|
231
|
-
fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
|
|
246
|
+
let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
|
|
247
|
+
<rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
|
|
248
|
+
fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
|
|
232
249
|
<text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`;
|
|
233
250
|
// Render items
|
|
234
|
-
items.
|
|
251
|
+
for (const [index, item] of items.entries()) {
|
|
235
252
|
const y = padding + 28 + index * lineHeight;
|
|
236
253
|
svg += `\n <g transform="translate(${padding}, ${y})">`;
|
|
237
254
|
svg += `\n ${item.icon}`;
|
|
238
255
|
svg += `\n <text x="${iconWidth + 4}" y="4" class="node-label" font-size="10">${this.escapeXml(item.label)}</text>`;
|
|
239
|
-
svg +=
|
|
240
|
-
}
|
|
256
|
+
svg += '\n </g>';
|
|
257
|
+
}
|
|
241
258
|
svg += '\n</g>';
|
|
242
259
|
return svg;
|
|
243
260
|
}
|
|
@@ -249,46 +266,59 @@ export class SVGRenderer {
|
|
|
249
266
|
const lineWidth = 24;
|
|
250
267
|
const strokeWidth = 2;
|
|
251
268
|
const offsets = this.calculateLineOffsets(lineCount, lineSpacing);
|
|
252
|
-
const lines = offsets.map(offset => {
|
|
269
|
+
const lines = offsets.map((offset) => {
|
|
253
270
|
const y = offset;
|
|
254
271
|
return `<line x1="0" y1="${y}" x2="${lineWidth}" y2="${y}" stroke="${this.themeColors.defaultLinkStroke}" stroke-width="${strokeWidth}" />`;
|
|
255
272
|
});
|
|
256
273
|
return `<g transform="translate(0, 0)">${lines.join('')}</g>`;
|
|
257
274
|
}
|
|
258
275
|
renderHeader(width, height, viewBox) {
|
|
259
|
-
return `<svg xmlns="http://www.w3.org/2000/svg"
|
|
260
|
-
viewBox="${viewBox}"
|
|
261
|
-
width="${width}"
|
|
262
|
-
height="${height}"
|
|
276
|
+
return `<svg xmlns="http://www.w3.org/2000/svg"
|
|
277
|
+
viewBox="${viewBox}"
|
|
278
|
+
width="${width}"
|
|
279
|
+
height="${height}"
|
|
263
280
|
style="background: ${this.themeColors.backgroundColor}">`;
|
|
264
281
|
}
|
|
265
282
|
renderDefs() {
|
|
266
|
-
return `<defs>
|
|
267
|
-
<!-- Arrow marker -->
|
|
268
|
-
<marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
269
|
-
<polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
|
|
270
|
-
</marker>
|
|
271
|
-
<marker id="arrow-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
272
|
-
<polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
|
|
273
|
-
</marker>
|
|
274
|
-
|
|
275
|
-
<!-- Filters -->
|
|
276
|
-
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
277
|
-
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
|
|
278
|
-
</filter>
|
|
283
|
+
return `<defs>
|
|
284
|
+
<!-- Arrow marker -->
|
|
285
|
+
<marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
286
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
|
|
287
|
+
</marker>
|
|
288
|
+
<marker id="arrow-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
289
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
|
|
290
|
+
</marker>
|
|
291
|
+
|
|
292
|
+
<!-- Filters -->
|
|
293
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
294
|
+
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
|
|
295
|
+
</filter>
|
|
279
296
|
</defs>`;
|
|
280
297
|
}
|
|
281
298
|
renderStyles() {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
299
|
+
// CSS variables for interactive runtime theming
|
|
300
|
+
const cssVars = this.isInteractive
|
|
301
|
+
? `
|
|
302
|
+
:root {
|
|
303
|
+
--shumoku-bg: ${this.themeColors.backgroundColor};
|
|
304
|
+
--shumoku-surface: ${this.themeColors.subgraphFill};
|
|
305
|
+
--shumoku-text: ${this.themeColors.labelColor};
|
|
306
|
+
--shumoku-text-secondary: ${this.themeColors.labelSecondaryColor};
|
|
307
|
+
--shumoku-border: ${this.themeColors.subgraphStroke};
|
|
308
|
+
--shumoku-node-fill: ${this.themeColors.defaultNodeFill};
|
|
309
|
+
--shumoku-node-stroke: ${this.themeColors.defaultNodeStroke};
|
|
310
|
+
--shumoku-link-stroke: ${this.themeColors.defaultLinkStroke};
|
|
311
|
+
--shumoku-font: ${this.options.fontFamily};
|
|
312
|
+
}`
|
|
313
|
+
: '';
|
|
314
|
+
return `<style>${cssVars}
|
|
315
|
+
.node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
|
|
316
|
+
.node-label-bold { font-weight: bold; }
|
|
317
|
+
.node-icon { color: ${this.themeColors.labelSecondaryColor}; }
|
|
318
|
+
.subgraph-icon { opacity: 0.9; }
|
|
319
|
+
.subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
|
|
320
|
+
.link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
|
|
321
|
+
.endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
|
|
292
322
|
</style>`;
|
|
293
323
|
}
|
|
294
324
|
renderSubgraph(sg) {
|
|
@@ -327,37 +357,48 @@ export class SVGRenderer {
|
|
|
327
357
|
if (iconContent.startsWith('<svg')) {
|
|
328
358
|
const viewBoxMatch = iconContent.match(/viewBox="0 0 (\d+) (\d+)"/);
|
|
329
359
|
if (viewBoxMatch) {
|
|
330
|
-
const vbWidth = parseInt(viewBoxMatch[1]);
|
|
331
|
-
const vbHeight = parseInt(viewBoxMatch[2]);
|
|
360
|
+
const vbWidth = Number.parseInt(viewBoxMatch[1], 10);
|
|
361
|
+
const vbHeight = Number.parseInt(viewBoxMatch[2], 10);
|
|
332
362
|
const aspectRatio = vbWidth / vbHeight;
|
|
333
363
|
const iconWidth = Math.round(iconSize * aspectRatio);
|
|
334
|
-
iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
|
|
335
|
-
<svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
|
|
336
|
-
${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
|
|
337
|
-
</svg>
|
|
364
|
+
iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
|
|
365
|
+
<svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
|
|
366
|
+
${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
|
|
367
|
+
</svg>
|
|
338
368
|
</g>`;
|
|
339
369
|
}
|
|
340
370
|
}
|
|
341
371
|
else {
|
|
342
372
|
// Use viewBox from entry
|
|
343
|
-
iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
|
|
344
|
-
<svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
|
|
345
|
-
${iconContent}
|
|
346
|
-
</svg>
|
|
373
|
+
iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
|
|
374
|
+
<svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
|
|
375
|
+
${iconContent}
|
|
376
|
+
</svg>
|
|
347
377
|
</g>`;
|
|
348
378
|
}
|
|
349
379
|
}
|
|
350
380
|
}
|
|
351
|
-
return `<g class="subgraph" data-id="${sg.id}">
|
|
352
|
-
<rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
|
|
353
|
-
rx="${rx}" ry="${rx}"
|
|
354
|
-
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
|
|
355
|
-
${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
|
|
356
|
-
${iconSvg}
|
|
357
|
-
<text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
|
|
381
|
+
return `<g class="subgraph" data-id="${sg.id}">
|
|
382
|
+
<rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
|
|
383
|
+
rx="${rx}" ry="${rx}"
|
|
384
|
+
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
|
|
385
|
+
${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
|
|
386
|
+
${iconSvg}
|
|
387
|
+
<text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
|
|
358
388
|
</g>`;
|
|
359
389
|
}
|
|
360
390
|
/** Render node background (shape only) */
|
|
391
|
+
/** Render complete node (bg + fg as one unit) */
|
|
392
|
+
renderNode(layoutNode) {
|
|
393
|
+
const { id, node } = layoutNode;
|
|
394
|
+
const dataAttrs = this.buildNodeDataAttributes(node);
|
|
395
|
+
const bg = this.renderNodeBackground(layoutNode);
|
|
396
|
+
const fg = this.renderNodeForeground(layoutNode);
|
|
397
|
+
return `<g class="node" data-id="${id}"${dataAttrs} >
|
|
398
|
+
${bg}
|
|
399
|
+
${fg}
|
|
400
|
+
</g>`;
|
|
401
|
+
}
|
|
361
402
|
renderNodeBackground(layoutNode) {
|
|
362
403
|
const { id, position, size, node } = layoutNode;
|
|
363
404
|
const x = position.x;
|
|
@@ -370,36 +411,72 @@ export class SVGRenderer {
|
|
|
370
411
|
const strokeWidth = style.strokeWidth || 1;
|
|
371
412
|
const strokeDasharray = style.strokeDasharray || '';
|
|
372
413
|
const shape = this.renderNodeShape(node.shape, x, y, w, h, fill, stroke, strokeWidth, strokeDasharray);
|
|
373
|
-
|
|
414
|
+
// Build data attributes for interactive mode
|
|
415
|
+
const dataAttrs = this.buildNodeDataAttributes(node);
|
|
416
|
+
return `<g class="node-bg" data-id="${id}"${dataAttrs}>${shape}</g>`;
|
|
374
417
|
}
|
|
375
|
-
/**
|
|
418
|
+
/** Build data attributes for a node (interactive mode only) */
|
|
419
|
+
buildNodeDataAttributes(node) {
|
|
420
|
+
if (!this.isInteractive || !this.dataAttrs.device)
|
|
421
|
+
return '';
|
|
422
|
+
const attrs = [];
|
|
423
|
+
if (node.type)
|
|
424
|
+
attrs.push(`data-device-type="${this.escapeXml(node.type)}"`);
|
|
425
|
+
if (node.vendor)
|
|
426
|
+
attrs.push(`data-device-vendor="${this.escapeXml(node.vendor)}"`);
|
|
427
|
+
if (node.model)
|
|
428
|
+
attrs.push(`data-device-model="${this.escapeXml(node.model)}"`);
|
|
429
|
+
if (node.service)
|
|
430
|
+
attrs.push(`data-device-service="${this.escapeXml(node.service)}"`);
|
|
431
|
+
if (node.resource)
|
|
432
|
+
attrs.push(`data-device-resource="${this.escapeXml(node.resource)}"`);
|
|
433
|
+
// Include full metadata as JSON
|
|
434
|
+
if (this.dataAttrs.metadata) {
|
|
435
|
+
const deviceInfo = {
|
|
436
|
+
id: node.id,
|
|
437
|
+
label: node.label,
|
|
438
|
+
type: node.type,
|
|
439
|
+
vendor: node.vendor,
|
|
440
|
+
model: node.model,
|
|
441
|
+
service: node.service,
|
|
442
|
+
resource: node.resource,
|
|
443
|
+
metadata: node.metadata,
|
|
444
|
+
};
|
|
445
|
+
attrs.push(`data-device-json="${this.escapeXml(JSON.stringify(deviceInfo))}"`);
|
|
446
|
+
}
|
|
447
|
+
return attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
|
|
448
|
+
}
|
|
449
|
+
/** Render node foreground (content only, ports rendered separately) */
|
|
376
450
|
renderNodeForeground(layoutNode) {
|
|
377
|
-
const { id, position, size, node
|
|
451
|
+
const { id, position, size, node } = layoutNode;
|
|
378
452
|
const x = position.x;
|
|
379
453
|
const y = position.y;
|
|
380
454
|
const w = size.width;
|
|
381
455
|
const content = this.renderNodeContent(node, x, y, w);
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
${
|
|
456
|
+
// Include data attributes for interactive mode (same as node-bg)
|
|
457
|
+
const dataAttrs = this.buildNodeDataAttributes(node);
|
|
458
|
+
return `<g class="node-fg" data-id="${id}"${dataAttrs}>
|
|
459
|
+
${content}
|
|
386
460
|
</g>`;
|
|
387
461
|
}
|
|
388
462
|
/**
|
|
389
|
-
* Render ports on a node
|
|
463
|
+
* Render ports on a node (as separate groups)
|
|
390
464
|
*/
|
|
391
|
-
renderPorts(nodeX, nodeY, ports) {
|
|
465
|
+
renderPorts(nodeId, nodeX, nodeY, ports) {
|
|
392
466
|
if (!ports || ports.size === 0)
|
|
393
467
|
return '';
|
|
394
|
-
const
|
|
395
|
-
ports.
|
|
468
|
+
const groups = [];
|
|
469
|
+
for (const port of ports.values()) {
|
|
396
470
|
const px = nodeX + port.position.x;
|
|
397
471
|
const py = nodeY + port.position.y;
|
|
398
472
|
const pw = port.size.width;
|
|
399
473
|
const ph = port.size.height;
|
|
474
|
+
// Port data attribute for interactive mode
|
|
475
|
+
const portDeviceAttr = this.isInteractive ? ` data-port-device="${nodeId}"` : '';
|
|
476
|
+
const parts = [];
|
|
400
477
|
// Port box
|
|
401
|
-
parts.push(`<rect class="port
|
|
402
|
-
x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
|
|
478
|
+
parts.push(`<rect class="port-box"
|
|
479
|
+
x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
|
|
403
480
|
fill="${this.themeColors.portFill}" stroke="${this.themeColors.portStroke}" stroke-width="1" rx="2" />`);
|
|
404
481
|
// Port label - position based on side
|
|
405
482
|
let labelX = px;
|
|
@@ -438,8 +515,12 @@ export class SVGRenderer {
|
|
|
438
515
|
const bgY = labelY - labelHeight + 3;
|
|
439
516
|
parts.push(`<rect class="port-label-bg" x="${bgX}" y="${bgY}" width="${labelWidth}" height="${labelHeight}" rx="2" fill="${this.themeColors.portLabelBg}" />`);
|
|
440
517
|
parts.push(`<text class="port-label" x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="9" fill="${this.themeColors.portLabelColor}">${labelText}</text>`);
|
|
441
|
-
|
|
442
|
-
|
|
518
|
+
// Wrap in a group with data attributes
|
|
519
|
+
groups.push(`<g class="port" data-port="${port.id}"${portDeviceAttr} >
|
|
520
|
+
${parts.join('\n ')}
|
|
521
|
+
</g>`);
|
|
522
|
+
}
|
|
523
|
+
return groups.join('\n');
|
|
443
524
|
}
|
|
444
525
|
renderNodeShape(shape, x, y, w, h, fill, stroke, strokeWidth, strokeDasharray) {
|
|
445
526
|
const dashAttr = strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : '';
|
|
@@ -447,40 +528,44 @@ export class SVGRenderer {
|
|
|
447
528
|
const halfH = h / 2;
|
|
448
529
|
switch (shape) {
|
|
449
530
|
case 'rect':
|
|
450
|
-
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
|
|
531
|
+
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
|
|
451
532
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
|
|
452
533
|
case 'rounded':
|
|
453
|
-
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
|
|
534
|
+
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
|
|
454
535
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
|
|
455
|
-
case 'circle':
|
|
536
|
+
case 'circle': {
|
|
456
537
|
const r = Math.min(halfW, halfH);
|
|
457
|
-
return `<circle cx="${x}" cy="${y}" r="${r}"
|
|
538
|
+
return `<circle cx="${x}" cy="${y}" r="${r}"
|
|
458
539
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
|
|
540
|
+
}
|
|
459
541
|
case 'diamond':
|
|
460
|
-
return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
|
|
542
|
+
return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
|
|
461
543
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
|
|
462
|
-
case 'hexagon':
|
|
544
|
+
case 'hexagon': {
|
|
463
545
|
const hx = halfW * 0.866;
|
|
464
|
-
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}"
|
|
546
|
+
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}"
|
|
465
547
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
|
|
466
|
-
|
|
548
|
+
}
|
|
549
|
+
case 'cylinder': {
|
|
467
550
|
const ellipseH = h * 0.15;
|
|
468
|
-
return `<g>
|
|
469
|
-
<ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
|
|
470
|
-
<rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
|
|
471
|
-
<line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
|
|
472
|
-
<line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
|
|
473
|
-
<ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />
|
|
551
|
+
return `<g>
|
|
552
|
+
<ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
|
|
553
|
+
<rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
|
|
554
|
+
<line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
|
|
555
|
+
<line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
|
|
556
|
+
<ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />
|
|
474
557
|
</g>`;
|
|
558
|
+
}
|
|
475
559
|
case 'stadium':
|
|
476
|
-
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
|
|
560
|
+
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
|
|
477
561
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
|
|
478
|
-
case 'trapezoid':
|
|
562
|
+
case 'trapezoid': {
|
|
479
563
|
const indent = w * 0.15;
|
|
480
|
-
return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
|
|
564
|
+
return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
|
|
481
565
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
|
|
566
|
+
}
|
|
482
567
|
default:
|
|
483
|
-
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
|
|
568
|
+
return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
|
|
484
569
|
fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
|
|
485
570
|
}
|
|
486
571
|
}
|
|
@@ -501,8 +586,8 @@ export class SVGRenderer {
|
|
|
501
586
|
if (vendorIcon.startsWith('<svg')) {
|
|
502
587
|
const viewBoxMatch = vendorIcon.match(/viewBox="0 0 (\d+) (\d+)"/);
|
|
503
588
|
if (viewBoxMatch) {
|
|
504
|
-
const vbWidth = parseInt(viewBoxMatch[1]);
|
|
505
|
-
const vbHeight = parseInt(viewBoxMatch[2]);
|
|
589
|
+
const vbWidth = Number.parseInt(viewBoxMatch[1], 10);
|
|
590
|
+
const vbHeight = Number.parseInt(viewBoxMatch[2], 10);
|
|
506
591
|
const aspectRatio = vbWidth / vbHeight;
|
|
507
592
|
let iconWidth = Math.round(DEFAULT_ICON_SIZE * aspectRatio);
|
|
508
593
|
let iconHeight = DEFAULT_ICON_SIZE;
|
|
@@ -521,10 +606,12 @@ export class SVGRenderer {
|
|
|
521
606
|
// Parse viewBox for aspect ratio calculation
|
|
522
607
|
const vbMatch = viewBox.match(/(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/);
|
|
523
608
|
if (vbMatch) {
|
|
524
|
-
const vbWidth = parseInt(vbMatch[3]);
|
|
525
|
-
const vbHeight = parseInt(vbMatch[4]);
|
|
609
|
+
const vbWidth = Number.parseInt(vbMatch[3], 10);
|
|
610
|
+
const vbHeight = Number.parseInt(vbMatch[4], 10);
|
|
526
611
|
const aspectRatio = vbWidth / vbHeight;
|
|
527
|
-
let iconWidth = Math.abs(aspectRatio - 1) < 0.01
|
|
612
|
+
let iconWidth = Math.abs(aspectRatio - 1) < 0.01
|
|
613
|
+
? DEFAULT_ICON_SIZE
|
|
614
|
+
: Math.round(DEFAULT_ICON_SIZE * aspectRatio);
|
|
528
615
|
let iconHeight = DEFAULT_ICON_SIZE;
|
|
529
616
|
if (iconWidth > maxIconWidth) {
|
|
530
617
|
iconWidth = maxIconWidth;
|
|
@@ -571,18 +658,18 @@ export class SVGRenderer {
|
|
|
571
658
|
// Render icon at top of content block
|
|
572
659
|
if (iconInfo) {
|
|
573
660
|
const iconY = contentTop;
|
|
574
|
-
parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
|
|
575
|
-
${iconInfo.svg}
|
|
661
|
+
parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
|
|
662
|
+
${iconInfo.svg}
|
|
576
663
|
</g>`);
|
|
577
664
|
}
|
|
578
665
|
// Render labels below icon
|
|
579
666
|
const labelStartY = contentTop + iconHeight + gap + LABEL_LINE_HEIGHT * 0.7; // 0.7 for text baseline adjustment
|
|
580
|
-
labels.
|
|
667
|
+
for (const [i, line] of labels.entries()) {
|
|
581
668
|
const isBold = line.includes('<b>') || line.includes('<strong>');
|
|
582
669
|
const cleanLine = line.replace(/<\/?b>|<\/?strong>|<br\s*\/?>/gi, '');
|
|
583
670
|
const className = isBold ? 'node-label node-label-bold' : 'node-label';
|
|
584
671
|
parts.push(`<text x="${x}" y="${labelStartY + i * LABEL_LINE_HEIGHT}" class="${className}" text-anchor="middle">${this.escapeXml(cleanLine)}</text>`);
|
|
585
|
-
}
|
|
672
|
+
}
|
|
586
673
|
return parts.join('\n ');
|
|
587
674
|
}
|
|
588
675
|
renderLink(layoutLink, nodes) {
|
|
@@ -609,9 +696,7 @@ export class SVGRenderer {
|
|
|
609
696
|
}
|
|
610
697
|
// VLANs (link-level, applies to both endpoints)
|
|
611
698
|
if (link.vlan && link.vlan.length > 0) {
|
|
612
|
-
const vlanText = link.vlan.length === 1
|
|
613
|
-
? `VLAN ${link.vlan[0]}`
|
|
614
|
-
: `VLAN ${link.vlan.join(', ')}`;
|
|
699
|
+
const vlanText = link.vlan.length === 1 ? `VLAN ${link.vlan[0]}` : `VLAN ${link.vlan.join(', ')}`;
|
|
615
700
|
result += `\n<text x="${midPoint.x}" y="${midPoint.y + labelYOffset}" class="link-label" text-anchor="middle">${this.escapeXml(vlanText)}</text>`;
|
|
616
701
|
}
|
|
617
702
|
// Get node center positions for label placement
|
|
@@ -632,7 +717,45 @@ export class SVGRenderer {
|
|
|
632
717
|
const labelPos = this.getEndpointLabelPosition(points, 'end', toNodeCenterX, portName);
|
|
633
718
|
result += this.renderEndpointLabels(toLabels, labelPos.x, labelPos.y, labelPos.anchor);
|
|
634
719
|
}
|
|
635
|
-
|
|
720
|
+
// Build data attributes for interactive mode
|
|
721
|
+
const dataAttrs = this.buildLinkDataAttributes(layoutLink);
|
|
722
|
+
return `<g class="link-group" data-link-id="${id}"${dataAttrs}>\n${result}\n</g>`;
|
|
723
|
+
}
|
|
724
|
+
/** Build data attributes for a link (interactive mode only) */
|
|
725
|
+
buildLinkDataAttributes(layoutLink) {
|
|
726
|
+
if (!this.isInteractive || !this.dataAttrs.link)
|
|
727
|
+
return '';
|
|
728
|
+
const { link, fromEndpoint, toEndpoint } = layoutLink;
|
|
729
|
+
const attrs = [];
|
|
730
|
+
// Basic link attributes
|
|
731
|
+
if (link.bandwidth)
|
|
732
|
+
attrs.push(`data-link-bandwidth="${this.escapeXml(link.bandwidth)}"`);
|
|
733
|
+
if (link.vlan && link.vlan.length > 0) {
|
|
734
|
+
attrs.push(`data-link-vlan="${link.vlan.join(',')}"`);
|
|
735
|
+
}
|
|
736
|
+
if (link.redundancy)
|
|
737
|
+
attrs.push(`data-link-redundancy="${this.escapeXml(link.redundancy)}"`);
|
|
738
|
+
// Endpoint info
|
|
739
|
+
const fromStr = fromEndpoint.port
|
|
740
|
+
? `${fromEndpoint.node}:${fromEndpoint.port}`
|
|
741
|
+
: fromEndpoint.node;
|
|
742
|
+
const toStr = toEndpoint.port ? `${toEndpoint.node}:${toEndpoint.port}` : toEndpoint.node;
|
|
743
|
+
attrs.push(`data-link-from="${this.escapeXml(fromStr)}"`);
|
|
744
|
+
attrs.push(`data-link-to="${this.escapeXml(toStr)}"`);
|
|
745
|
+
// Include full metadata as JSON
|
|
746
|
+
if (this.dataAttrs.metadata) {
|
|
747
|
+
const linkInfo = {
|
|
748
|
+
id: layoutLink.id,
|
|
749
|
+
from: fromEndpoint,
|
|
750
|
+
to: toEndpoint,
|
|
751
|
+
bandwidth: link.bandwidth,
|
|
752
|
+
vlan: link.vlan,
|
|
753
|
+
redundancy: link.redundancy,
|
|
754
|
+
label: link.label,
|
|
755
|
+
};
|
|
756
|
+
attrs.push(`data-link-json="${this.escapeXml(JSON.stringify(linkInfo))}"`);
|
|
757
|
+
}
|
|
758
|
+
return attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
|
|
636
759
|
}
|
|
637
760
|
formatEndpointLabels(endpoint) {
|
|
638
761
|
const parts = [];
|
|
@@ -722,7 +845,7 @@ export class SVGRenderer {
|
|
|
722
845
|
const paddingY = 2;
|
|
723
846
|
const charWidth = 4.8; // Approximate character width for 9px font
|
|
724
847
|
// Calculate dimensions
|
|
725
|
-
const maxLen = Math.max(...lines.map(l => l.length));
|
|
848
|
+
const maxLen = Math.max(...lines.map((l) => l.length));
|
|
726
849
|
const rectWidth = maxLen * charWidth + paddingX * 2;
|
|
727
850
|
const rectHeight = lines.length * lineHeight + paddingY * 2;
|
|
728
851
|
// Adjust rect position based on text anchor
|
|
@@ -735,17 +858,20 @@ export class SVGRenderer {
|
|
|
735
858
|
}
|
|
736
859
|
const rectY = y - lineHeight + paddingY;
|
|
737
860
|
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" />`;
|
|
738
|
-
lines.
|
|
861
|
+
for (const [i, line] of lines.entries()) {
|
|
739
862
|
const textY = y + i * lineHeight;
|
|
740
863
|
result += `\n<text x="${x}" y="${textY}" class="endpoint-label" text-anchor="${anchor}">${this.escapeXml(line)}</text>`;
|
|
741
|
-
}
|
|
864
|
+
}
|
|
742
865
|
return result;
|
|
743
866
|
}
|
|
744
867
|
getLinkStrokeWidth(type) {
|
|
745
868
|
switch (type) {
|
|
746
|
-
case 'thick':
|
|
747
|
-
|
|
748
|
-
|
|
869
|
+
case 'thick':
|
|
870
|
+
return 3;
|
|
871
|
+
case 'double':
|
|
872
|
+
return 2;
|
|
873
|
+
default:
|
|
874
|
+
return 2;
|
|
749
875
|
}
|
|
750
876
|
}
|
|
751
877
|
/**
|
|
@@ -779,32 +905,42 @@ export class SVGRenderer {
|
|
|
779
905
|
renderBandwidthLines(id, points, stroke, strokeWidth, dasharray, markerEnd, config, type) {
|
|
780
906
|
const { lineCount } = config;
|
|
781
907
|
const lineSpacing = 3; // Space between parallel lines
|
|
908
|
+
// Generate line paths
|
|
909
|
+
const lines = [];
|
|
910
|
+
const basePath = this.generatePath(points);
|
|
782
911
|
if (lineCount === 1) {
|
|
783
912
|
// Single line
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
${
|
|
788
|
-
${markerEnd ? `marker-end="${markerEnd}"` : ''} />`;
|
|
913
|
+
let linePath = `<path class="link" data-id="${id}" d="${basePath}"
|
|
914
|
+
fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
|
|
915
|
+
${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
|
|
916
|
+
${markerEnd ? `marker-end="${markerEnd}"` : ''} pointer-events="none" />`;
|
|
789
917
|
// Double line effect for redundancy types
|
|
790
918
|
if (type === 'double') {
|
|
791
|
-
|
|
792
|
-
<path class="link-double-inner" d="${
|
|
793
|
-
${
|
|
919
|
+
linePath = `<path class="link-double-outer" d="${basePath}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" pointer-events="none" />
|
|
920
|
+
<path class="link-double-inner" d="${basePath}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" pointer-events="none" />
|
|
921
|
+
${linePath}`;
|
|
794
922
|
}
|
|
795
|
-
|
|
923
|
+
lines.push(linePath);
|
|
796
924
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
|
|
805
|
-
${dasharray ? `stroke-dasharray="${dasharray}"` : ''} />`);
|
|
925
|
+
else {
|
|
926
|
+
// Multiple parallel lines
|
|
927
|
+
const offsets = this.calculateLineOffsets(lineCount, lineSpacing);
|
|
928
|
+
for (const offset of offsets) {
|
|
929
|
+
const offsetPoints = this.offsetPoints(points, offset);
|
|
930
|
+
const path = this.generatePath(offsetPoints);
|
|
931
|
+
lines.push(`<path class="link" data-id="${id}" d="${path}"
|
|
932
|
+
fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
|
|
933
|
+
${dasharray ? `stroke-dasharray="${dasharray}"` : ''} pointer-events="none" />`);
|
|
934
|
+
}
|
|
806
935
|
}
|
|
807
|
-
|
|
936
|
+
// Calculate hit area width (same as actual lines, hidden underneath)
|
|
937
|
+
const hitWidth = lineCount === 1 ? strokeWidth : (lineCount - 1) * lineSpacing + strokeWidth;
|
|
938
|
+
// Wrap all lines in a group with transparent hit area on top
|
|
939
|
+
return `<g class="link-lines">
|
|
940
|
+
${lines.join('\n')}
|
|
941
|
+
<path class="link-hit-area" d="${basePath}"
|
|
942
|
+
fill="none" stroke="${stroke}" stroke-width="${hitWidth}" opacity="0" />
|
|
943
|
+
</g>`;
|
|
808
944
|
}
|
|
809
945
|
/**
|
|
810
946
|
* Generate SVG path string from points with rounded corners
|
|
@@ -968,9 +1104,12 @@ ${result}`;
|
|
|
968
1104
|
}
|
|
969
1105
|
getLinkDasharray(type) {
|
|
970
1106
|
switch (type) {
|
|
971
|
-
case 'dashed':
|
|
972
|
-
|
|
973
|
-
|
|
1107
|
+
case 'dashed':
|
|
1108
|
+
return '5 3';
|
|
1109
|
+
case 'invisible':
|
|
1110
|
+
return '0';
|
|
1111
|
+
default:
|
|
1112
|
+
return '';
|
|
974
1113
|
}
|
|
975
1114
|
}
|
|
976
1115
|
getMidPoint(points) {
|
|
@@ -1020,7 +1159,7 @@ ${result}`;
|
|
|
1020
1159
|
let hash = 0;
|
|
1021
1160
|
for (let i = 0; i < str.length; i++) {
|
|
1022
1161
|
const char = str.charCodeAt(i);
|
|
1023
|
-
hash = (
|
|
1162
|
+
hash = (hash << 5) - hash + char;
|
|
1024
1163
|
hash = hash & hash; // Convert to 32-bit integer
|
|
1025
1164
|
}
|
|
1026
1165
|
return Math.abs(hash);
|