@diagrammo/dgmo 0.8.9 → 0.8.10

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.
@@ -16,20 +16,14 @@ import { parseOrg } from './parser';
16
16
  import { layoutOrg } from './layout';
17
17
  import {
18
18
  LEGEND_HEIGHT,
19
- LEGEND_PILL_PAD,
20
- LEGEND_PILL_FONT_SIZE,
21
- LEGEND_CAPSULE_PAD,
22
- LEGEND_DOT_R,
23
- LEGEND_ENTRY_FONT_SIZE,
24
- LEGEND_ENTRY_DOT_GAP,
25
- LEGEND_ENTRY_TRAIL,
26
19
  LEGEND_GROUP_GAP,
27
20
  LEGEND_EYE_SIZE,
28
21
  LEGEND_EYE_GAP,
29
22
  EYE_OPEN_PATH,
30
23
  EYE_CLOSED_PATH,
31
- measureLegendText,
32
24
  } from '../utils/legend-constants';
25
+ import { renderLegendD3 } from '../utils/legend-d3';
26
+ import type { LegendConfig, LegendState } from '../utils/legend-types';
33
27
 
34
28
  // ============================================================
35
29
  // Constants
@@ -487,33 +481,17 @@ export function renderOrg(
487
481
  }
488
482
  }
489
483
 
490
- // Render legend — kanban-style pills.
484
+ // Render legend — capsule pills.
491
485
  // In app mode (fixedLegend): render at native size outside the scaled group.
492
486
  // In export mode: skip legend (unless legend-only chart).
493
487
  // Legend-only (no nodes): all groups rendered as expanded capsules inside scaled group.
