@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.
package/src/d3.ts CHANGED
@@ -205,9 +205,14 @@ import {
205
205
  LEGEND_ENTRY_FONT_SIZE as TL_LEGEND_ENTRY_FONT_SIZE,
206
206
  LEGEND_ENTRY_DOT_GAP as TL_LEGEND_ENTRY_DOT_GAP,
207
207
  LEGEND_ENTRY_TRAIL as TL_LEGEND_ENTRY_TRAIL,
208
- LEGEND_GROUP_GAP as TL_LEGEND_GROUP_GAP,
209
208
  measureLegendText,
210
209
  } from './utils/legend-constants';
210
+ import { renderLegendD3 } from './utils/legend-d3';
211
+ import type {
212
+ LegendConfig,
213
+ LegendState,
214
+ LegendCallbacks,
215
+ } from './utils/legend-types';
211
216
  import {
212
217
  TITLE_FONT_SIZE,
213
218
  TITLE_FONT_WEIGHT,
@@ -4866,7 +4871,7 @@ export function renderTimeline(
4866
4871
  const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
4867
4872
  const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
4868
4873
  const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
4869
- const LG_GROUP_GAP = TL_LEGEND_GROUP_GAP;
4874
+ // LG_GROUP_GAP no longer needed — centralized legend handles spacing
4870
4875
  const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space) — local
4871
4876
 
4872
4877
  const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
@@ -4875,10 +4880,6 @@ export function renderTimeline(
4875
4880
  // Position legend at top, below title
4876
4881
  const legendY = title ? 50 : 10;
4877
4882
 
4878
- const groupBg = isDark
4879
- ? mix(palette.surface, palette.bg, 50)
4880
- : mix(palette.surface, palette.bg, 30);
4881
-
4882
4883
  // Pre-compute group widths (minified and expanded)
4883
4884
  type LegendGroup = {
4884
4885
  group: TagGroup;
@@ -4980,20 +4981,6 @@ export function renderTimeline(
4980
4981
 
4981
4982
  if (visibleGroups.length === 0) return;
4982
4983
 
4983
- // Compute total width and center horizontally in SVG
4984
- const totalW =
4985
- visibleGroups.reduce((s, lg) => {
4986
- const isActive =
4987
- viewMode ||
4988
- (currentActiveGroup != null &&
4989
- lg.group.name.toLowerCase() ===
4990
- currentActiveGroup.toLowerCase());
4991
- return s + (isActive ? lg.expandedWidth : lg.minifiedWidth);
4992
- }, 0) +
4993
- (visibleGroups.length - 1) * LG_GROUP_GAP;
4994
-
4995
- let cx = (width - totalW) / 2;
4996
-
4997
4984
  // Legend container for data-legend-active attribute
4998
4985
  const legendContainer = mainSvg
4999
4986
  .append('g')
@@ -5005,177 +4992,113 @@ export function renderTimeline(
5005
4992
  );
5006
4993
  }
5007
4994
 
5008
- for (const lg of visibleGroups) {
5009
- const groupKey = lg.group.name.toLowerCase();
5010
- const isActive =
5011
- viewMode ||
5012
- (currentActiveGroup != null &&
5013
- currentActiveGroup.toLowerCase() === groupKey);
5014
- const isSwimActive =
5015
- currentSwimlaneGroup != null &&
5016
- currentSwimlaneGroup.toLowerCase() === groupKey;
5017
-
5018
- const pillLabel = lg.group.name;
5019
- const pillWidth =
5020
- measureLegendText(pillLabel, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
5021
-
5022
- const gEl = legendContainer
5023
- .append('g')
5024
- .attr('transform', `translate(${cx}, ${legendY})`)
5025
- .attr('class', 'tl-tag-legend-group tl-tag-legend-entry')
5026
- .attr('data-legend-group', groupKey)
5027
- .attr('data-tag-group', groupKey)
5028
- .attr('data-legend-entry', '__group__');
5029
-
5030
- if (!viewMode) {
5031
- gEl.style('cursor', 'pointer').on('click', () => {
5032
- currentActiveGroup =
5033
- currentActiveGroup === groupKey ? null : groupKey;
5034
- drawLegend();
5035
- recolorEvents();
5036
- onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
5037
- });
5038
- }
5039
-
5040
- // Outer capsule background (active only)
5041
- if (isActive) {
5042
- gEl
5043
- .append('rect')
5044
- .attr('width', lg.expandedWidth)
5045
- .attr('height', LG_HEIGHT)
5046
- .attr('rx', LG_HEIGHT / 2)
5047
- .attr('fill', groupBg);
5048
- }
5049
-
5050
- const pillXOff = isActive ? LG_CAPSULE_PAD : 0;
5051
- const pillYOff = LG_CAPSULE_PAD;
5052
- const pillH = LG_HEIGHT - LG_CAPSULE_PAD * 2;
5053
-
5054
- // Pill background
5055
- gEl
5056
- .append('rect')
5057
- .attr('x', pillXOff)
5058
- .attr('y', pillYOff)
5059
- .attr('width', pillWidth)
5060
- .attr('height', pillH)
5061
- .attr('rx', pillH / 2)
5062
- .attr('fill', isActive ? palette.bg : groupBg);
5063
-
5064
- // Active pill border
5065
- if (isActive) {
5066
- gEl
5067
- .append('rect')
5068
- .attr('x', pillXOff)
5069
- .attr('y', pillYOff)
5070
- .attr('width', pillWidth)
5071
- .attr('height', pillH)
5072
- .attr('rx', pillH / 2)
5073
- .attr('fill', 'none')
5074
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
5075
- .attr('stroke-width', 0.75);
5076
- }
5077
-
5078
- // Pill text
5079
- gEl
5080
- .append('text')
5081
- .attr('x', pillXOff + pillWidth / 2)
5082
- .attr('y', LG_HEIGHT / 2 + LG_PILL_FONT_SIZE / 2 - 2)
5083
- .attr('font-size', LG_PILL_FONT_SIZE)
5084
- .attr('font-weight', '500')
5085
- .attr('font-family', FONT_FAMILY)
5086
- .attr('fill', isActive ? palette.text : palette.textMuted)
5087
- .attr('text-anchor', 'middle')
5088
- .text(pillLabel);
5089
-
5090
- // Entries + swimlane icon inside capsule (active only)
5091
- if (isActive) {
5092
- // Swimlane icon (skip in view mode — non-interactive)
5093
- let entryX: number;
5094
- if (!viewMode) {
5095
- const iconX = pillXOff + pillWidth + 5;
5096
- const iconY = (LG_HEIGHT - 10) / 2; // vertically centered
5097
- const iconEl = drawSwimlaneIcon(gEl, iconX, iconY, isSwimActive);
5098
- iconEl
5099
- .attr('data-swimlane-toggle', groupKey)
5100
- .on('click', (event: MouseEvent) => {
5101
- event.stopPropagation();
5102
- currentSwimlaneGroup =
5103
- currentSwimlaneGroup === groupKey ? null : groupKey;
5104
- onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
5105
- relayout();
5106
- });
5107
- entryX = pillXOff + pillWidth + LG_ICON_W + 4;
5108
- } else {
5109
- entryX = pillXOff + pillWidth + 8;
5110
- }
5111
-
5112
- for (const entry of lg.group.entries) {
5113
- const tagKey = lg.group.name.toLowerCase();
5114
- const tagVal = entry.value.toLowerCase();
5115
-
5116
- const entryG = gEl
5117
- .append('g')
5118
- .attr('class', 'tl-tag-legend-entry')
5119
- .attr('data-tag-group', tagKey)
5120
- .attr('data-legend-entry', tagVal);
5121
-
5122
- if (!viewMode) {
5123
- entryG
5124
- .style('cursor', 'pointer')
5125
- .on('mouseenter', (event: MouseEvent) => {
5126
- event.stopPropagation();
5127
- fadeToTagValue(mainG, tagKey, tagVal);
5128
- mainSvg
5129
- .selectAll<SVGGElement, unknown>('.tl-tag-legend-entry')
5130
- .each(function () {
5131
- const el = d3Selection.select(this);
5132
- const ev = el.attr('data-legend-entry');
5133
- if (ev === '__group__') return;
5134
- const eg = el.attr('data-tag-group');
5135
- el.attr(
5136
- 'opacity',
5137
- eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY
5138
- );
5139
- });
5140
- })
5141
- .on('mouseleave', (event: MouseEvent) => {
5142
- event.stopPropagation();
5143
- fadeReset(mainG);
5144
- mainSvg
5145
- .selectAll<SVGGElement, unknown>('.tl-tag-legend-entry')
5146
- .attr('opacity', 1);
5147
- })
5148
- .on('click', (event: MouseEvent) => {
5149
- event.stopPropagation();
5150
- });
5151
- }
5152
-
5153
- entryG
5154
- .append('circle')
5155
- .attr('cx', entryX + LG_DOT_R)
5156
- .attr('cy', LG_HEIGHT / 2)
5157
- .attr('r', LG_DOT_R)
5158
- .attr('fill', entry.color);
5159
-
5160
- const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
5161
- entryG
5162
- .append('text')
5163
- .attr('x', textX)
5164
- .attr('y', LG_HEIGHT / 2 + LG_ENTRY_FONT_SIZE / 2 - 1)
5165
- .attr('font-size', LG_ENTRY_FONT_SIZE)
5166
- .attr('font-family', FONT_FAMILY)
5167
- .attr('fill', palette.textMuted)
5168
- .text(entry.value);
5169
-
5170
- entryX =
5171
- textX +
5172
- measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) +
5173
- LG_ENTRY_TRAIL;
5174
- }
5175
- }
5176
-
5177
- cx += (isActive ? lg.expandedWidth : lg.minifiedWidth) + LG_GROUP_GAP;
5178
- }
4995
+ // Render tag groups via centralized legend system
4996
+ const iconAddon = viewMode ? 0 : LG_ICON_W;
4997
+ const centralGroups = visibleGroups.map((lg) => ({
4998
+ name: lg.group.name,
4999
+ entries: lg.group.entries.map((e) => ({
5000
+ value: e.value,
5001
+ color: e.color,
5002
+ })),
5003
+ }));
5004
+
5005
+ // Determine effective active group for centralized renderer
5006
+ const centralActive = viewMode ? effectiveColorKey : currentActiveGroup;
5007
+
5008
+ const centralConfig: LegendConfig = {
5009
+ groups: centralGroups,
5010
+ position: { placement: 'top-center', titleRelation: 'below-title' },
5011
+ mode: 'fixed',
5012
+ capsulePillAddonWidth: iconAddon,
5013
+ };
5014
+ const centralState: LegendState = { activeGroup: centralActive };
5015
+
5016
+ const centralCallbacks: LegendCallbacks = viewMode
5017
+ ? {}
5018
+ : {
5019
+ onGroupToggle: (groupName) => {
5020
+ currentActiveGroup =
5021
+ currentActiveGroup === groupName.toLowerCase()
5022
+ ? null
5023
+ : groupName.toLowerCase();
5024
+ drawLegend();
5025
+ recolorEvents();
5026
+ onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
5027
+ },
5028
+ onEntryHover: (groupName, entryValue) => {
5029
+ const tagKey = groupName.toLowerCase();
5030
+ if (entryValue) {
5031
+ const tagVal = entryValue.toLowerCase();
5032
+ fadeToTagValue(mainG, tagKey, tagVal);
5033
+ mainSvg
5034
+ .selectAll<SVGGElement, unknown>('[data-legend-entry]')
5035
+ .each(function () {
5036
+ const el = d3Selection.select(this);
5037
+ const ev = el.attr('data-legend-entry');
5038
+ const eg =
5039
+ el.attr('data-tag-group') ??
5040
+ (el.node() as Element)
5041
+ ?.closest?.('[data-tag-group]')
5042
+ ?.getAttribute('data-tag-group');
5043
+ el.attr(
5044
+ 'opacity',
5045
+ eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY
5046
+ );
5047
+ });
5048
+ } else {
5049
+ fadeReset(mainG);
5050
+ mainSvg
5051
+ .selectAll<SVGGElement, unknown>('[data-legend-entry]')
5052
+ .attr('opacity', 1);
5053
+ }
5054
+ },
5055
+ onGroupRendered: (groupName, groupEl, isActive) => {
5056
+ const groupKey = groupName.toLowerCase();
5057
+ groupEl.attr('data-tag-group', groupKey);
5058
+ if (isActive && !viewMode) {
5059
+ const isSwimActive =
5060
+ currentSwimlaneGroup != null &&
5061
+ currentSwimlaneGroup.toLowerCase() === groupKey;
5062
+ const pillWidth =
5063
+ measureLegendText(groupName, LG_PILL_FONT_SIZE) +
5064
+ LG_PILL_PAD;
5065
+ const pillXOff = LG_CAPSULE_PAD;
5066
+ const iconX = pillXOff + pillWidth + 5;
5067
+ const iconY = (LG_HEIGHT - 10) / 2;
5068
+ const iconEl = drawSwimlaneIcon(
5069
+ groupEl,
5070
+ iconX,
5071
+ iconY,
5072
+ isSwimActive
5073
+ );
5074
+ iconEl
5075
+ .attr('data-swimlane-toggle', groupKey)
5076
+ .on('click', (event: MouseEvent) => {
5077
+ event.stopPropagation();
5078
+ currentSwimlaneGroup =
5079
+ currentSwimlaneGroup === groupKey ? null : groupKey;
5080
+ onTagStateChange?.(
5081
+ currentActiveGroup,
5082
+ currentSwimlaneGroup
5083
+ );
5084
+ relayout();
5085
+ });
5086
+ }
5087
+ },
5088
+ };
5089
+
5090
+ const legendInnerG = legendContainer
5091
+ .append('g')
5092
+ .attr('transform', `translate(0, ${legendY})`);
5093
+ renderLegendD3(
5094
+ legendInnerG,
5095
+ centralConfig,
5096
+ centralState,
5097
+ palette,
5098
+ isDark,
5099
+ centralCallbacks,
5100
+ width
5101
+ );
5179
5102
  }
5180
5103
 
5181
5104
  // Build a quick lineNumber→event lookup
@@ -9,18 +9,9 @@ import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import { getSeriesColors } from '../palettes';
11
11
  import { resolveTagColor } from '../utils/tag-groups';
12
- import {
13
- LEGEND_HEIGHT,
14
- LEGEND_PILL_PAD,
15
- LEGEND_PILL_FONT_SIZE,
16
- LEGEND_CAPSULE_PAD,
17
- LEGEND_DOT_R,
18
- LEGEND_ENTRY_FONT_SIZE,
19
- LEGEND_ENTRY_DOT_GAP,
20
- LEGEND_ENTRY_TRAIL,
21
- LEGEND_GROUP_GAP,
22
- measureLegendText,
23
- } from '../utils/legend-constants';
12
+ import { LEGEND_HEIGHT } from '../utils/legend-constants';
13
+ import { renderLegendD3 } from '../utils/legend-d3';
14
+ import type { LegendConfig, LegendState } from '../utils/legend-types';
24
15
  import {
25
16
  TITLE_FONT_SIZE,
26
17
  TITLE_FONT_WEIGHT,
@@ -557,96 +548,30 @@ export function renderERDiagram(
557
548
 
558
549
  // ── Tag Legend ──
559
550
  if (parsed.tagGroups.length > 0) {
560
- const LEGEND_PILL_H = LEGEND_HEIGHT - 6;
561
- const LEGEND_PILL_RX = Math.floor(LEGEND_PILL_H / 2);
562
- const LEGEND_GAP = 8;
563
-
564
- const legendG = svg.append('g').attr('class', 'er-tag-legend');
565
-
566
- if (activeTagGroup) {
567
- legendG.attr('data-legend-active', activeTagGroup.toLowerCase());
568
- }
569
-
570
- let legendX = DIAGRAM_PADDING;
571
551
  const legendY = DIAGRAM_PADDING + titleHeight;
572
-
573
- for (const group of parsed.tagGroups) {
574
- const groupG = legendG
575
- .append('g')
576
- .attr('data-legend-group', group.name.toLowerCase());
577
-
578
- // Group label
579
- const labelText = groupG
580
- .append('text')
581
- .attr('x', legendX)
582
- .attr('y', legendY + LEGEND_PILL_H / 2)
583
- .attr('dominant-baseline', 'central')
584
- .attr('fill', palette.textMuted)
585
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
586
- .attr('font-family', FONT_FAMILY)
587
- .text(`${group.name}:`);
588
-
589
- const labelWidth =
590
- (labelText.node()?.getComputedTextLength?.() ?? group.name.length * 7) +
591
- 6;
592
- legendX += labelWidth;
593
-
594
- // Entries
595
- for (const entry of group.entries) {
596
- const pillG = groupG
597
- .append('g')
598
- .attr('data-legend-entry', entry.value.toLowerCase())
599
- .style('cursor', 'pointer');
600
-
601
- // Estimate text width
602
- const tmpText = legendG
603
- .append('text')
604
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
605
- .attr('font-family', FONT_FAMILY)
606
- .text(entry.value);
607
- const textW =
608
- tmpText.node()?.getComputedTextLength?.() ?? entry.value.length * 7;
609
- tmpText.remove();
610
-
611
- const pillW = textW + LEGEND_PILL_PAD * 2;
612
-
613
- pillG
614
- .append('rect')
615
- .attr('x', legendX)
616
- .attr('y', legendY)
617
- .attr('width', pillW)
618
- .attr('height', LEGEND_PILL_H)
619
- .attr('rx', LEGEND_PILL_RX)
620
- .attr('ry', LEGEND_PILL_RX)
621
- .attr(
622
- 'fill',
623
- mix(entry.color, isDark ? palette.surface : palette.bg, 25)
624
- )
625
- .attr('stroke', entry.color)
626
- .attr('stroke-width', 1);
627
-
628
- pillG
629
- .append('text')
630
- .attr('x', legendX + pillW / 2)
631
- .attr('y', legendY + LEGEND_PILL_H / 2)
632
- .attr('text-anchor', 'middle')
633
- .attr('dominant-baseline', 'central')
634
- .attr('fill', palette.text)
635
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
636
- .attr('font-family', FONT_FAMILY)
637
- .text(entry.value);
638
-
639
- legendX += pillW + LEGEND_GAP;
640
- }
641
-
642
- legendX += LEGEND_GROUP_GAP;
643
- }
552
+ const legendConfig: LegendConfig = {
553
+ groups: parsed.tagGroups,
554
+ position: { placement: 'top-center', titleRelation: 'below-title' },
555
+ mode: 'fixed',
556
+ };
557
+ const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
558
+ const legendG = svg
559
+ .append('g')
560
+ .attr('class', 'er-tag-legend')
561
+ .attr('transform', `translate(0,${legendY})`);
562
+ renderLegendD3(
563
+ legendG,
564
+ legendConfig,
565
+ legendState,
566
+ palette,
567
+ isDark,
568
+ undefined,
569
+ viewW
570
+ );
571
+ legendG.selectAll('[data-legend-group]').classed('er-legend-group', true);
644
572
  }
645
573
 
646
574
  // ── Semantic Legend ──
647
- // Rendered when semantic role detection is enabled (no tag groups, no explicit colors).
648
- // Follows the sequence-legend pattern: one clickable "Role" group pill that expands
649
- // to show colored-dot entries. Clicking toggles semanticColorsActive on/off.
650
575
  if (semanticRoles) {
651
576
  const presentRoles = ROLE_ORDER.filter((role) => {
652
577
  for (const r of semanticRoles.values()) {
@@ -656,156 +581,38 @@ export function renderERDiagram(
656
581
  });
657
582
 
658
583
  if (presentRoles.length > 0) {
659
- // Measure actual text widths for consistent spacing regardless of character mix.
660
- // Falls back to a character-count estimate in jsdom/test environments.
661
- const measureLabelW = (text: string, fontSize: number): number => {
662
- const dummy = svg
663
- .append('text')
664
- .attr('font-size', fontSize)
665
- .attr('font-family', FONT_FAMILY)
666
- .attr('visibility', 'hidden')
667
- .text(text);
668
- const measured =
669
- (dummy.node() as SVGTextElement | null)?.getComputedTextLength?.() ??
670
- 0;
671
- dummy.remove();
672
- return measured > 0 ? measured : text.length * fontSize * 0.6;
673
- };
674
-
675
- const labelWidths = new Map<EntityRole, number>();
676
- for (const role of presentRoles) {
677
- labelWidths.set(
678
- role,
679
- measureLabelW(ROLE_LABELS[role], LEGEND_ENTRY_FONT_SIZE)
680
- );
681
- }
682
-
683
- const groupBg = isDark
684
- ? mix(palette.surface, palette.bg, 50)
685
- : mix(palette.surface, palette.bg, 30);
686
-
687
- const groupName = 'Role';
688
- const pillWidth =
689
- measureLegendText(groupName, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
690
- const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
691
-
692
- let totalWidth: number;
693
- let entriesWidth = 0;
694
- if (semanticActive) {
695
- for (const role of presentRoles) {
696
- entriesWidth +=
697
- LEGEND_DOT_R * 2 +
698
- LEGEND_ENTRY_DOT_GAP +
699
- labelWidths.get(role)! +
700
- LEGEND_ENTRY_TRAIL;
701
- }
702
- totalWidth =
703
- LEGEND_CAPSULE_PAD * 2 +
704
- pillWidth +
705
- LEGEND_ENTRY_TRAIL +
706
- entriesWidth;
707
- } else {
708
- totalWidth = pillWidth;
709
- }
710
-
711
- const legendX = (viewW - totalWidth) / 2;
712
584
  const legendY = DIAGRAM_PADDING + titleHeight;
713
-
714
- const semanticLegendG = svg
585
+ const semanticGroups = [
586
+ {
587
+ name: 'Role',
588
+ entries: presentRoles.map((role) => ({
589
+ value: ROLE_LABELS[role],
590
+ color: palette.colors[ROLE_COLORS[role]],
591
+ })),
592
+ },
593
+ ];
594
+ const legendConfig: LegendConfig = {
595
+ groups: semanticGroups,
596
+ position: { placement: 'top-center', titleRelation: 'below-title' },
597
+ mode: 'fixed',
598
+ };
599
+ const legendState: LegendState = {
600
+ activeGroup: semanticActive ? 'Role' : null,
601
+ };
602
+ const legendG = svg
715
603
  .append('g')
716
604
  .attr('class', 'er-semantic-legend')
717
- .attr('data-legend-group', 'role')
718
- .attr('transform', `translate(${legendX}, ${legendY})`)
719
- .style('cursor', 'pointer');
720
-
721
- if (semanticActive) {
722
- // ── Expanded: outer capsule + inner pill + dot entries ──
723
- semanticLegendG
724
- .append('rect')
725
- .attr('width', totalWidth)
726
- .attr('height', LEGEND_HEIGHT)
727
- .attr('rx', LEGEND_HEIGHT / 2)
728
- .attr('fill', groupBg);
729
-
730
- semanticLegendG
731
- .append('rect')
732
- .attr('x', LEGEND_CAPSULE_PAD)
733
- .attr('y', LEGEND_CAPSULE_PAD)
734
- .attr('width', pillWidth)
735
- .attr('height', pillH)
736
- .attr('rx', pillH / 2)
737
- .attr('fill', palette.bg);
738
-
739
- semanticLegendG
740
- .append('rect')
741
- .attr('x', LEGEND_CAPSULE_PAD)
742
- .attr('y', LEGEND_CAPSULE_PAD)
743
- .attr('width', pillWidth)
744
- .attr('height', pillH)
745
- .attr('rx', pillH / 2)
746
- .attr('fill', 'none')
747
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
748
- .attr('stroke-width', 0.75);
749
-
750
- semanticLegendG
751
- .append('text')
752
- .attr('x', LEGEND_CAPSULE_PAD + pillWidth / 2)
753
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
754
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
755
- .attr('font-weight', '500')
756
- .attr('fill', palette.text)
757
- .attr('text-anchor', 'middle')
758
- .attr('font-family', FONT_FAMILY)
759
- .text(groupName);
760
-
761
- let entryX = LEGEND_CAPSULE_PAD + pillWidth + LEGEND_ENTRY_TRAIL;
762
- for (const role of presentRoles) {
763
- const label = ROLE_LABELS[role];
764
- const roleColor = palette.colors[ROLE_COLORS[role]];
765
-
766
- const entryG = semanticLegendG
767
- .append('g')
768
- .attr('data-legend-entry', role);
769
-
770
- entryG
771
- .append('circle')
772
- .attr('cx', entryX + LEGEND_DOT_R)
773
- .attr('cy', LEGEND_HEIGHT / 2)
774
- .attr('r', LEGEND_DOT_R)
775
- .attr('fill', roleColor);
776
-
777
- const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
778
- entryG
779
- .append('text')
780
- .attr('x', textX)
781
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
782
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
783
- .attr('fill', palette.textMuted)
784
- .attr('font-family', FONT_FAMILY)
785
- .text(label);
786
-
787
- entryX = textX + labelWidths.get(role)! + LEGEND_ENTRY_TRAIL;
788
- }
789
- } else {
790
- // ── Collapsed: single muted pill, no entries ──
791
- semanticLegendG
792
- .append('rect')
793
- .attr('width', pillWidth)
794
- .attr('height', LEGEND_HEIGHT)
795
- .attr('rx', LEGEND_HEIGHT / 2)
796
- .attr('fill', groupBg);
797
-
798
- semanticLegendG
799
- .append('text')
800
- .attr('x', pillWidth / 2)
801
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
802
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
803
- .attr('font-weight', '500')
804
- .attr('fill', palette.textMuted)
805
- .attr('text-anchor', 'middle')
806
- .attr('font-family', FONT_FAMILY)
807
- .text(groupName);
808
- }
605
+ .attr('transform', `translate(0,${legendY})`);
606
+ renderLegendD3(
607
+ legendG,
608
+ legendConfig,
609
+ legendState,
610
+ palette,
611
+ isDark,
612
+ undefined,
613
+ viewW
614
+ );
615
+ legendG.selectAll('[data-legend-group]').classed('er-legend-group', true);
809
616
  }
810
617
  }
811
618
  }