@diagrammo/dgmo 0.8.9 → 0.8.11

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.
Files changed (46) hide show
  1. package/AGENTS.md +3 -0
  2. package/dist/cli.cjs +245 -672
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.d.cts +2 -3
  5. package/dist/editor.d.ts +2 -3
  6. package/dist/editor.js.map +1 -1
  7. package/dist/index.cjs +1623 -800
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +153 -1
  10. package/dist/index.d.ts +153 -1
  11. package/dist/index.js +1619 -802
  12. package/dist/index.js.map +1 -1
  13. package/docs/language-reference.md +28 -2
  14. package/gallery/fixtures/sitemap-full.dgmo +1 -0
  15. package/package.json +14 -17
  16. package/src/boxes-and-lines/layout.ts +48 -8
  17. package/src/boxes-and-lines/parser.ts +59 -13
  18. package/src/boxes-and-lines/renderer.ts +34 -138
  19. package/src/c4/layout.ts +31 -10
  20. package/src/c4/renderer.ts +25 -138
  21. package/src/class/renderer.ts +185 -186
  22. package/src/d3.ts +194 -222
  23. package/src/echarts.ts +56 -57
  24. package/src/editor/index.ts +1 -2
  25. package/src/er/renderer.ts +52 -245
  26. package/src/gantt/renderer.ts +140 -182
  27. package/src/gantt/resolver.ts +19 -14
  28. package/src/index.ts +23 -1
  29. package/src/infra/renderer.ts +91 -244
  30. package/src/kanban/renderer.ts +29 -133
  31. package/src/label-layout.ts +286 -0
  32. package/src/org/renderer.ts +103 -170
  33. package/src/render.ts +39 -9
  34. package/src/sequence/parser.ts +4 -0
  35. package/src/sequence/renderer.ts +47 -154
  36. package/src/sitemap/layout.ts +180 -38
  37. package/src/sitemap/parser.ts +64 -23
  38. package/src/sitemap/renderer.ts +73 -161
  39. package/src/utils/arrows.ts +1 -1
  40. package/src/utils/legend-constants.ts +6 -0
  41. package/src/utils/legend-d3.ts +400 -0
  42. package/src/utils/legend-layout.ts +491 -0
  43. package/src/utils/legend-svg.ts +28 -2
  44. package/src/utils/legend-types.ts +166 -0
  45. package/src/utils/parsing.ts +1 -1
  46. package/src/utils/tag-groups.ts +1 -1
package/src/echarts.ts CHANGED
@@ -4,6 +4,12 @@ import { FONT_FAMILY } from './fonts';
4
4
  import { injectBranding } from './branding';
5
5
  import { renderLegendSvg } from './utils/legend-svg';
6
6
  import type { LegendGroupData } from './utils/legend-svg';
7
+ import {
8
+ type LabelRect,
9
+ type PointCircle,
10
+ rectsOverlap,
11
+ rectCircleOverlap,
12
+ } from './label-layout';
7
13
 
8
14
  // ============================================================
9
15
  // Types
@@ -17,14 +23,14 @@ export type ExtendedChartType =
17
23
  | 'heatmap'
18
24
  | 'funnel';
19
25
 