494
488
  if (fixedLegend || legendOnly || (exportDims && hasLegend)) {
495
- // Determine which groups to render
496
- const visibleGroups = layout.legend.filter((group) => {
497
- if (legendOnly) return true;
498
- if (activeTagGroup == null) return true;
499
- return group.name.toLowerCase() === activeTagGroup.toLowerCase();
500
- });
489
+ const groups = layout.legend.map((g) => ({
490
+ name: g.name,
491
+ entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
492
+ }));
501
493
 
502
- // For fixedLegend: compute positions in pixel space, centered in SVG
503
- let fixedPositions: Map<string, number> | undefined;
504
- if (fixedLegend && visibleGroups.length > 0) {
505
- fixedPositions = new Map();
506
- const effectiveW = (g: (typeof visibleGroups)[0]) =>
507
- activeTagGroup != null ? g.width : g.minifiedWidth;
508
- const totalW =
509
- visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
510
- (visibleGroups.length - 1) * LEGEND_GROUP_GAP;
511
- let cx = (width - totalW) / 2;
512
- for (const g of visibleGroups) {
513
- fixedPositions.set(g.name, cx);
514
- cx += effectiveW(g) + LEGEND_GROUP_GAP;
515
- }
516
- }
494
+ const eyeAddonWidth = fixedLegend ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
517
495
 
518
496
  // Choose parent: unscaled group for fixedLegend, contentG for legend-only
519
497
  const legendParentBase = fixedLegend
@@ -521,152 +499,107 @@ export function renderOrg(
521
499
  .append('g')
522
500
  .attr('class', 'org-legend-fixed')
523
501
  .attr('transform', `translate(0, ${DIAGRAM_PADDING + titleReserve})`)
524
- : contentG;
525
- const legendParent = legendParentBase;
526
- if (fixedLegend && activeTagGroup) {
527
- legendParentBase.attr('data-legend-active', activeTagGroup.toLowerCase());
528
- }
529
-
530
- for (const group of visibleGroups) {
531
- const isActive =
532
- legendOnly ||
533
- (activeTagGroup != null &&
534
- group.name.toLowerCase() === activeTagGroup.toLowerCase());
535
-
536
- const groupBg = isDark
537
- ? mix(palette.surface, palette.bg, 50)
538
- : mix(palette.surface, palette.bg, 30);
539
-
540
- const pillLabel = group.name;
541
- const pillWidth =
542
- measureLegendText(pillLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
543
-
544
- const gX = fixedPositions?.get(group.name) ?? group.x;
545
- const gY = fixedPositions ? 0 : group.y;
546
-
547
- const gEl = legendParent
548
- .append('g')
549
- .attr('transform', `translate(${gX}, ${gY})`)
550
- .attr('class', 'org-legend-group')
551
- .attr('data-legend-group', group.name.toLowerCase())
552
- .style('cursor', legendOnly ? 'default' : 'pointer');
553
-
554
- // Outer capsule background (active only)
555
- if (isActive) {
556
- gEl
557
- .append('rect')
558
- .attr('width', group.width)
559
- .attr('height', LEGEND_HEIGHT)
560
- .attr('rx', LEGEND_HEIGHT / 2)
561
- .attr('fill', groupBg);
562
- }
563
-
564
- const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
565
- const pillYOff = LEGEND_CAPSULE_PAD;
566
- const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
567
-
568
- // Pill background
569
- gEl
570
- .append('rect')
571
- .attr('x', pillXOff)
572
- .attr('y', pillYOff)
573
- .attr('width', pillWidth)
574
- .attr('height', pillH)
575
- .attr('rx', pillH / 2)
576
- .attr('fill', isActive ? palette.bg : groupBg);
577
-
578
- // Active pill border
579
- if (isActive) {
580
- gEl
581
- .append('rect')
582
- .attr('x', pillXOff)
583
- .attr('y', pillYOff)
584
- .attr('width', pillWidth)
585
- .attr('height', pillH)
586
- .attr('rx', pillH / 2)
587
- .attr('fill', 'none')
588
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
589
- .attr('stroke-width', 0.75);
502
+ : contentG.append('g');
503
+
504
+ let legendHandle;
505
+ if (legendOnly) {
506
+ // Legend-only mode: render each group expanded individually at layout positions
507
+ for (const lg of layout.legend) {
508
+ const singleConfig: LegendConfig = {
509
+ groups: [
510
+ {
511
+ name: lg.name,
512
+ entries: lg.entries.map((e) => ({
513
+ value: e.value,
514
+ color: e.color,
515
+ })),
516
+ },
517
+ ],
518
+ position: { placement: 'top-center', titleRelation: 'below-title' },
519
+ mode: 'fixed',
520
+ };
521
+ const singleState: LegendState = { activeGroup: lg.name };
522
+ const groupG = legendParentBase
523
+ .append('g')
524
+ .attr('transform', `translate(${lg.x}, ${lg.y})`);
525
+ renderLegendD3(
526
+ groupG,
527
+ singleConfig,
528
+ singleState,
529
+ palette,
530
+ isDark,
531
+ undefined,
532
+ lg.width
533
+ );
534
+ groupG
535
+ .selectAll('[data-legend-group]')
536
+ .classed('org-legend-group', true);
590
537
  }
538
+ legendHandle = null;
539
+ } else {
540
+ const legendConfig: LegendConfig = {
541
+ groups,
542
+ position: { placement: 'top-center', titleRelation: 'below-title' },
543
+ mode: 'fixed',
544
+ capsulePillAddonWidth: eyeAddonWidth,
545
+ };
546
+ const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
547
+ legendHandle = renderLegendD3(
548
+ legendParentBase,
549
+ legendConfig,
550
+ legendState,
551
+ palette,
552
+ isDark,
553
+ undefined,
554
+ fixedLegend ? width : layout.width
555
+ );
556
+ legendParentBase
557
+ .selectAll('[data-legend-group]')
558
+ .classed('org-legend-group', true);
559
+ }
591
560
 
592
- // Pill text
593
- gEl
594
- .append('text')
595
- .attr('x', pillXOff + pillWidth / 2)
596
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
597
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
598
- .attr('font-weight', '500')
599
- .attr('fill', isActive ? palette.text : palette.textMuted)
600
- .attr('text-anchor', 'middle')
601
- .text(pillLabel);
602
-
603
- // Eye icon for visibility toggle (active only, app mode)
604
- if (isActive && fixedLegend) {
605
- const groupKey = group.name.toLowerCase();
561
+ // Inject eye icons into active group capsules (app mode only)
562
+ if (fixedLegend && legendHandle) {
563
+ const computedLayout = legendHandle.getLayout();
564
+ if (computedLayout.activeCapsule?.addonX != null) {
565
+ const capsule = computedLayout.activeCapsule;
566
+ const groupKey = capsule.groupName.toLowerCase();
606
567
  const isHidden = hiddenAttributes?.has(groupKey) ?? false;
607
- const eyeX = pillXOff + pillWidth + LEGEND_EYE_GAP;
608
- const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
609
- const hitPad = 6;
610
568
 
611
- const eyeG = gEl
612
- .append('g')
613
- .attr('class', 'org-legend-eye')
614
- .attr('data-legend-visibility', groupKey)
615
- .style('cursor', 'pointer')
616
- .attr('opacity', isHidden ? 0.4 : 0.7);
617
-
618
- // Transparent hit area for easier clicking
619
- eyeG
620
- .append('rect')
621
- .attr('x', eyeX - hitPad)
622
- .attr('y', eyeY - hitPad)
623
- .attr('width', LEGEND_EYE_SIZE + hitPad * 2)
624
- .attr('height', LEGEND_EYE_SIZE + hitPad * 2)
625
- .attr('fill', 'transparent')
626
- .attr('pointer-events', 'all');
627
-
628
- eyeG
629
- .append('path')
630
- .attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
631
- .attr('transform', `translate(${eyeX}, ${eyeY})`)
632
- .attr('fill', 'none')
633
- .attr('stroke', palette.textMuted)
634
- .attr('stroke-width', 1.2)
635
- .attr('stroke-linecap', 'round')
636
- .attr('stroke-linejoin', 'round');
637
- }
569
+ // Find the rendered active group <g> and append eye icon
570
+ const activeGroupEl = legendParentBase.select(
571
+ `[data-legend-group="${groupKey}"]`
572
+ );
573
+ if (!activeGroupEl.empty()) {
574
+ const eyeX = capsule.addonX!;
575
+ const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
576
+ const hitPad = 6;
638
577
 
639
- // Entries inside capsule (active only)
640
- if (isActive) {
641
- const eyeShift = fixedLegend ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
642
- let entryX = pillXOff + pillWidth + 4 + eyeShift;
643
- for (const entry of group.entries) {
644
- const entryG = gEl
578
+ const eyeG = activeGroupEl
645
579
  .append('g')
646
- .attr('data-legend-entry', entry.value.toLowerCase())
647
- .style('cursor', 'pointer');
648
-
649
- entryG
650
- .append('circle')
651
- .attr('cx', entryX + LEGEND_DOT_R)
652
- .attr('cy', LEGEND_HEIGHT / 2)
653
- .attr('r', LEGEND_DOT_R)
654
- .attr('fill', entry.color);
655
-
656
- const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
657
- const entryLabel = entry.value;
658
- entryG
659
- .append('text')
660
- .attr('x', textX)
661
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
662
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
663
- .attr('fill', palette.textMuted)
664
- .text(entryLabel);
665
-
666
- entryX =
667
- textX +
668
- measureLegendText(entryLabel, LEGEND_ENTRY_FONT_SIZE) +
669
- LEGEND_ENTRY_TRAIL;
580
+ .attr('class', 'org-legend-eye')
581
+ .attr('data-legend-visibility', groupKey)
582
+ .style('cursor', 'pointer')
583
+ .attr('opacity', isHidden ? 0.4 : 0.7);
584
+
585
+ eyeG
586
+ .append('rect')
587
+ .attr('x', eyeX - hitPad)
588
+ .attr('y', eyeY - hitPad)
589
+ .attr('width', LEGEND_EYE_SIZE + hitPad * 2)
590
+ .attr('height', LEGEND_EYE_SIZE + hitPad * 2)
591
+ .attr('fill', 'transparent')
592
+ .attr('pointer-events', 'all');
593
+
594
+ eyeG
595
+ .append('path')
596
+ .attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
597
+ .attr('transform', `translate(${eyeX}, ${eyeY})`)
598
+ .attr('fill', 'none')
599
+ .attr('stroke', palette.textMuted)
600
+ .attr('stroke-width', 1.2)
601
+ .attr('stroke-linecap', 'round')
602
+ .attr('stroke-linejoin', 'round');
670
603
  }
671
604
  }
672
605
  }
package/src/render.ts CHANGED
@@ -15,11 +15,26 @@ async function ensureDom(): Promise<void> {
15
15
  const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
16
16
  const win = dom.window;
17
17
 
18
- Object.defineProperty(globalThis, 'document', { value: win.document, configurable: true });
19
- Object.defineProperty(globalThis, 'window', { value: win, configurable: true });
20
- Object.defineProperty(globalThis, 'navigator', { value: win.navigator, configurable: true });
21
- Object.defineProperty(globalThis, 'HTMLElement', { value: win.HTMLElement, configurable: true });
22
- Object.defineProperty(globalThis, 'SVGElement', { value: win.SVGElement, configurable: true });
18
+ Object.defineProperty(globalThis, 'document', {
19
+ value: win.document,
20
+ configurable: true,
21
+ });
22
+ Object.defineProperty(globalThis, 'window', {
23
+ value: win,
24
+ configurable: true,
25
+ });
26
+ Object.defineProperty(globalThis, 'navigator', {
27
+ value: win.navigator,
28
+ configurable: true,
29
+ });
30
+ Object.defineProperty(globalThis, 'HTMLElement', {
31
+ value: win.HTMLElement,
32
+ configurable: true,
33
+ });
34
+ Object.defineProperty(globalThis, 'SVGElement', {
35
+ value: win.SVGElement,
36
+ configurable: true,
37
+ });
23
38
  }
24
39
 
25
40
  /**
@@ -52,24 +67,39 @@ export async function render(
52
67
  c4System?: string;
53
68
  c4Container?: string;
54
69
  tagGroup?: string;
55
- },
70
+ /** Legend state for export — controls which tag group is shown in exported SVG. */
71
+ legendState?: { activeGroup?: string; hiddenAttributes?: string[] };
72
+ }
56
73
  ): Promise<string> {
57
74
  const theme = options?.theme ?? 'light';
58
75
  const paletteName = options?.palette ?? 'nord';
59
76
  const branding = options?.branding ?? false;
60
77
 
61
- const paletteColors = getPalette(paletteName)[theme === 'dark' ? 'dark' : 'light'];
78
+ const paletteColors =
79
+ getPalette(paletteName)[theme === 'dark' ? 'dark' : 'light'];
62
80
 
63
81
  const chartType = parseDgmoChartType(content);
64
82
  const category = chartType ? getRenderCategory(chartType) : null;
65
83
 
84
+ // Build orgExportState from legendState if provided
85
+ const legendExportState = options?.legendState
86
+ ? {
87
+ activeTagGroup: options.legendState.activeGroup ?? null,
88
+ hiddenAttributes: options.legendState.hiddenAttributes
89
+ ? new Set(options.legendState.hiddenAttributes)
90
+ : undefined,
91
+ }
92
+ : undefined;
93
+
66
94
  if (category === 'data-chart') {
67
- return renderExtendedChartForExport(content, theme, paletteColors, { branding });
95
+ return renderExtendedChartForExport(content, theme, paletteColors, {
96
+ branding,
97
+ });
68
98
  }
69
99
 
70
100
  // Visualization/diagram and unknown/null types all go through the unified renderer
71
101
  await ensureDom();
72
- return renderForExport(content, theme, paletteColors, undefined, {
102
+ return renderForExport(content, theme, paletteColors, legendExportState, {
73
103
  branding,
74
104
  c4Level: options?.c4Level,
75
105
  c4System: options?.c4System,
@@ -24,18 +24,9 @@ import type {
24
24
  import { isSequenceBlock, isSequenceSection, isSequenceNote } from './parser';
25
25
  import { resolveSequenceTags } from './tag-resolution';
26
26
  import type { ResolvedTagMap } from './tag-resolution';
27
- import {
28
- LEGEND_HEIGHT,
29
- LEGEND_PILL_PAD,
30
- LEGEND_PILL_FONT_SIZE,
31
- LEGEND_CAPSULE_PAD,
32
- LEGEND_DOT_R,
33
- LEGEND_ENTRY_FONT_SIZE,
34
- LEGEND_ENTRY_DOT_GAP,
35
- LEGEND_ENTRY_TRAIL,
36
- LEGEND_GROUP_GAP,
37
- measureLegendText,
38
- } from '../utils/legend-constants';
27
+ import { LEGEND_HEIGHT } from '../utils/legend-constants';
28
+ import { renderLegendD3 } from '../utils/legend-d3';
29
+ import type { LegendConfig, LegendState } from '../utils/legend-types';
39
30
  import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
40
31
 
41
32
  // ============================================================
@@ -1598,146 +1589,35 @@ export function renderSequenceDiagram(
1598
1589
  // Render legend pills for tag groups
1599
1590
  if (parsed.tagGroups.length > 0) {
1600
1591
  const legendY = TOP_MARGIN + titleOffset;
1601
- const groupBg = isDark
1602
- ? mix(palette.surface, palette.bg, 50)
1603
- : mix(palette.surface, palette.bg, 30);
1604
-
1605
- // Pre-compute pill/capsule widths for centering
1606
- const legendItems: Array<{
1607
- group: (typeof parsed.tagGroups)[0];
1608
- isActive: boolean;
1609
- pillWidth: number;
1610
- totalWidth: number;
1611
- entries: Array<{ value: string; color: string }>;
1612
- }> = [];
1613
- for (const tg of parsed.tagGroups) {
1614
- if (tg.entries.length === 0) continue;
1615
- const isActive =
1616
- !!activeTagGroup &&
1617
- tg.name.toLowerCase() === activeTagGroup.toLowerCase();
1618
- const pillWidth =
1619
- measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1620
- const entries = tg.entries.map((e) => ({
1621
- value: e.value,
1622
- color: resolveColor(e.color) ?? e.color,
1592
+ // Resolve tag colors for legend entries
1593
+ const resolvedGroups = parsed.tagGroups
1594
+ .filter((tg) => tg.entries.length > 0)
1595
+ .map((tg) => ({
1596
+ name: tg.name,
1597
+ entries: tg.entries.map((e) => ({
1598
+ value: e.value,
1599
+ color: resolveColor(e.color) ?? e.color,
1600
+ })),
1623
1601
  }));
1624
- let totalWidth = pillWidth;
1625
- if (isActive) {
1626
- let entriesWidth = 0;
1627
- for (const entry of entries) {
1628
- entriesWidth +=
1629
- LEGEND_DOT_R * 2 +
1630
- LEGEND_ENTRY_DOT_GAP +
1631
- measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
1632
- LEGEND_ENTRY_TRAIL;
1633
- }
1634
- totalWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
1635
- }
1636
- legendItems.push({ group: tg, isActive, pillWidth, totalWidth, entries });
1637
- }
1638
-
1639
- // Center legend horizontally
1640
- const totalLegendWidth =
1641
- legendItems.reduce((s, item) => s + item.totalWidth, 0) +
1642
- (legendItems.length - 1) * LEGEND_GROUP_GAP;
1643
- let legendX = (svgWidth - totalLegendWidth) / 2;
1644
-
1645
- const legendContainer = svg.append('g').attr('class', 'sequence-legend');
1646
- if (activeTagGroup) {
1647
- legendContainer.attr('data-legend-active', activeTagGroup.toLowerCase());
1648
- }
1649
-
1650
- for (const item of legendItems) {
1651
- const gEl = legendContainer
1652
- .append('g')
1653
- .attr('transform', `translate(${legendX}, ${legendY})`)
1654
- .attr('class', 'sequence-legend-group')
1655
- .attr('data-legend-group', item.group.name.toLowerCase())
1656
- .style('cursor', 'pointer');
1657
-
1658
- // Outer capsule background (active only)
1659
- if (item.isActive) {
1660
- gEl
1661
- .append('rect')
1662
- .attr('width', item.totalWidth)
1663
- .attr('height', LEGEND_HEIGHT)
1664
- .attr('rx', LEGEND_HEIGHT / 2)
1665
- .attr('fill', groupBg);
1666
- }
1667
-
1668
- const pillXOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
1669
- const pillYOff = LEGEND_CAPSULE_PAD;
1670
- const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
1671
-
1672
- // Pill background
1673
- gEl
1674
- .append('rect')
1675
- .attr('x', pillXOff)
1676
- .attr('y', pillYOff)
1677
- .attr('width', item.pillWidth)
1678
- .attr('height', pillH)
1679
- .attr('rx', pillH / 2)
1680
- .attr('fill', item.isActive ? palette.bg : groupBg);
1681
-
1682
- // Active pill border
1683
- if (item.isActive) {
1684
- gEl
1685
- .append('rect')
1686
- .attr('x', pillXOff)
1687
- .attr('y', pillYOff)
1688
- .attr('width', item.pillWidth)
1689
- .attr('height', pillH)
1690
- .attr('rx', pillH / 2)
1691
- .attr('fill', 'none')
1692
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
1693
- .attr('stroke-width', 0.75);
1694
- }
1695
-
1696
- // Pill text
1697
- gEl
1698
- .append('text')
1699
- .attr('x', pillXOff + item.pillWidth / 2)
1700
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
1701
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
1702
- .attr('font-weight', '500')
1703
- .attr('fill', item.isActive ? palette.text : palette.textMuted)
1704
- .attr('text-anchor', 'middle')
1705
- .text(item.group.name);
1706
-
1707
- // Entries inside capsule (active only)
1708
- if (item.isActive) {
1709
- let entryX = pillXOff + item.pillWidth + 4;
1710
- for (const entry of item.entries) {
1711
- const entryG = gEl
1712
- .append('g')
1713
- .attr('data-legend-entry', entry.value.toLowerCase())
1714
- .style('cursor', 'pointer');
1715
-
1716
- entryG
1717
- .append('circle')
1718
- .attr('cx', entryX + LEGEND_DOT_R)
1719
- .attr('cy', LEGEND_HEIGHT / 2)
1720
- .attr('r', LEGEND_DOT_R)
1721
- .attr('fill', entry.color);
1722
-
1723
- const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
1724
- entryG
1725
- .append('text')
1726
- .attr('x', textX)
1727
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
1728
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
1729
- .attr('fill', palette.textMuted)
1730
- .text(entry.value);
1731
-
1732
- entryX =
1733
- textX +
1734
- measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
1735
- LEGEND_ENTRY_TRAIL;
1736
- }
1737
- }
1738
-
1739
- legendX += item.totalWidth + LEGEND_GROUP_GAP;
1740
- }
1602
+ const legendConfig: LegendConfig = {
1603
+ groups: resolvedGroups,
1604
+ position: { placement: 'top-center', titleRelation: 'below-title' },
1605
+ mode: 'fixed',
1606
+ };
1607
+ const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
1608
+ const legendG = svg
1609
+ .append('g')
1610
+ .attr('class', 'sequence-legend')
1611
+ .attr('transform', `translate(0,${legendY})`);
1612
+ renderLegendD3(
1613
+ legendG,
1614
+ legendConfig,
1615
+ legendState,
1616
+ palette,
1617
+ isDark,
1618
+ undefined,
1619
+ svgWidth
1620
+ );
1741
1621
  }
1742
1622
 
1743
1623
  // Render group boxes (behind participant shapes)