@shumoku/renderer 0.2.3 → 0.2.5

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/svg.d.ts CHANGED
@@ -4,6 +4,13 @@
4
4
  */
5
5
  import type { LayoutResult, NetworkGraph } from '@shumoku/core';
6
6
  import type { DataAttributeOptions, RenderMode } from './types.js';
7
+ /** Embedded content for a subgraph */
8
+ export interface EmbeddedSubgraphContent {
9
+ /** SVG content to embed (inner content, without outer <svg> tag) */
10
+ svgContent: string;
11
+ /** ViewBox of the embedded content */
12
+ viewBox: string;
13
+ }
7
14
  export interface SVGRendererOptions {
8
15
  /** Font family */
9
16
  fontFamily?: string;
@@ -20,6 +27,16 @@ export interface SVGRendererOptions {
20
27
  * Only used when renderMode is 'interactive'
21
28
  */
22
29
  dataAttributes?: DataAttributeOptions;
30
+ /**
31
+ * Unique sheet ID for generating unique filter/marker IDs
32
+ * Required when multiple SVGs are embedded in the same HTML page
33
+ */
34
+ sheetId?: string;
35
+ /**
36
+ * Embedded content for subgraphs (subgraphId -> content)
37
+ * Used to embed child SVG content into parent subgraph boxes
38
+ */
39
+ embeddedContent?: Map<string, EmbeddedSubgraphContent>;
23
40
  }
24
41
  export declare class SVGRenderer {
25
42
  private options;
@@ -30,6 +47,14 @@ export declare class SVGRenderer {
30
47
  private get isInteractive();
31
48
  /** Get data attribute options with defaults */
32
49
  private get dataAttrs();
50
+ /** Get unique ID suffix for this sheet */
51
+ private get idSuffix();
52
+ /** Get shadow filter ID */
53
+ private get shadowId();
54
+ /** Get arrow marker ID */
55
+ private get arrowId();
56
+ /** Get red arrow marker ID */
57
+ private get arrowRedId();
33
58
  /**
34
59
  * Get theme colors based on theme type
35
60
  */
@@ -80,6 +105,8 @@ export declare class SVGRenderer {
80
105
  * Render node content (icon + label) with dynamic vertical centering
81
106
  */
82
107
  private renderNodeContent;
108
+ /** Render content for export connector nodes */
109
+ private renderExportConnectorContent;
83
110
  private renderLink;
84
111
  /** Build data attributes for a link (interactive mode only) */
85
112
  private buildLinkDataAttributes;
package/dist/svg.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"svg.d.ts","sourceRoot":"","sources":["../src/svg.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAKV,YAAY,EAKZ,YAAY,EAIb,MAAM,eAAe,CAAA;AAUtB,OAAO,KAAK,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAgElE,MAAM,WAAW,kBAAkB;IACjC,kBAAkB;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,gEAAgE;IAChE,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB;;;;OAIG;IACH,UAAU,CAAC,EAAE,UAAU,CAAA;IACvB;;;OAGG;IACH,cAAc,CAAC,EAAE,oBAAoB,CAAA;CACtC;AAaD,qBAAa,WAAW;IACtB,OAAO,CAAC,OAAO,CAA8B;IAC7C,OAAO,CAAC,WAAW,CAA2B;IAC9C,OAAO,CAAC,SAAS,CAA8B;gBAEnC,OAAO,CAAC,EAAE,kBAAkB;IAIxC,2CAA2C;IAC3C,OAAO,KAAK,aAAa,GAExB;IAED,+CAA+C;IAC/C,OAAO,KAAK,SAAS,GAMpB;IAED;;OAEG;IACH,OAAO,CAAC,cAAc;IAItB;;OAEG;IACH,OAAO,CAAC,YAAY;IAIpB,MAAM,CAAC,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,GAAG,MAAM;IAyEzD;;OAEG;IACH,OAAO,CAAC,yBAAyB;IA8BjC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA4BzB;;OAEG;IACH,OAAO,CAAC,YAAY;IAmFpB;;OAEG;IACH,OAAO,CAAC,yBAAyB;IAcjC,OAAO,CAAC,YAAY;IAQpB,OAAO,CAAC,UAAU;IAiBlB,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,cAAc;IA2EtB,0CAA0C;IAC1C,iDAAiD;IACjD,OAAO,CAAC,UAAU;IAYlB,OAAO,CAAC,oBAAoB;IA+B5B,+DAA+D;IAC/D,OAAO,CAAC,uBAAuB;IA6B/B,uEAAuE;IACvE,OAAO,CAAC,oBAAoB;IAgB5B;;OAEG;IACH,OAAO,CAAC,WAAW;IAgFnB,OAAO,CAAC,eAAe;IAmEvB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA+EzB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAqCzB,OAAO,CAAC,UAAU;IA2ElB,+DAA+D;IAC/D,OAAO,CAAC,uBAAuB;IAsC/B,OAAO,CAAC,oBAAoB;IAO5B;;;;OAIG;IACH,OAAO,CAAC,wBAAwB;IAmFhC;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAiC5B,OAAO,CAAC,kBAAkB;IAW1B;;;;;;;OAOG;IACH,OAAO,CAAC,kBAAkB;IAkB1B;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAsD5B;;OAEG;IACH,OAAO,CAAC,YAAY;IA+CpB;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAW5B;;;OAGG;IACH,OAAO,CAAC,YAAY;IAyCpB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAcxB;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAe1B;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAK3B;;OAEG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAalC;IAED;;OAEG;IACH,OAAO,CAAC,aAAa;IAiBrB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,WAAW;IAsCnB,OAAO,CAAC,SAAS;IASjB;;OAEG;IACH,OAAO,CAAC,UAAU;CASnB;AAGD,MAAM,WAAW,aAAc,SAAQ,kBAAkB;CAAG;AAE5D;;GAEG;AACH,wBAAgB,MAAM,CAAC,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAGjG"}
1
+ {"version":3,"file":"svg.d.ts","sourceRoot":"","sources":["../src/svg.ts"],"names":[],"mappings":"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"}
package/dist/svg.js CHANGED
@@ -42,6 +42,8 @@ const DEFAULT_OPTIONS = {
42
42
  interactive: true,
43
43
  renderMode: 'static',
44
44
  dataAttributes: { device: true, link: true, metadata: true },
45
+ sheetId: '',
46
+ embeddedContent: new Map(),
45
47
  };
46
48
  // ============================================
47
49
  // SVG Renderer
@@ -65,6 +67,22 @@ export class SVGRenderer {
65
67
  metadata: this.options.dataAttributes?.metadata ?? true,
66
68
  };
67
69
  }
70
+ /** Get unique ID suffix for this sheet */
71
+ get idSuffix() {
72
+ return this.options.sheetId ? `-${this.options.sheetId}` : '';
73
+ }
74
+ /** Get shadow filter ID */
75
+ get shadowId() {
76
+ return `shadow${this.idSuffix}`;
77
+ }
78
+ /** Get arrow marker ID */
79
+ get arrowId() {
80
+ return `arrow${this.idSuffix}`;
81
+ }
82
+ /** Get red arrow marker ID */
83
+ get arrowRedId() {
84
+ return `arrow-red${this.idSuffix}`;
85
+ }
68
86
  /**
69
87
  * Get theme colors based on theme type
70
88
  */
@@ -242,9 +260,9 @@ export class SVGRenderer {
242
260
  break;
243
261
  }
244
262
  // Render legend box
245
- let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
246
- <rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
247
- fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
263
+ let svg = `<g class="legend" transform="translate(${legendX}, ${legendY})">
264
+ <rect x="0" y="0" width="${legendWidth}" height="${legendHeight}" rx="4"
265
+ fill="${this.themeColors.backgroundColor}" stroke="${this.themeColors.subgraphStroke}" stroke-width="1" opacity="0.95" />
248
266
  <text x="${padding}" y="${padding + 12}" class="subgraph-label" font-size="11">Legend</text>`;
249
267
  // Render items
250
268
  for (const [index, item] of items.entries()) {
@@ -272,52 +290,52 @@ export class SVGRenderer {
272
290
  return `<g transform="translate(0, 0)">${lines.join('')}</g>`;
273
291
  }
274
292
  renderHeader(width, height, viewBox) {
275
- return `<svg xmlns="http://www.w3.org/2000/svg"
276
- viewBox="${viewBox}"
277
- width="${width}"
278
- height="${height}"
293
+ return `<svg xmlns="http://www.w3.org/2000/svg"
294
+ viewBox="${viewBox}"
295
+ width="${width}"
296
+ height="${height}"
279
297
  style="background: ${this.themeColors.backgroundColor}">`;
280
298
  }
281
299
  renderDefs() {
282
- return `<defs>
283
- <!-- Arrow marker -->
284
- <marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
285
- <polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
286
- </marker>
287
- <marker id="arrow-red" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
288
- <polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
289
- </marker>
290
-
291
- <!-- Filters -->
292
- <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
293
- <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
294
- </filter>
300
+ return `<defs>
301
+ <!-- Arrow marker -->
302
+ <marker id="${this.arrowId}" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
303
+ <polygon points="0 0, 10 3.5, 0 7" fill="${this.themeColors.defaultLinkStroke}" />
304
+ </marker>
305
+ <marker id="${this.arrowRedId}" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
306
+ <polygon points="0 0, 10 3.5, 0 7" fill="#dc2626" />
307
+ </marker>
308
+
309
+ <!-- Filters -->
310
+ <filter id="${this.shadowId}" x="-20%" y="-20%" width="140%" height="140%">
311
+ <feDropShadow dx="2" dy="2" stdDeviation="3" flood-opacity="0.15"/>
312
+ </filter>
295
313
  </defs>`;
296
314
  }
297
315
  renderStyles() {
298
316
  // CSS variables for interactive runtime theming
299
317
  const cssVars = this.isInteractive
300
- ? `
301
- :root {
302
- --shumoku-bg: ${this.themeColors.backgroundColor};
303
- --shumoku-surface: ${this.themeColors.subgraphFill};
304
- --shumoku-text: ${this.themeColors.labelColor};
305
- --shumoku-text-secondary: ${this.themeColors.labelSecondaryColor};
306
- --shumoku-border: ${this.themeColors.subgraphStroke};
307
- --shumoku-node-fill: ${this.themeColors.defaultNodeFill};
308
- --shumoku-node-stroke: ${this.themeColors.defaultNodeStroke};
309
- --shumoku-link-stroke: ${this.themeColors.defaultLinkStroke};
310
- --shumoku-font: ${this.options.fontFamily};
318
+ ? `
319
+ :root {
320
+ --shumoku-bg: ${this.themeColors.backgroundColor};
321
+ --shumoku-surface: ${this.themeColors.subgraphFill};
322
+ --shumoku-text: ${this.themeColors.labelColor};
323
+ --shumoku-text-secondary: ${this.themeColors.labelSecondaryColor};
324
+ --shumoku-border: ${this.themeColors.subgraphStroke};
325
+ --shumoku-node-fill: ${this.themeColors.defaultNodeFill};
326
+ --shumoku-node-stroke: ${this.themeColors.defaultNodeStroke};
327
+ --shumoku-link-stroke: ${this.themeColors.defaultLinkStroke};
328
+ --shumoku-font: ${this.options.fontFamily};
311
329
  }`
312
330
  : '';
313
- return `<style>${cssVars}
314
- .node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
315
- .node-label-bold { font-weight: bold; }
316
- .node-icon { color: ${this.themeColors.labelSecondaryColor}; }
317
- .subgraph-icon { opacity: 0.9; }
318
- .subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
319
- .link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
320
- .endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
331
+ return `<style>${cssVars}
332
+ .node-label { font-family: ${this.options.fontFamily}; font-size: 12px; fill: ${this.themeColors.labelColor}; }
333
+ .node-label-bold { font-weight: bold; }
334
+ .node-icon { color: ${this.themeColors.labelSecondaryColor}; }
335
+ .subgraph-icon { opacity: 0.9; }
336
+ .subgraph-label { font-family: ${this.options.fontFamily}; font-size: 14px; font-weight: 600; fill: ${this.themeColors.subgraphLabelColor}; }
337
+ .link-label { font-family: ${this.options.fontFamily}; font-size: 11px; fill: ${this.themeColors.labelSecondaryColor}; }
338
+ .endpoint-label { font-family: ${this.options.fontFamily}; font-size: 9px; fill: ${this.themeColors.labelColor}; }
321
339
  </style>`;
322
340
  }
323
341
  renderSubgraph(sg) {
@@ -360,30 +378,92 @@ export class SVGRenderer {
360
378
  const vbHeight = Number.parseInt(viewBoxMatch[2], 10);
361
379
  const aspectRatio = vbWidth / vbHeight;
362
380
  const iconWidth = Math.round(iconSize * aspectRatio);
363
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
364
- <svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
365
- ${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
366
- </svg>
381
+ iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
382
+ <svg width="${iconWidth}" height="${iconSize}" viewBox="0 0 ${vbWidth} ${vbHeight}">
383
+ ${iconContent.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')}
384
+ </svg>
367
385
  </g>`;
368
386
  }
369
387
  }
370
388
  else {
371
389
  // Use viewBox from entry
372
- iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
373
- <svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
374
- ${iconContent}
375
- </svg>
390
+ iconSvg = `<g class="subgraph-icon" transform="translate(${iconX}, ${iconY})">
391
+ <svg width="${iconSize}" height="${iconSize}" viewBox="${viewBox}">
392
+ ${iconContent}
393
+ </svg>
376
394
  </g>`;
377
395
  }
378
396
  }
379
397
  }
380
- return `<g class="subgraph" data-id="${sg.id}">
381
- <rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
382
- rx="${rx}" ry="${rx}"
383
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
384
- ${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
385
- ${iconSvg}
386
- <text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>
398
+ // Hierarchical navigation attributes
399
+ const hasSheet = subgraph.file || (subgraph.pins && subgraph.pins.length > 0);
400
+ const sheetAttrs = hasSheet ? ` data-has-sheet="true" data-sheet-id="${sg.id}"` : '';
401
+ // Check for embedded content
402
+ const embeddedContent = this.options.embeddedContent?.get(sg.id);
403
+ let embeddedSvg = '';
404
+ if (embeddedContent) {
405
+ // Calculate content area (below label, with padding)
406
+ const labelHeight = 30; // Space for label
407
+ const padding = 10;
408
+ const contentX = bounds.x + padding;
409
+ const contentY = bounds.y + labelHeight;
410
+ const contentWidth = bounds.width - padding * 2;
411
+ const contentHeight = bounds.height - labelHeight - padding;
412
+ // Embed child SVG with viewBox for automatic scaling
413
+ embeddedSvg = `
414
+ <svg x="${contentX}" y="${contentY}" width="${contentWidth}" height="${contentHeight}"
415
+ viewBox="${embeddedContent.viewBox}" preserveAspectRatio="xMidYMid meet">
416
+ ${embeddedContent.svgContent}
417
+ </svg>`;
418
+ }
419
+ // Render boundary ports for hierarchical connections
420
+ let portsSvg = '';
421
+ if (sg.ports && sg.ports.size > 0) {
422
+ const portParts = [];
423
+ const centerX = bounds.x + bounds.width / 2;
424
+ const centerY = bounds.y + bounds.height / 2;
425
+ for (const port of sg.ports.values()) {
426
+ const px = centerX + port.position.x;
427
+ const py = centerY + port.position.y;
428
+ const pw = port.size.width;
429
+ const ph = port.size.height;
430
+ // Port circle/diamond on boundary
431
+ portParts.push(`<circle class="subgraph-port" cx="${px}" cy="${py}" r="${Math.max(pw, ph) / 2 + 2}"
432
+ fill="#3b82f6" stroke="#1d4ed8" stroke-width="2" />`);
433
+ // Port label
434
+ let labelX = px;
435
+ let labelY = py;
436
+ let anchor = 'middle';
437
+ const labelOffset = 16;
438
+ switch (port.side) {
439
+ case 'top':
440
+ labelY = py - labelOffset;
441
+ break;
442
+ case 'bottom':
443
+ labelY = py + labelOffset + 4;
444
+ break;
445
+ case 'left':
446
+ labelX = px - labelOffset;
447
+ anchor = 'end';
448
+ break;
449
+ case 'right':
450
+ labelX = px + labelOffset;
451
+ anchor = 'start';
452
+ break;
453
+ }
454
+ portParts.push(`<text x="${labelX}" y="${labelY}" class="port-label" text-anchor="${anchor}"
455
+ fill="#3b82f6" font-size="10" font-weight="500">${this.escapeXml(port.label)}</text>`);
456
+ }
457
+ portsSvg = portParts.join('\n ');
458
+ }
459
+ return `<g class="subgraph" data-id="${sg.id}"${sheetAttrs}>
460
+ <rect x="${bounds.x}" y="${bounds.y}" width="${bounds.width}" height="${bounds.height}"
461
+ rx="${rx}" ry="${rx}"
462
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"
463
+ ${strokeDasharray ? `stroke-dasharray="${strokeDasharray}"` : ''} />
464
+ ${iconSvg}
465
+ <text x="${labelX}" y="${labelY}" class="subgraph-label" text-anchor="${textAnchor}">${this.escapeXml(subgraph.label)}</text>${embeddedSvg}
466
+ ${portsSvg}
387
467
  </g>`;
388
468
  }
389
469
  /** Render node background (shape only) */
@@ -393,9 +473,9 @@ export class SVGRenderer {
393
473
  const dataAttrs = this.buildNodeDataAttributes(node);
394
474
  const bg = this.renderNodeBackground(layoutNode);
395
475
  const fg = this.renderNodeForeground(layoutNode);
396
- return `<g class="node" data-id="${id}"${dataAttrs}>
397
- ${bg}
398
- ${fg}
476
+ return `<g class="node" data-id="${id}"${dataAttrs}>
477
+ ${bg}
478
+ ${fg}
399
479
  </g>`;
400
480
  }
401
481
  renderNodeBackground(layoutNode) {
@@ -405,8 +485,15 @@ ${fg}
405
485
  const w = size.width;
406
486
  const h = size.height;
407
487
  const style = node.style || {};
408
- const fill = style.fill || this.themeColors.defaultNodeFill;
409
- const stroke = style.stroke || this.themeColors.defaultNodeStroke;
488
+ // Check if this is an export connector node (for hierarchical diagrams)
489
+ const isExport = node.metadata?._isExport === true;
490
+ // Special styling for export connector nodes - use subgraph colors
491
+ let fill = style.fill || this.themeColors.defaultNodeFill;
492
+ let stroke = style.stroke || this.themeColors.defaultNodeStroke;
493
+ if (isExport) {
494
+ fill = style.fill || this.themeColors.subgraphFill;
495
+ stroke = style.stroke || this.themeColors.defaultNodeStroke;
496
+ }
410
497
  const strokeWidth = style.strokeWidth || 1;
411
498
  const strokeDasharray = style.strokeDasharray || '';
412
499
  const shape = this.renderNodeShape(node.shape, x, y, w, h, fill, stroke, strokeWidth, strokeDasharray);
@@ -454,8 +541,8 @@ ${fg}
454
541
  const content = this.renderNodeContent(node, x, y, w);
455
542
  // Include data attributes for interactive mode (same as node-bg)
456
543
  const dataAttrs = this.buildNodeDataAttributes(node);
457
- return `<g class="node-fg" data-id="${id}"${dataAttrs}>
458
- ${content}
544
+ return `<g class="node-fg" data-id="${id}"${dataAttrs}>
545
+ ${content}
459
546
  </g>`;
460
547
  }
461
548
  /**
@@ -474,8 +561,8 @@ ${fg}
474
561
  const portDeviceAttr = this.isInteractive ? ` data-port-device="${nodeId}"` : '';
475
562
  const parts = [];
476
563
  // Port box
477
- parts.push(`<rect class="port-box"
478
- x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
564
+ parts.push(`<rect class="port-box"
565
+ x="${px - pw / 2}" y="${py - ph / 2}" width="${pw}" height="${ph}"
479
566
  fill="${this.themeColors.portFill}" stroke="${this.themeColors.portStroke}" stroke-width="1" rx="2" />`);
480
567
  // Port label - position based on side
481
568
  let labelX = px;
@@ -515,8 +602,8 @@ ${fg}
515
602
  parts.push(`<rect class="port-label-bg" x="${bgX}" y="${bgY}" width="${labelWidth}" height="${labelHeight}" rx="2" fill="${this.themeColors.portLabelBg}" />`);
516
603
  parts.push(`<text class="port-label" x="${labelX}" y="${labelY}" text-anchor="${textAnchor}" font-size="9" fill="${this.themeColors.portLabelColor}">${labelText}</text>`);
517
604
  // Wrap in a group with data attributes
518
- groups.push(`<g class="port" data-port="${port.id}"${portDeviceAttr}>
519
- ${parts.join('\n ')}
605
+ groups.push(`<g class="port" data-port="${port.id}"${portDeviceAttr}>
606
+ ${parts.join('\n ')}
520
607
  </g>`);
521
608
  }
522
609
  return groups.join('\n');
@@ -527,45 +614,45 @@ ${fg}
527
614
  const halfH = h / 2;
528
615
  switch (shape) {
529
616
  case 'rect':
530
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
531
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
617
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}"
618
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`;
532
619
  case 'rounded':
533
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
534
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
620
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="8" ry="8"
621
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`;
535
622
  case 'circle': {
536
623
  const r = Math.min(halfW, halfH);
537
- return `<circle cx="${x}" cy="${y}" r="${r}"
538
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
624
+ return `<circle cx="${x}" cy="${y}" r="${r}"
625
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`;
539
626
  }
540
627
  case 'diamond':
541
- return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
542
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
628
+ return `<polygon points="${x},${y - halfH} ${x + halfW},${y} ${x},${y + halfH} ${x - halfW},${y}"
629
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`;
543
630
  case 'hexagon': {
544
631
  const hx = halfW * 0.866;
545
- 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
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
632
+ 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}"
633
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`;
547
634
  }
548
635
  case 'cylinder': {
549
636
  const ellipseH = h * 0.15;
550
- return `<g>
551
- <ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
552
- <rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
553
- <line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
554
- <line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
555
- <ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />
637
+ return `<g>
638
+ <ellipse cx="${x}" cy="${y + halfH - ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} />
639
+ <rect x="${x - halfW}" y="${y - halfH + ellipseH}" width="${w}" height="${h - ellipseH * 2}" fill="${fill}" stroke="none" />
640
+ <line x1="${x - halfW}" y1="${y - halfH + ellipseH}" x2="${x - halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
641
+ <line x1="${x + halfW}" y1="${y - halfH + ellipseH}" x2="${x + halfW}" y2="${y + halfH - ellipseH}" stroke="${stroke}" stroke-width="${strokeWidth}" />
642
+ <ellipse cx="${x}" cy="${y - halfH + ellipseH}" rx="${halfW}" ry="${ellipseH}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />
556
643
  </g>`;
557
644
  }
558
645
  case 'stadium':
559
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
560
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
646
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="${halfH}" ry="${halfH}"
647
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`;
561
648
  case 'trapezoid': {
562
649
  const indent = w * 0.15;
563
- return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
564
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
650
+ return `<polygon points="${x - halfW + indent},${y - halfH} ${x + halfW - indent},${y - halfH} ${x + halfW},${y + halfH} ${x - halfW},${y + halfH}"
651
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`;
565
652
  }
566
653
  default:
567
- return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
568
- fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#shadow)" />`;
654
+ return `<rect x="${x - halfW}" y="${y - halfH}" width="${w}" height="${h}" rx="4" ry="4"
655
+ fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" ${dashAttr} filter="url(#${this.shadowId})" />`;
569
656
  }
570
657
  }
571
658
  /**
@@ -644,6 +731,12 @@ ${fg}
644
731
  * Render node content (icon + label) with dynamic vertical centering
645
732
  */
646
733
  renderNodeContent(node, x, y, w) {
734
+ // Check if this is an export connector node
735
+ const isExport = node.metadata?._isExport === true;
736
+ // For export connectors, render with arrow icon
737
+ if (isExport) {
738
+ return this.renderExportConnectorContent(node, x, y);
739
+ }
647
740
  const iconInfo = this.calculateIconInfo(node, w);
648
741
  const labels = Array.isArray(node.label) ? node.label : [node.label];
649
742
  const labelHeight = labels.length * LABEL_LINE_HEIGHT;
@@ -657,8 +750,8 @@ ${fg}
657
750
  // Render icon at top of content block
658
751
  if (iconInfo) {
659
752
  const iconY = contentTop;
660
- parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
661
- ${iconInfo.svg}
753
+ parts.push(`<g class="node-icon" transform="translate(${x - iconInfo.width / 2}, ${iconY})">
754
+ ${iconInfo.svg}
662
755
  </g>`);
663
756
  }
664
757
  // Render labels below icon
@@ -671,6 +764,13 @@ ${fg}
671
764
  }
672
765
  return parts.join('\n ');
673
766
  }
767
+ /** Render content for export connector nodes */
768
+ renderExportConnectorContent(node, x, y) {
769
+ const labels = Array.isArray(node.label) ? node.label : [node.label];
770
+ const labelText = labels[0] || '';
771
+ // Just render the label (subgraph name), no arrows
772
+ return `<text x="${x}" y="${y + 4}" class="node-label" text-anchor="middle">${this.escapeXml(labelText)}</text>`;
773
+ }
674
774
  renderLink(layoutLink, nodes) {
675
775
  const { id, points, link, fromEndpoint, toEndpoint } = layoutLink;
676
776
  const label = link.label;
@@ -679,7 +779,7 @@ ${fg}
679
779
  const arrow = link.arrow ?? this.getDefaultArrowType(link.redundancy);
680
780
  const stroke = link.style?.stroke || this.getVlanStroke(link.vlan) || this.themeColors.defaultLinkStroke;
681
781
  const dasharray = link.style?.strokeDasharray || this.getLinkDasharray(type);
682
- const markerEnd = arrow !== 'none' ? 'url(#arrow)' : '';
782
+ const markerEnd = arrow !== 'none' ? `url(#${this.arrowId})` : '';
683
783
  // Get bandwidth rendering config
684
784
  const bandwidthConfig = this.getBandwidthConfig(link.bandwidth);
685
785
  const strokeWidth = link.style?.strokeWidth || bandwidthConfig.strokeWidth || this.getLinkStrokeWidth(type);
@@ -751,6 +851,7 @@ ${fg}
751
851
  vlan: link.vlan,
752
852
  redundancy: link.redundancy,
753
853
  label: link.label,
854
+ metadata: link.metadata,
754
855
  };
755
856
  attrs.push(`data-link-json="${this.escapeXml(JSON.stringify(linkInfo))}"`);
756
857
  }
@@ -909,14 +1010,14 @@ ${fg}
909
1010
  const basePath = this.generatePath(points);
910
1011
  if (lineCount === 1) {
911
1012
  // Single line
912
- let linePath = `<path class="link" data-id="${id}" d="${basePath}"
913
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
914
- ${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
1013
+ let linePath = `<path class="link" data-id="${id}" d="${basePath}"
1014
+ fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
1015
+ ${dasharray ? `stroke-dasharray="${dasharray}"` : ''}
915
1016
  ${markerEnd ? `marker-end="${markerEnd}"` : ''} pointer-events="none" />`;
916
1017
  // Double line effect for redundancy types
917
1018
  if (type === 'double') {
918
- linePath = `<path class="link-double-outer" d="${basePath}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" pointer-events="none" />
919
- <path class="link-double-inner" d="${basePath}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" pointer-events="none" />
1019
+ linePath = `<path class="link-double-outer" d="${basePath}" fill="none" stroke="${stroke}" stroke-width="${strokeWidth + 2}" pointer-events="none" />
1020
+ <path class="link-double-inner" d="${basePath}" fill="none" stroke="white" stroke-width="${strokeWidth - 1}" pointer-events="none" />
920
1021
  ${linePath}`;
921
1022
  }
922
1023
  lines.push(linePath);
@@ -927,18 +1028,18 @@ ${linePath}`;
927
1028
  for (const offset of offsets) {
928
1029
  const offsetPoints = this.offsetPoints(points, offset);
929
1030
  const path = this.generatePath(offsetPoints);
930
- lines.push(`<path class="link" data-id="${id}" d="${path}"
931
- fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
1031
+ lines.push(`<path class="link" data-id="${id}" d="${path}"
1032
+ fill="none" stroke="${stroke}" stroke-width="${strokeWidth}"
932
1033
  ${dasharray ? `stroke-dasharray="${dasharray}"` : ''} pointer-events="none" />`);
933
1034
  }
934
1035
  }
935
1036
  // Calculate hit area width (same as actual lines, hidden underneath)
936
1037
  const hitWidth = lineCount === 1 ? strokeWidth : (lineCount - 1) * lineSpacing + strokeWidth;
937
1038
  // Wrap all lines in a group with transparent hit area on top
938
- return `<g class="link-lines">
939
- ${lines.join('\n')}
940
- <path class="link-hit-area" d="${basePath}"
941
- fill="none" stroke="${stroke}" stroke-width="${hitWidth}" opacity="0" />
1039
+ return `<g class="link-lines">
1040
+ ${lines.join('\n')}
1041
+ <path class="link-hit-area" d="${basePath}"
1042
+ fill="none" stroke="${stroke}" stroke-width="${hitWidth}" opacity="0" />
942
1043
  </g>`;
943
1044
  }
944
1045
  /**