20
- export interface ExtendedChartDataPoint {
26
+ interface ExtendedChartDataPoint {
21
27
  label: string;
22
28
  value: number;
23
29
  color?: string;
24
30
  lineNumber: number;
25
31
  }
26
32
 
27
- export interface ParsedSankeyLink {
33
+ interface ParsedSankeyLink {
28
34
  source: string;
29
35
  target: string;
30
36
  value: number;
@@ -33,14 +39,14 @@ export interface ParsedSankeyLink {
33
39
  lineNumber: number;
34
40
  }
35
41
 
36
- export interface ParsedFunction {
42
+ interface ParsedFunction {
37
43
  name: string;
38
44
  expression: string;
39
45
  color?: string;
40
46
  lineNumber: number;
41
47
  }
42
48
 
43
- export interface ParsedScatterPoint {
49
+ interface ParsedScatterPoint {
44
50
  name: string;
45
51
  x: number;
46
52
  y: number;
@@ -50,7 +56,7 @@ export interface ParsedScatterPoint {
50
56
  lineNumber: number;
51
57
  }
52
58
 
53
- export interface ParsedHeatmapRow {
59
+ interface ParsedHeatmapRow {
54
60
  label: string;
55
61
  values: number[];
56
62
  lineNumber: number;
@@ -733,7 +739,8 @@ export function buildExtendedChartOption(
733
739
 
734
740
  // Sankey chart has different structure
735
741
  if (parsed.type === 'sankey') {
736
- return buildSankeyOption(parsed, textColor, colors, titleConfig);
742
+ const bg = isDark ? palette.surface : palette.bg;
743
+ return buildSankeyOption(parsed, textColor, colors, bg, titleConfig);
737
744
  }
738
745
 
739
746
  // Chord diagram
@@ -794,6 +801,7 @@ function buildSankeyOption(
794
801
  parsed: ParsedExtendedChart,
795
802
  textColor: string,
796
803
  colors: string[],
804
+ bg: string,
797
805
  titleConfig: EChartsOption['title']
798
806
  ): EChartsOption {
799
807
  // Extract unique nodes from links
@@ -805,12 +813,18 @@ function buildSankeyOption(
805
813
  }
806
814
  }
807
815
 
808
- const nodes = Array.from(nodeSet).map((name, index) => ({
809
- name,
810
- itemStyle: {
811
- color: parsed.nodeColors?.[name] ?? colors[index % colors.length],
812
- },
813
- }));
816
+ // Tint colors with background so the diagram feels less saturated.
817
+ // Nodes get a lighter tint so they stand out; links get more tinting.
818
+ const tintNode = (c: string) => mix(c, bg, 75);
819
+ const tintLink = (c: string) => mix(c, bg, 45);
820
+
821
+ const nodeColorMap = new Map<string, string>();
822
+ const nodes = Array.from(nodeSet).map((name, index) => {
823
+ const raw = parsed.nodeColors?.[name] ?? colors[index % colors.length];
824
+ const tinted = tintNode(raw);
825
+ nodeColorMap.set(name, tintLink(raw));
826
+ return { name, itemStyle: { color: tinted } };
827
+ });
814
828
 
815
829
  return {
816
830
  ...CHART_BASE,
@@ -834,11 +848,15 @@ function buildSankeyOption(
834
848
  source: link.source,
835
849
  target: link.target,
836
850
  value: link.value,
837
- ...(link.color && { lineStyle: { color: link.color } }),
851
+ lineStyle: {
852
+ color: link.color
853
+ ? tintLink(link.color)
854
+ : nodeColorMap.get(link.source),
855
+ },
838
856
  })),
839
857
  lineStyle: {
840
- color: 'gradient',
841
858
  curveness: 0.5,
859
+ opacity: 0.6,
842
860
  },
843
861
  label: {
844
862
  color: textColor,
@@ -1182,37 +1200,6 @@ export function getExtendedChartLegendGroups(
1182
1200
  // Scatter label collision avoidance — greedy placement algorithm
1183
1201
  // ---------------------------------------------------------------------------
1184
1202
 
1185
- interface LabelRect {
1186
- x: number;
1187
- y: number;
1188
- w: number;
1189
- h: number;
1190
- }
1191
- interface PointCircle {
1192
- cx: number;
1193
- cy: number;
1194
- r: number;
1195
- }
1196
-
1197
- /** Axis-aligned bounding box overlap test. @internal exported for testing */
1198
- export function rectsOverlap(a: LabelRect, b: LabelRect): boolean {
1199
- return (
1200
- a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
1201
- );
1202
- }
1203
-
1204
- /** Rect vs circle overlap using nearest-point-on-rect distance check. @internal exported for testing */
1205
- export function rectCircleOverlap(
1206
- rect: LabelRect,
1207
- circle: PointCircle
1208
- ): boolean {
1209
- const nearestX = Math.max(rect.x, Math.min(circle.cx, rect.x + rect.w));
1210
- const nearestY = Math.max(rect.y, Math.min(circle.cy, rect.y + rect.h));
1211
- const dx = nearestX - circle.cx;
1212
- const dy = nearestY - circle.cy;
1213
- return dx * dx + dy * dy < circle.r * circle.r;
1214
- }
1215
-
1216
1203
  export interface ScatterLabelPoint {
1217
1204
  name: string;
1218
1205
  px: number;
@@ -1734,12 +1721,22 @@ function buildHeatmapOption(
1734
1721
  });
1735
1722
  });
1736
1723
 
1724
+ // Rotate column labels only when they'd overlap at the default font size.
1725
+ // Estimate: each char ~7px at 12px font; rotate if longest label exceeds
1726
+ // an even share of a ~900px-wide chart.
1727
+ const CHAR_WIDTH = 7;
1728
+ const ESTIMATED_CHART_WIDTH = 900;
1729
+ const longestCol = Math.max(...columns.map((c) => c.length), 0);
1730
+ const slotWidth =
1731
+ columns.length > 0 ? ESTIMATED_CHART_WIDTH / columns.length : Infinity;
1732
+ const needsRotation = longestCol * CHAR_WIDTH > slotWidth * 0.85;
1733
+
1737
1734
  return {
1738
1735
  ...CHART_BASE,
1739
1736
  title: titleConfig,
1740
1737
  grid: {
1741
1738
  left: '3%',
1742
- right: '10%',
1739
+ right: '3%',
1743
1740
  bottom: '3%',
1744
1741
  top: parsed.title ? '15%' : '5%',
1745
1742
  containLabel: true,
@@ -1747,6 +1744,7 @@ function buildHeatmapOption(
1747
1744
  xAxis: {
1748
1745
  type: 'category',
1749
1746
  data: columns,
1747
+ position: 'top',
1750
1748
  splitArea: {
1751
1749
  show: true,
1752
1750
  },
@@ -1755,12 +1753,19 @@ function buildHeatmapOption(
1755
1753
  },
1756
1754
  axisLabel: {
1757
1755
  color: textColor,
1758
- fontSize: 16,
1756
+ fontSize: 12,
1757
+ interval: 0,
1758
+ ...(needsRotation && {
1759
+ rotate: -45,
1760
+ width: 200,
1761
+ overflow: 'none' as const,
1762
+ }),
1759
1763
  },
1760
1764
  },
1761
1765
  yAxis: {
1762
1766
  type: 'category',
1763
1767
  data: rowLabels,
1768
+ inverse: true,
1764
1769
  splitArea: {
1765
1770
  show: true,
1766
1771
  },
@@ -1769,16 +1774,14 @@ function buildHeatmapOption(
1769
1774
  },
1770
1775
  axisLabel: {
1771
1776
  color: textColor,
1772
- fontSize: 16,
1777
+ fontSize: 12,
1778
+ interval: 0,
1773
1779
  },
1774
1780
  },
1775
1781
  visualMap: {
1782
+ show: false,
1776
1783
  min: minValue,
1777
1784
  max: maxValue,
1778
- calculable: true,
1779
- orient: 'vertical',
1780
- right: '2%',
1781
- top: 'center',
1782
1785
  inRange: {
1783
1786
  color: [
1784
1787
  mix(palette.primary, bg, 30),
@@ -1787,9 +1790,6 @@ function buildHeatmapOption(
1787
1790
  mix(palette.colors.orange, bg, 30),
1788
1791
  ],
1789
1792
  },
1790
- textStyle: {
1791
- color: textColor,
1792
- },
1793
1793
  },
1794
1794
  series: [
1795
1795
  {
@@ -1806,9 +1806,8 @@ function buildHeatmapOption(
1806
1806
  fontWeight: 'bold' as const,
1807
1807
  },
1808
1808
  emphasis: {
1809
- ...EMPHASIS_SELF,
1809
+ disabled: true,
1810
1810
  },
1811
- blur: BLUR_DIM,
1812
1811
  },
1813
1812
  ],
1814
1813
  };
@@ -1,5 +1,4 @@
1
1
  import { LRLanguage, LanguageSupport } from '@codemirror/language';
2
- import type { Extension } from '@codemirror/state';
3
2
  import { parser } from './dgmo.grammar.js';
4
3
  import { dgmoHighlighting } from './highlight';
5
4
 
@@ -25,4 +24,4 @@ export const dgmoLanguageSupport = new LanguageSupport(dgmoLanguage);
25
24
  * Consumers should add indentationMarkers() separately if desired
26
25
  * (from @replit/codemirror-indentation-markers).
27
26
  */
28
- export const dgmoExtension: Extension = dgmoLanguageSupport;
27
+ export const dgmoExtension = dgmoLanguageSupport;
@@ -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
  }