@diagrammo/dgmo 0.8.8 → 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.
@@ -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
  }
@@ -22,6 +22,12 @@ import {
22
22
  LEGEND_ICON_W,
23
23
  measureLegendText,
24
24
  } from '../utils/legend-constants';
25
+ import { renderLegendD3 } from '../utils/legend-d3';
26
+ import type {
27
+ LegendConfig,
28
+ LegendState,
29
+ LegendCallbacks,
30
+ } from '../utils/legend-types';
25
31
  import {
26
32
  TITLE_FONT_SIZE,
27
33
  TITLE_FONT_WEIGHT,
@@ -1893,7 +1899,7 @@ function renderTagLegend(
1893
1899
  const isSwimlane =
1894
1900
  currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
1895
1901
  const showIcon = !legendViewMode && tagGroups.length > 0;
1896
- const iconReserve = showIcon ? LEGEND_ICON_W : 0;
1902
+ const iconReserve = showIcon && isActive ? LEGEND_ICON_W : 0;
1897
1903
  const pillW =
1898
1904
  measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) +
1899
1905
  LEGEND_PILL_PAD +
@@ -1940,196 +1946,148 @@ function renderTagLegend(
1940
1946
 
1941
1947
  let cursorX = 0;
1942
1948
 
1943
- for (let i = 0; i < visibleGroups.length; i++) {
1944
- const group = visibleGroups[i];
1945
- const isActive =
1946
- activeGroupName?.toLowerCase() === group.name.toLowerCase();
1947
- const isSwimlane =
1948
- currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
1949
+ // Render tag groups via centralized legend system
1950
+ if (visibleGroups.length > 0) {
1949
1951
  const showIcon = !legendViewMode && tagGroups.length > 0;
1950
1952
  const iconReserve = showIcon ? LEGEND_ICON_W : 0;
1951
- const pillW =
1952
- measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) +
1953
- LEGEND_PILL_PAD +
1954
- iconReserve;
1955
- const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
1956
- const groupW = groupWidths[i];
1957
-
1958
- const gEl = legendRow
1959
- .append('g')
1960
- .attr('transform', `translate(${cursorX}, 0)`)
1961
- .attr('class', 'gantt-tag-legend-group')
1962
- .attr('data-tag-group', group.name)
1963
- .attr('data-line-number', String(group.lineNumber))
1964
- .style('cursor', 'pointer')
1965
- .on('click', () => {
1966
- if (onToggle) onToggle(group.name);
1967
- });
1968
-
1969
- if (isActive) {
1970
- // Outer capsule background
1971
- gEl
1972
- .append('rect')
1973
- .attr('width', groupW)
1974
- .attr('height', LEGEND_HEIGHT)
1975
- .attr('rx', LEGEND_HEIGHT / 2)
1976
- .attr('fill', groupBg);
1977
- }
1978
1953
 
1979
- const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
1980
- const pillYOff = LEGEND_CAPSULE_PAD;
1981
-
1982
- // Pill background
1983
- gEl
1984
- .append('rect')
1985
- .attr('x', pillXOff)
1986
- .attr('y', pillYOff)
1987
- .attr('width', pillW)
1988
- .attr('height', pillH)
1989
- .attr('rx', pillH / 2)
1990
- .attr('fill', isActive ? palette.bg : groupBg);
1991
-
1992
- // Active pill border
1993
- if (isActive) {
1994
- gEl
1995
- .append('rect')
1996
- .attr('x', pillXOff)
1997
- .attr('y', pillYOff)
1998
- .attr('width', pillW)
1999
- .attr('height', pillH)
2000
- .attr('rx', pillH / 2)
2001
- .attr('fill', 'none')
2002
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
2003
- .attr('stroke-width', 0.75);
2004
- }
1954
+ // Build groups with filtered entries
1955
+ const legendGroups = visibleGroups.map((g) => {
1956
+ const key = g.name.toLowerCase();
1957
+ const entries = filteredEntries.get(key) ?? g.entries;
1958
+ return {
1959
+ name: g.name,
1960
+ entries: entries.map((e) => ({ value: e.value, color: e.color })),
1961
+ };
1962
+ });
2005
1963
 
2006
- // Pill text (offset to leave room for icon on right)
2007
- const textW =
2008
- measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
2009
- gEl
2010
- .append('text')
2011
- .attr('x', pillXOff + textW / 2)
2012
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
2013
- .attr('text-anchor', 'middle')
2014
- .attr('font-size', `${LEGEND_PILL_FONT_SIZE}px`)
2015
- .attr('font-weight', '500')
2016
- .attr('fill', isActive || isSwimlane ? palette.text : palette.textMuted)
2017
- .text(group.name);
2018
-
2019
- // swimlane icon (after pill name)
2020
- if (showIcon) {
2021
- const iconX = pillXOff + textW + 3;
2022
- const iconY = (LEGEND_HEIGHT - 10) / 2;
2023
- const iconEl = drawSwimlaneIcon(gEl, iconX, iconY, isSwimlane, palette);
2024
- iconEl.append('title').text(`Group by ${group.name}`);
2025
- iconEl.style('cursor', 'pointer').on('click', (event: Event) => {
2026
- event.stopPropagation();
2027
- if (onSwimlaneChange) {
2028
- onSwimlaneChange(
2029
- currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase()
2030
- ? null
2031
- : group.name
2032
- );
1964
+ const legendConfig: LegendConfig = {
1965
+ groups: legendGroups,
1966
+ position: {
1967
+ placement: 'top-center' as const,
1968
+ titleRelation: 'below-title' as const,
1969
+ },
1970
+ mode: 'fixed' as const,
1971
+ capsulePillAddonWidth: iconReserve,
1972
+ };
1973
+ const legendState: LegendState = { activeGroup: activeGroupName };
1974
+
1975
+ const tagGroupsW =
1976
+ visibleGroups.reduce((s, _, i) => s + groupWidths[i], 0) +
1977
+ Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
1978
+ const tagGroupG = legendRow.append('g');
1979
+
1980
+ const legendCallbacks: LegendCallbacks = {
1981
+ onGroupToggle: onToggle,
1982
+ onEntryHover: (groupName, entryValue) => {
1983
+ const tagKey = groupName.toLowerCase();
1984
+ if (entryValue) {
1985
+ const ev = entryValue.toLowerCase();
1986
+ chartG
1987
+ .selectAll<SVGGElement, unknown>('.gantt-task')
1988
+ .each(function () {
1989
+ const el = d3Selection.select(this);
1990
+ el.attr(
1991
+ 'opacity',
1992
+ el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
1993
+ );
1994
+ });
1995
+ chartG
1996
+ .selectAll<SVGElement, unknown>('.gantt-milestone')
1997
+ .attr('opacity', FADE_OPACITY);
1998
+ chartG
1999
+ .selectAll<
2000
+ SVGElement,
2001
+ unknown
2002
+ >('.gantt-group-bar, .gantt-group-summary')
2003
+ .attr('opacity', FADE_OPACITY);
2004
+ svg
2005
+ .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2006
+ .each(function () {
2007
+ const el = d3Selection.select(this);
2008
+ el.attr(
2009
+ 'opacity',
2010
+ el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
2011
+ );
2012
+ });
2013
+ svg
2014
+ .selectAll<SVGGElement, unknown>('.gantt-group-label')
2015
+ .attr('opacity', FADE_OPACITY);
2016
+ svg
2017
+ .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2018
+ .each(function () {
2019
+ const el = d3Selection.select(this);
2020
+ el.attr(
2021
+ 'opacity',
2022
+ el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
2023
+ );
2024
+ });
2025
+ chartG
2026
+ .selectAll<
2027
+ SVGElement,
2028
+ unknown
2029
+ >('.gantt-lane-band, .gantt-lane-accent')
2030
+ .attr('opacity', FADE_OPACITY);
2031
+ } else {
2032
+ if (criticalPathActive) {
2033
+ applyCriticalPathHighlight(svg, chartG);
2034
+ } else {
2035
+ resetHighlightAll(svg, chartG);
2036
+ }
2033
2037
  }
2034
- });
2035
- }
2036
-
2037
- // Entries (when active expanded color group, only used values)
2038
- if (isActive) {
2039
- const tagKey = group.name.toLowerCase();
2040
- const entries = filteredEntries.get(tagKey) ?? group.entries;
2041
- let ex = pillXOff + pillW + LEGEND_CAPSULE_PAD + 4;
2042
- for (const entry of entries) {
2043
- const entryValue = entry.value.toLowerCase();
2044
-
2045
- // Wrap dot + label in a <g> for hover targeting
2046
- const entryG = gEl
2047
- .append('g')
2048
- .attr('class', 'gantt-legend-entry')
2049
- .attr('data-line-number', String(entry.lineNumber))
2050
- .style('cursor', 'pointer');
2051
-
2052
- // Dot
2053
- entryG
2054
- .append('circle')
2055
- .attr('cx', ex + LEGEND_DOT_R)
2056
- .attr('cy', LEGEND_HEIGHT / 2)
2057
- .attr('r', LEGEND_DOT_R)
2058
- .attr('fill', entry.color);
2059
-
2060
- // Label
2061
- entryG
2062
- .append('text')
2063
- .attr('x', ex + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
2064
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 2)
2065
- .attr('text-anchor', 'start')
2066
- .attr('font-size', `${LEGEND_ENTRY_FONT_SIZE}px`)
2067
- .attr('fill', palette.textMuted)
2068
- .text(entry.value);
2069
-
2070
- // Hover: highlight matching tasks + labels + lane headers, fade others
2071
- entryG
2072
- .on('mouseenter', () => {
2073
- chartG
2074
- .selectAll<SVGGElement, unknown>('.gantt-task')
2075
- .each(function () {
2076
- const el = d3Selection.select(this);
2077
- const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
2078
- el.attr('opacity', matches ? 1 : FADE_OPACITY);
2079
- });
2080
- chartG
2081
- .selectAll<SVGElement, unknown>('.gantt-milestone')
2082
- .attr('opacity', FADE_OPACITY);
2083
- chartG
2084
- .selectAll<
2085
- SVGElement,
2086
- unknown
2087
- >('.gantt-group-bar, .gantt-group-summary')
2088
- .attr('opacity', FADE_OPACITY);
2089
- // Fade left-side task labels
2090
- svg
2091
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2092
- .each(function () {
2093
- const el = d3Selection.select(this);
2094
- const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
2095
- el.attr('opacity', matches ? 1 : FADE_OPACITY);
2096
- });
2097
- // Fade group labels
2098
- svg
2099
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
2100
- .attr('opacity', FADE_OPACITY);
2101
- // Fade non-matching lane headers + bands + accents
2102
- svg
2103
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2104
- .each(function () {
2105
- const el = d3Selection.select(this);
2106
- const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
2107
- el.attr('opacity', matches ? 1 : FADE_OPACITY);
2108
- });
2109
- chartG
2110
- .selectAll<
2111
- SVGElement,
2112
- unknown
2113
- >('.gantt-lane-band, .gantt-lane-accent')
2114
- .attr('opacity', FADE_OPACITY);
2115
- })
2116
- .on('mouseleave', () => {
2117
- if (criticalPathActive) {
2118
- applyCriticalPathHighlight(svg, chartG);
2119
- } else {
2120
- resetHighlightAll(svg, chartG);
2038
+ },
2039
+ onGroupRendered: (groupName, groupEl, _isActive) => {
2040
+ // Add swimlane icon and data attributes
2041
+ const group = visibleGroups.find((g) => g.name === groupName);
2042
+ if (group) {
2043
+ groupEl
2044
+ .attr('data-tag-group', group.name)
2045
+ .attr('data-line-number', String(group.lineNumber));
2046
+ }
2047
+ if (showIcon && _isActive) {
2048
+ const isSwimlane =
2049
+ currentSwimlaneGroup?.toLowerCase() === groupName.toLowerCase();
2050
+ const textW =
2051
+ measureLegendText(groupName, LEGEND_PILL_FONT_SIZE) +
2052
+ LEGEND_PILL_PAD;
2053
+ const pillXOff = LEGEND_CAPSULE_PAD;
2054
+ const iconX = pillXOff + textW + 3;
2055
+ const iconY = (LEGEND_HEIGHT - 10) / 2;
2056
+ const iconEl = drawSwimlaneIcon(
2057
+ groupEl,
2058
+ iconX,
2059
+ iconY,
2060
+ isSwimlane,
2061
+ palette
2062
+ );
2063
+ iconEl.append('title').text(`Group by ${groupName}`);
2064
+ iconEl.style('cursor', 'pointer').on('click', (event: Event) => {
2065
+ event.stopPropagation();
2066
+ if (onSwimlaneChange) {
2067
+ onSwimlaneChange(
2068
+ currentSwimlaneGroup?.toLowerCase() === groupName.toLowerCase()
2069
+ ? null
2070
+ : groupName
2071
+ );
2121
2072
  }
2122
2073
  });
2074
+ }
2075
+ },
2076
+ };
2123
2077
 
2124
- ex +=
2125
- LEGEND_DOT_R * 2 +
2126
- LEGEND_ENTRY_DOT_GAP +
2127
- measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
2128
- LEGEND_ENTRY_TRAIL;
2129
- }
2130
- }
2078
+ renderLegendD3(
2079
+ tagGroupG,
2080
+ legendConfig,
2081
+ legendState,
2082
+ palette,
2083
+ isDark,
2084
+ legendCallbacks,
2085
+ tagGroupsW
2086
+ );
2131
2087
 
2132
- cursorX += groupW + LEGEND_GROUP_GAP;
2088
+ for (let i = 0; i < visibleGroups.length; i++) {
2089
+ cursorX += groupWidths[i] + LEGEND_GROUP_GAP;
2090
+ }
2133
2091
  }
2134
2092
 
2135
2093
  // Critical Path pill