@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/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
|
-
|
|
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
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
currentActiveGroup
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
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
|
package/src/er/renderer.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
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('
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
}
|