@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/AGENTS.md +3 -0
- package/dist/cli.cjs +191 -191
- package/dist/index.cjs +1318 -724
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +147 -1
- package/dist/index.d.ts +147 -1
- package/dist/index.js +1314 -724
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +28 -2
- package/gallery/fixtures/sitemap-full.dgmo +1 -0
- package/package.json +1 -1
- package/src/boxes-and-lines/layout.ts +48 -8
- package/src/boxes-and-lines/parser.ts +59 -13
- package/src/boxes-and-lines/renderer.ts +33 -137
- package/src/c4/renderer.ts +25 -138
- package/src/class/renderer.ts +185 -186
- package/src/d3.ts +114 -191
- package/src/er/renderer.ts +52 -245
- package/src/gantt/renderer.ts +140 -182
- package/src/index.ts +21 -1
- package/src/infra/renderer.ts +91 -244
- package/src/kanban/renderer.ts +22 -129
- package/src/org/renderer.ts +103 -170
- package/src/render.ts +39 -9
- package/src/sequence/renderer.ts +31 -151
- package/src/sitemap/layout.ts +180 -38
- package/src/sitemap/parser.ts +64 -23
- package/src/sitemap/renderer.ts +73 -161
- package/src/utils/legend-constants.ts +6 -0
- package/src/utils/legend-d3.ts +400 -0
- package/src/utils/legend-layout.ts +495 -0
- package/src/utils/legend-svg.ts +26 -0
- package/src/utils/legend-types.ts +169 -0
package/src/sitemap/renderer.ts
CHANGED
|
@@ -11,20 +11,14 @@ import type { ParsedSitemap } from './types';
|
|
|
11
11
|
import type { SitemapLayoutResult, SitemapLegendGroup } from './layout';
|
|
12
12
|
import {
|
|
13
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
14
|
LEGEND_GROUP_GAP,
|
|
22
15
|
LEGEND_EYE_SIZE,
|
|
23
16
|
LEGEND_EYE_GAP,
|
|
24
17
|
EYE_OPEN_PATH,
|
|
25
18
|
EYE_CLOSED_PATH,
|
|
26
|
-
measureLegendText,
|
|
27
19
|
} from '../utils/legend-constants';
|
|
20
|
+
import { renderLegendD3 } from '../utils/legend-d3';
|
|
21
|
+
import type { LegendConfig, LegendState } from '../utils/legend-types';
|
|
28
22
|
|
|
29
23
|
// ============================================================
|
|
30
24
|
// Constants
|
|
@@ -98,6 +92,12 @@ const lineGenerator = d3Shape
|
|
|
98
92
|
.y((d) => d.y)
|
|
99
93
|
.curve(d3Shape.curveBasis);
|
|
100
94
|
|
|
95
|
+
const lineGeneratorLinear = d3Shape
|
|
96
|
+
.line<{ x: number; y: number }>()
|
|
97
|
+
.x((d) => d.x)
|
|
98
|
+
.y((d) => d.y)
|
|
99
|
+
.curve(d3Shape.curveLinear);
|
|
100
|
+
|
|
101
101
|
// ============================================================
|
|
102
102
|
// Main Renderer
|
|
103
103
|
// ============================================================
|
|
@@ -380,7 +380,8 @@ export function renderSitemap(
|
|
|
380
380
|
? `sm-arrow-${edge.color.replace('#', '')}`
|
|
381
381
|
: 'sm-arrow';
|
|
382
382
|
|
|
383
|
-
const
|
|
383
|
+
const gen = edge.deferred ? lineGeneratorLinear : lineGenerator;
|
|
384
|
+
const pathD = gen(edge.points);
|
|
384
385
|
if (pathD) {
|
|
385
386
|
edgeG
|
|
386
387
|
.append('path')
|
|
@@ -617,164 +618,75 @@ function renderLegend(
|
|
|
617
618
|
): void {
|
|
618
619
|
if (legendGroups.length === 0) return;
|
|
619
620
|
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
for (const group of visibleGroups) {
|
|
648
|
-
const isActive = activeTagGroup != null;
|
|
649
|
-
const pillW =
|
|
650
|
-
measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
651
|
-
|
|
652
|
-
const gX = fixedPositions?.get(group.name) ?? group.x;
|
|
653
|
-
const gY = fixedPositions ? 0 : group.y;
|
|
654
|
-
|
|
655
|
-
const legendG = parent
|
|
656
|
-
.append('g')
|
|
657
|
-
.attr('transform', `translate(${gX}, ${gY})`)
|
|
658
|
-
.attr('class', 'sitemap-legend-group')
|
|
659
|
-
.attr('data-legend-group', group.name.toLowerCase())
|
|
660
|
-
.style('cursor', 'pointer');
|
|
661
|
-
|
|
662
|
-
// Outer capsule background (active/expanded only)
|
|
663
|
-
if (isActive) {
|
|
664
|
-
legendG
|
|
665
|
-
.append('rect')
|
|
666
|
-
.attr('width', group.width)
|
|
667
|
-
.attr('height', LEGEND_HEIGHT)
|
|
668
|
-
.attr('rx', LEGEND_HEIGHT / 2)
|
|
669
|
-
.attr('fill', groupBg);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
673
|
-
const pillYOff = LEGEND_CAPSULE_PAD;
|
|
674
|
-
const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
|
|
675
|
-
|
|
676
|
-
// Pill background
|
|
677
|
-
legendG
|
|
678
|
-
.append('rect')
|
|
679
|
-
.attr('x', pillXOff)
|
|
680
|
-
.attr('y', pillYOff)
|
|
681
|
-
.attr('width', pillW)
|
|
682
|
-
.attr('height', pillH)
|
|
683
|
-
.attr('rx', pillH / 2)
|
|
684
|
-
.attr('fill', isActive ? palette.bg : groupBg);
|
|
685
|
-
|
|
686
|
-
// Active pill border
|
|
687
|
-
if (isActive) {
|
|
688
|
-
legendG
|
|
689
|
-
.append('rect')
|
|
690
|
-
.attr('x', pillXOff)
|
|
691
|
-
.attr('y', pillYOff)
|
|
692
|
-
.attr('width', pillW)
|
|
693
|
-
.attr('height', pillH)
|
|
694
|
-
.attr('rx', pillH / 2)
|
|
695
|
-
.attr('fill', 'none')
|
|
696
|
-
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
697
|
-
.attr('stroke-width', 0.75);
|
|
698
|
-
}
|
|
621
|
+
const groups = legendGroups.map((g) => ({
|
|
622
|
+
name: g.name,
|
|
623
|
+
entries: g.entries.map((e) => ({ value: e.value, color: e.color })),
|
|
624
|
+
}));
|
|
625
|
+
|
|
626
|
+
const isFixedMode = fixedWidth != null;
|
|
627
|
+
const eyeAddonWidth = isFixedMode ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
|
|
628
|
+
|
|
629
|
+
const legendConfig: LegendConfig = {
|
|
630
|
+
groups,
|
|
631
|
+
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
632
|
+
mode: 'fixed',
|
|
633
|
+
capsulePillAddonWidth: eyeAddonWidth,
|
|
634
|
+
};
|
|
635
|
+
const legendState: LegendState = { activeGroup: activeTagGroup ?? null };
|
|
636
|
+
const containerWidth =
|
|
637
|
+
fixedWidth ?? legendGroups[0]?.x + (legendGroups[0]?.width ?? 200);
|
|
638
|
+
|
|
639
|
+
const legendHandle = renderLegendD3(
|
|
640
|
+
parent,
|
|
641
|
+
legendConfig,
|
|
642
|
+
legendState,
|
|
643
|
+
palette,
|
|
644
|
+
isDark,
|
|
645
|
+
undefined,
|
|
646
|
+
containerWidth
|
|
647
|
+
);
|
|
699
648
|
|
|
700
|
-
|
|
701
|
-
legendG
|
|
702
|
-
.append('text')
|
|
703
|
-
.attr('x', pillXOff + pillW / 2)
|
|
704
|
-
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
705
|
-
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
706
|
-
.attr('font-weight', '500')
|
|
707
|
-
.attr('fill', isActive ? palette.text : palette.textMuted)
|
|
708
|
-
.attr('text-anchor', 'middle')
|
|
709
|
-
.text(group.name);
|
|
649
|
+
parent.selectAll('[data-legend-group]').classed('sitemap-legend-group', true);
|
|
710
650
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
651
|
+
// Inject eye icons into active group capsules (fixed/app mode only)
|
|
652
|
+
if (isFixedMode) {
|
|
653
|
+
const computedLayout = legendHandle.getLayout();
|
|
654
|
+
if (computedLayout.activeCapsule?.addonX != null) {
|
|
655
|
+
const capsule = computedLayout.activeCapsule;
|
|
656
|
+
const groupKey = capsule.groupName.toLowerCase();
|
|
714
657
|
const isHidden = hiddenAttributes?.has(groupKey) ?? false;
|
|
715
|
-
const eyeX = pillXOff + pillW + LEGEND_EYE_GAP;
|
|
716
|
-
const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
|
|
717
|
-
const hitPad = 6;
|
|
718
|
-
|
|
719
|
-
const eyeG = legendG
|
|
720
|
-
.append('g')
|
|
721
|
-
.attr('class', 'sitemap-legend-eye')
|
|
722
|
-
.attr('data-legend-visibility', groupKey)
|
|
723
|
-
.style('cursor', 'pointer')
|
|
724
|
-
.attr('opacity', isHidden ? 0.4 : 0.7);
|
|
725
658
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
.attr('width', LEGEND_EYE_SIZE + hitPad * 2)
|
|
732
|
-
.attr('height', LEGEND_EYE_SIZE + hitPad * 2)
|
|
733
|
-
.attr('fill', 'transparent')
|
|
734
|
-
.attr('pointer-events', 'all');
|
|
735
|
-
|
|
736
|
-
eyeG
|
|
737
|
-
.append('path')
|
|
738
|
-
.attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
|
|
739
|
-
.attr('transform', `translate(${eyeX}, ${eyeY})`)
|
|
740
|
-
.attr('fill', 'none')
|
|
741
|
-
.attr('stroke', palette.textMuted)
|
|
742
|
-
.attr('stroke-width', 1.2)
|
|
743
|
-
.attr('stroke-linecap', 'round')
|
|
744
|
-
.attr('stroke-linejoin', 'round');
|
|
745
|
-
}
|
|
659
|
+
const activeGroupEl = parent.select(`[data-legend-group="${groupKey}"]`);
|
|
660
|
+
if (!activeGroupEl.empty()) {
|
|
661
|
+
const eyeX = capsule.addonX!;
|
|
662
|
+
const eyeY = (LEGEND_HEIGHT - LEGEND_EYE_SIZE) / 2;
|
|
663
|
+
const hitPad = 6;
|
|
746
664
|
|
|
747
|
-
|
|
748
|
-
if (isActive) {
|
|
749
|
-
const eyeShift =
|
|
750
|
-
fixedWidth != null ? LEGEND_EYE_SIZE + LEGEND_EYE_GAP : 0;
|
|
751
|
-
let entryX = pillXOff + pillW + 4 + eyeShift;
|
|
752
|
-
for (const entry of group.entries) {
|
|
753
|
-
const entryG = legendG
|
|
665
|
+
const eyeG = activeGroupEl
|
|
754
666
|
.append('g')
|
|
755
|
-
.attr('
|
|
756
|
-
.
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
.
|
|
762
|
-
.attr('
|
|
763
|
-
.attr('
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
.
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
.
|
|
771
|
-
.attr('
|
|
772
|
-
.
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
667
|
+
.attr('class', 'sitemap-legend-eye')
|
|
668
|
+
.attr('data-legend-visibility', groupKey)
|
|
669
|
+
.style('cursor', 'pointer')
|
|
670
|
+
.attr('opacity', isHidden ? 0.4 : 0.7);
|
|
671
|
+
|
|
672
|
+
eyeG
|
|
673
|
+
.append('rect')
|
|
674
|
+
.attr('x', eyeX - hitPad)
|
|
675
|
+
.attr('y', eyeY - hitPad)
|
|
676
|
+
.attr('width', LEGEND_EYE_SIZE + hitPad * 2)
|
|
677
|
+
.attr('height', LEGEND_EYE_SIZE + hitPad * 2)
|
|
678
|
+
.attr('fill', 'transparent')
|
|
679
|
+
.attr('pointer-events', 'all');
|
|
680
|
+
|
|
681
|
+
eyeG
|
|
682
|
+
.append('path')
|
|
683
|
+
.attr('d', isHidden ? EYE_CLOSED_PATH : EYE_OPEN_PATH)
|
|
684
|
+
.attr('transform', `translate(${eyeX}, ${eyeY})`)
|
|
685
|
+
.attr('fill', 'none')
|
|
686
|
+
.attr('stroke', palette.textMuted)
|
|
687
|
+
.attr('stroke-width', 1.2)
|
|
688
|
+
.attr('stroke-linecap', 'round')
|
|
689
|
+
.attr('stroke-linejoin', 'round');
|
|
778
690
|
}
|
|
779
691
|
}
|
|
780
692
|
}
|
|
@@ -16,6 +16,12 @@ export const LEGEND_EYE_SIZE = 14;
|
|
|
16
16
|
export const LEGEND_EYE_GAP = 6;
|
|
17
17
|
export const LEGEND_ICON_W = 20;
|
|
18
18
|
|
|
19
|
+
// ββ Spacing constants (centralized legend system) βββββββββββ
|
|
20
|
+
export const LEGEND_TOP_PAD = 12;
|
|
21
|
+
export const LEGEND_TITLE_GAP = 8;
|
|
22
|
+
export const LEGEND_CONTENT_GAP = 12;
|
|
23
|
+
export const LEGEND_MAX_ENTRY_ROWS = 3;
|
|
24
|
+
|
|
19
25
|
// ββ Proportional text measurement ββββββββββββββββββββββββββββ
|
|
20
26
|
// Helvetica character width ratios (fraction of fontSize).
|
|
21
27
|
// Replaces the naive `chars * 0.6 * fontSize` estimate with
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Centralized legend D3 renderer
|
|
3
|
+
// Thin adapter: layout engine β D3 DOM
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
LEGEND_HEIGHT,
|
|
8
|
+
LEGEND_PILL_FONT_SIZE,
|
|
9
|
+
LEGEND_DOT_R,
|
|
10
|
+
LEGEND_ENTRY_FONT_SIZE,
|
|
11
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
12
|
+
measureLegendText,
|
|
13
|
+
} from './legend-constants';
|
|
14
|
+
import { computeLegendLayout } from './legend-layout';
|
|
15
|
+
import { mix } from '../palettes/color-utils';
|
|
16
|
+
import { FONT_FAMILY } from '../fonts';
|
|
17
|
+
import type {
|
|
18
|
+
LegendConfig,
|
|
19
|
+
LegendState,
|
|
20
|
+
LegendCallbacks,
|
|
21
|
+
LegendHandle,
|
|
22
|
+
LegendPalette,
|
|
23
|
+
LegendLayout,
|
|
24
|
+
LegendPillLayout,
|
|
25
|
+
LegendCapsuleLayout,
|
|
26
|
+
LegendControlLayout,
|
|
27
|
+
D3Sel,
|
|
28
|
+
} from './legend-types';
|
|
29
|
+
|
|
30
|
+
// ββ Main renderer βββββββββββββββββββββββββββββββββββββββββββ
|
|
31
|
+
|
|
32
|
+
export function renderLegendD3(
|
|
33
|
+
container: D3Sel,
|
|
34
|
+
config: LegendConfig,
|
|
35
|
+
state: LegendState,
|
|
36
|
+
palette: LegendPalette,
|
|
37
|
+
isDark: boolean,
|
|
38
|
+
callbacks?: LegendCallbacks,
|
|
39
|
+
containerWidth?: number
|
|
40
|
+
): LegendHandle {
|
|
41
|
+
const width = containerWidth ?? parseFloat(container.attr('width') || '800');
|
|
42
|
+
let currentState = { ...state };
|
|
43
|
+
let currentLayout: LegendLayout;
|
|
44
|
+
|
|
45
|
+
const legendG = container.append('g').attr('class', 'dgmo-legend');
|
|
46
|
+
|
|
47
|
+
function render() {
|
|
48
|
+
currentLayout = computeLegendLayout(config, currentState, width);
|
|
49
|
+
legendG.selectAll('*').remove();
|
|
50
|
+
|
|
51
|
+
if (currentLayout.height === 0) return;
|
|
52
|
+
|
|
53
|
+
// Set active attribute on container
|
|
54
|
+
if (currentState.activeGroup) {
|
|
55
|
+
legendG.attr(
|
|
56
|
+
'data-legend-active',
|
|
57
|
+
currentState.activeGroup.toLowerCase()
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
legendG.attr('data-legend-active', null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const groupBg = isDark
|
|
64
|
+
? mix(palette.surface, palette.bg, 50)
|
|
65
|
+
: mix(palette.surface, palette.bg, 30);
|
|
66
|
+
const pillBorder = mix(palette.textMuted, palette.bg, 50);
|
|
67
|
+
|
|
68
|
+
// Render active capsule
|
|
69
|
+
if (currentLayout.activeCapsule) {
|
|
70
|
+
renderCapsule(
|
|
71
|
+
legendG,
|
|
72
|
+
currentLayout.activeCapsule,
|
|
73
|
+
palette,
|
|
74
|
+
groupBg,
|
|
75
|
+
pillBorder,
|
|
76
|
+
isDark,
|
|
77
|
+
callbacks
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Render collapsed pills
|
|
82
|
+
for (const pill of currentLayout.pills) {
|
|
83
|
+
renderPill(legendG, pill, palette, groupBg, callbacks);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Render controls
|
|
87
|
+
for (const ctrl of currentLayout.controls) {
|
|
88
|
+
renderControl(
|
|
89
|
+
legendG,
|
|
90
|
+
ctrl,
|
|
91
|
+
palette,
|
|
92
|
+
groupBg,
|
|
93
|
+
pillBorder,
|
|
94
|
+
isDark,
|
|
95
|
+
config.controls
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
render();
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
setState(newState: LegendState) {
|
|
104
|
+
currentState = { ...newState };
|
|
105
|
+
render();
|
|
106
|
+
},
|
|
107
|
+
destroy() {
|
|
108
|
+
legendG.remove();
|
|
109
|
+
},
|
|
110
|
+
getHeight() {
|
|
111
|
+
return currentLayout?.height ?? 0;
|
|
112
|
+
},
|
|
113
|
+
getLayout() {
|
|
114
|
+
return currentLayout;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ββ Capsule (active group) ββββββββββββββββββββββββββββββββββ
|
|
120
|
+
|
|
121
|
+
function renderCapsule(
|
|
122
|
+
parent: D3Sel,
|
|
123
|
+
capsule: LegendCapsuleLayout,
|
|
124
|
+
palette: LegendPalette,
|
|
125
|
+
groupBg: string,
|
|
126
|
+
pillBorder: string,
|
|
127
|
+
_isDark: boolean,
|
|
128
|
+
callbacks?: LegendCallbacks
|
|
129
|
+
): void {
|
|
130
|
+
const g = parent
|
|
131
|
+
.append('g')
|
|
132
|
+
.attr('transform', `translate(${capsule.x},${capsule.y})`)
|
|
133
|
+
.attr('data-legend-group', capsule.groupName.toLowerCase())
|
|
134
|
+
.style('cursor', 'pointer');
|
|
135
|
+
|
|
136
|
+
// Outer capsule background
|
|
137
|
+
g.append('rect')
|
|
138
|
+
.attr('width', capsule.width)
|
|
139
|
+
.attr('height', capsule.height)
|
|
140
|
+
.attr('rx', LEGEND_HEIGHT / 2)
|
|
141
|
+
.attr('fill', groupBg);
|
|
142
|
+
|
|
143
|
+
// Inner pill
|
|
144
|
+
const pill = capsule.pill;
|
|
145
|
+
g.append('rect')
|
|
146
|
+
.attr('x', pill.x)
|
|
147
|
+
.attr('y', pill.y)
|
|
148
|
+
.attr('width', pill.width)
|
|
149
|
+
.attr('height', pill.height)
|
|
150
|
+
.attr('rx', pill.height / 2)
|
|
151
|
+
.attr('fill', palette.bg);
|
|
152
|
+
|
|
153
|
+
// Pill border
|
|
154
|
+
g.append('rect')
|
|
155
|
+
.attr('x', pill.x)
|
|
156
|
+
.attr('y', pill.y)
|
|
157
|
+
.attr('width', pill.width)
|
|
158
|
+
.attr('height', pill.height)
|
|
159
|
+
.attr('rx', pill.height / 2)
|
|
160
|
+
.attr('fill', 'none')
|
|
161
|
+
.attr('stroke', pillBorder)
|
|
162
|
+
.attr('stroke-width', 0.75);
|
|
163
|
+
|
|
164
|
+
// Pill text
|
|
165
|
+
g.append('text')
|
|
166
|
+
.attr('x', pill.x + pill.width / 2)
|
|
167
|
+
.attr('y', LEGEND_HEIGHT / 2)
|
|
168
|
+
.attr('text-anchor', 'middle')
|
|
169
|
+
.attr('dominant-baseline', 'central')
|
|
170
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
171
|
+
.attr('font-weight', 500)
|
|
172
|
+
.attr('fill', palette.text)
|
|
173
|
+
.attr('pointer-events', 'none')
|
|
174
|
+
.attr('font-family', FONT_FAMILY)
|
|
175
|
+
.text(capsule.groupName);
|
|
176
|
+
|
|
177
|
+
// Entry dots + labels
|
|
178
|
+
for (const entry of capsule.entries) {
|
|
179
|
+
const entryG = g
|
|
180
|
+
.append('g')
|
|
181
|
+
.attr('data-legend-entry', entry.value.toLowerCase())
|
|
182
|
+
.attr('data-series-name', entry.value)
|
|
183
|
+
.style('cursor', 'pointer');
|
|
184
|
+
|
|
185
|
+
entryG
|
|
186
|
+
.append('circle')
|
|
187
|
+
.attr('cx', entry.dotCx)
|
|
188
|
+
.attr('cy', entry.dotCy)
|
|
189
|
+
.attr('r', LEGEND_DOT_R)
|
|
190
|
+
.attr('fill', entry.color);
|
|
191
|
+
|
|
192
|
+
entryG
|
|
193
|
+
.append('text')
|
|
194
|
+
.attr('x', entry.textX)
|
|
195
|
+
.attr('y', entry.textY)
|
|
196
|
+
.attr('dominant-baseline', 'central')
|
|
197
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
198
|
+
.attr('fill', palette.textMuted)
|
|
199
|
+
.attr('font-family', FONT_FAMILY)
|
|
200
|
+
.text(entry.value);
|
|
201
|
+
|
|
202
|
+
// Entry hover callback for chart-level highlighting
|
|
203
|
+
if (callbacks?.onEntryHover) {
|
|
204
|
+
const groupName = capsule.groupName;
|
|
205
|
+
const entryValue = entry.value;
|
|
206
|
+
const onHover = callbacks.onEntryHover;
|
|
207
|
+
entryG
|
|
208
|
+
.on('mouseenter', () => onHover(groupName, entryValue))
|
|
209
|
+
.on('mouseleave', () => onHover(groupName, null));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// "+N more" indicator
|
|
214
|
+
if (capsule.moreCount) {
|
|
215
|
+
const lastEntry = capsule.entries[capsule.entries.length - 1];
|
|
216
|
+
const moreX = lastEntry
|
|
217
|
+
? lastEntry.textX +
|
|
218
|
+
measureLegendText(lastEntry.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
219
|
+
LEGEND_ENTRY_DOT_GAP * 2
|
|
220
|
+
: pill.x + pill.width + 8;
|
|
221
|
+
const moreY = lastEntry?.textY ?? LEGEND_HEIGHT / 2;
|
|
222
|
+
|
|
223
|
+
g.append('text')
|
|
224
|
+
.attr('x', moreX)
|
|
225
|
+
.attr('y', moreY)
|
|
226
|
+
.attr('dominant-baseline', 'central')
|
|
227
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
228
|
+
.attr('font-style', 'italic')
|
|
229
|
+
.attr('fill', palette.textMuted)
|
|
230
|
+
.attr('font-family', FONT_FAMILY)
|
|
231
|
+
.text(`+${capsule.moreCount} more`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Click handler on pill
|
|
235
|
+
if (callbacks?.onGroupToggle) {
|
|
236
|
+
const cb = callbacks.onGroupToggle;
|
|
237
|
+
const name = capsule.groupName;
|
|
238
|
+
g.on('click', () => cb(name));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Post-render callback for chart-specific addons
|
|
242
|
+
if (callbacks?.onGroupRendered) {
|
|
243
|
+
callbacks.onGroupRendered(capsule.groupName, g, true);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ββ Collapsed pill ββββββββββββββββββββββββββββββββββββββββββ
|
|
248
|
+
|
|
249
|
+
function renderPill(
|
|
250
|
+
parent: D3Sel,
|
|
251
|
+
pill: LegendPillLayout,
|
|
252
|
+
palette: LegendPalette,
|
|
253
|
+
groupBg: string,
|
|
254
|
+
callbacks?: LegendCallbacks
|
|
255
|
+
): void {
|
|
256
|
+
const g = parent
|
|
257
|
+
.append('g')
|
|
258
|
+
.attr('transform', `translate(${pill.x},${pill.y})`)
|
|
259
|
+
.attr('data-legend-group', pill.groupName.toLowerCase())
|
|
260
|
+
.style('cursor', 'pointer');
|
|
261
|
+
|
|
262
|
+
g.append('rect')
|
|
263
|
+
.attr('width', pill.width)
|
|
264
|
+
.attr('height', pill.height)
|
|
265
|
+
.attr('rx', pill.height / 2)
|
|
266
|
+
.attr('fill', groupBg);
|
|
267
|
+
|
|
268
|
+
g.append('text')
|
|
269
|
+
.attr('x', pill.width / 2)
|
|
270
|
+
.attr('y', pill.height / 2)
|
|
271
|
+
.attr('text-anchor', 'middle')
|
|
272
|
+
.attr('dominant-baseline', 'central')
|
|
273
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
274
|
+
.attr('font-weight', 500)
|
|
275
|
+
.attr('fill', palette.textMuted)
|
|
276
|
+
.attr('pointer-events', 'none')
|
|
277
|
+
.attr('font-family', FONT_FAMILY)
|
|
278
|
+
.text(pill.groupName);
|
|
279
|
+
|
|
280
|
+
if (callbacks?.onGroupToggle) {
|
|
281
|
+
const cb = callbacks.onGroupToggle;
|
|
282
|
+
const name = pill.groupName;
|
|
283
|
+
g.on('click', () => cb(name));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (callbacks?.onGroupRendered) {
|
|
287
|
+
callbacks.onGroupRendered(pill.groupName, g, false);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ββ Control (right-aligned) βββββββββββββββββββββββββββββββββ
|
|
292
|
+
|
|
293
|
+
function renderControl(
|
|
294
|
+
parent: D3Sel,
|
|
295
|
+
ctrl: LegendControlLayout,
|
|
296
|
+
palette: LegendPalette,
|
|
297
|
+
_groupBg: string,
|
|
298
|
+
pillBorder: string,
|
|
299
|
+
_isDark: boolean,
|
|
300
|
+
configControls?: LegendConfig['controls']
|
|
301
|
+
): void {
|
|
302
|
+
const g = parent
|
|
303
|
+
.append('g')
|
|
304
|
+
.attr('transform', `translate(${ctrl.x},${ctrl.y})`)
|
|
305
|
+
.attr('data-legend-control', ctrl.id)
|
|
306
|
+
.style('cursor', 'pointer');
|
|
307
|
+
|
|
308
|
+
if (ctrl.exportBehavior === 'strip') {
|
|
309
|
+
g.attr('data-export-ignore', 'true');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Outline pill style for controls
|
|
313
|
+
g.append('rect')
|
|
314
|
+
.attr('width', ctrl.width)
|
|
315
|
+
.attr('height', ctrl.height)
|
|
316
|
+
.attr('rx', ctrl.height / 2)
|
|
317
|
+
.attr('fill', 'none')
|
|
318
|
+
.attr('stroke', pillBorder)
|
|
319
|
+
.attr('stroke-width', 0.75);
|
|
320
|
+
|
|
321
|
+
// Icon + label
|
|
322
|
+
let textX = ctrl.width / 2;
|
|
323
|
+
if (ctrl.icon && ctrl.label) {
|
|
324
|
+
// Icon on left, label on right
|
|
325
|
+
const iconG = g
|
|
326
|
+
.append('g')
|
|
327
|
+
.attr('transform', `translate(8,${(ctrl.height - 14) / 2})`);
|
|
328
|
+
iconG.html(ctrl.icon);
|
|
329
|
+
textX =
|
|
330
|
+
8 +
|
|
331
|
+
14 +
|
|
332
|
+
LEGEND_ENTRY_DOT_GAP +
|
|
333
|
+
measureLegendText(ctrl.label, LEGEND_PILL_FONT_SIZE) / 2;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (ctrl.label) {
|
|
337
|
+
g.append('text')
|
|
338
|
+
.attr('x', textX)
|
|
339
|
+
.attr('y', ctrl.height / 2)
|
|
340
|
+
.attr('text-anchor', 'middle')
|
|
341
|
+
.attr('dominant-baseline', 'central')
|
|
342
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
343
|
+
.attr('font-weight', 500)
|
|
344
|
+
.attr('fill', palette.textMuted)
|
|
345
|
+
.attr('pointer-events', 'none')
|
|
346
|
+
.attr('font-family', FONT_FAMILY)
|
|
347
|
+
.text(ctrl.label);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Children (e.g., speed badges)
|
|
351
|
+
if (ctrl.children) {
|
|
352
|
+
let cx = ctrl.width + 4;
|
|
353
|
+
for (const child of ctrl.children) {
|
|
354
|
+
const childG = g
|
|
355
|
+
.append('g')
|
|
356
|
+
.attr('transform', `translate(${cx},0)`)
|
|
357
|
+
.style('cursor', 'pointer');
|
|
358
|
+
|
|
359
|
+
childG
|
|
360
|
+
.append('rect')
|
|
361
|
+
.attr('width', child.width)
|
|
362
|
+
.attr('height', ctrl.height)
|
|
363
|
+
.attr('rx', ctrl.height / 2)
|
|
364
|
+
.attr(
|
|
365
|
+
'fill',
|
|
366
|
+
child.isActive ? (palette.primary ?? palette.text) : 'none'
|
|
367
|
+
)
|
|
368
|
+
.attr('stroke', pillBorder)
|
|
369
|
+
.attr('stroke-width', 0.75);
|
|
370
|
+
|
|
371
|
+
childG
|
|
372
|
+
.append('text')
|
|
373
|
+
.attr('x', child.width / 2)
|
|
374
|
+
.attr('y', ctrl.height / 2)
|
|
375
|
+
.attr('text-anchor', 'middle')
|
|
376
|
+
.attr('dominant-baseline', 'central')
|
|
377
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
378
|
+
.attr('fill', child.isActive ? palette.bg : palette.textMuted)
|
|
379
|
+
.attr('font-family', FONT_FAMILY)
|
|
380
|
+
.text(child.label);
|
|
381
|
+
|
|
382
|
+
// Click handler for child
|
|
383
|
+
const configCtrl = configControls?.find((c) => c.id === ctrl.id);
|
|
384
|
+
const configChild = configCtrl?.children?.find((c) => c.id === child.id);
|
|
385
|
+
if (configChild?.onClick) {
|
|
386
|
+
const onClick = configChild.onClick;
|
|
387
|
+
childG.on('click', () => onClick());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
cx += child.width + 4;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Click handler
|
|
395
|
+
const configCtrl = configControls?.find((c) => c.id === ctrl.id);
|
|
396
|
+
if (configCtrl?.onClick) {
|
|
397
|
+
const onClick = configCtrl.onClick;
|
|
398
|
+
g.on('click', () => onClick());
|
|
399
|
+
}
|
|
400
|
+
}
|