@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/d3.ts CHANGED
@@ -5,6 +5,7 @@ import * as d3Array from 'd3-array';
5
5
  import cloud from 'd3-cloud';
6
6
  import { FONT_FAMILY } from './fonts';
7
7
  import { injectBranding } from './branding';
8
+ import { computeQuadrantPointLabels, type LabelRect } from './label-layout';
8
9
 
9
10
  // ============================================================
10
11
  // Types
@@ -19,22 +20,22 @@ export type VisualizationType =
19
20
  | 'quadrant'
20
21
  | 'sequence';
21
22
 
22
- export interface D3DataItem {
23
+ interface D3DataItem {
23
24
  label: string;
24
25
  values: number[];
25
26
  color: string | null;
26
27
  lineNumber: number;
27
28
  }
28
29
 
29
- export interface WordCloudWord {
30
+ interface WordCloudWord {
30
31
  text: string;
31
32
  weight: number;
32
33
  lineNumber: number;
33
34
  }
34
35
 
35
- export type WordCloudRotate = 'none' | 'mixed' | 'angled';
36
+ type WordCloudRotate = 'none' | 'mixed' | 'angled';
36
37
 
37
- export interface WordCloudOptions {
38
+ interface WordCloudOptions {
38
39
  rotate: WordCloudRotate;
39
40
  max: number;
40
41
  minSize: number;
@@ -56,7 +57,7 @@ export interface ArcLink {
56
57
  lineNumber: number;
57
58
  }
58
59
 
59
- export type ArcOrder = 'appearance' | 'name' | 'group' | 'degree';
60
+ type ArcOrder = 'appearance' | 'name' | 'group' | 'degree';
60
61
 
61
62
  export interface ArcNodeGroup {
62
63
  name: string;
@@ -65,9 +66,9 @@ export interface ArcNodeGroup {
65
66
  lineNumber: number;
66
67
  }
67
68
 
68
- export type TimelineSort = 'time' | 'group' | 'tag';
69
+ type TimelineSort = 'time' | 'group' | 'tag';
69
70
 
70
- export interface TimelineEvent {
71
+ interface TimelineEvent {
71
72
  date: string;
72
73
  endDate: string | null;
73
74
  label: string;
@@ -77,13 +78,13 @@ export interface TimelineEvent {
77
78
  uncertain?: boolean;
78
79
  }
79
80
 
80
- export interface TimelineGroup {
81
+ interface TimelineGroup {
81
82
  name: string;
82
83
  color: string | null;
83
84
  lineNumber: number;
84
85
  }
85
86
 
86
- export interface TimelineEra {
87
+ interface TimelineEra {
87
88
  startDate: string;
88
89
  endDate: string;
89
90
  label: string;
@@ -91,40 +92,40 @@ export interface TimelineEra {
91
92
  lineNumber: number;
92
93
  }
93
94
 
94
- export interface TimelineMarker {
95
+ interface TimelineMarker {
95
96
  date: string;
96
97
  label: string;
97
98
  color: string | null;
98
99
  lineNumber: number;
99
100
  }
100
101
 
101
- export interface VennSet {
102
+ interface VennSet {
102
103
  name: string;
103
104
  alias: string | null;
104
105
  color: string | null;
105
106
  lineNumber: number;
106
107
  }
107
108
 
108
- export interface VennOverlap {
109
+ interface VennOverlap {
109
110
  sets: string[];
110
111
  label: string | null;
111
112
  lineNumber: number;
112
113
  }
113
114
 
114
- export interface QuadrantLabel {
115
+ interface QuadrantLabel {
115
116
  text: string;
116
117
  color: string | null;
117
118
  lineNumber: number;
118
119
  }
119
120
 
120
- export interface QuadrantPoint {
121
+ interface QuadrantPoint {
121
122
  label: string;
122
123
  x: number;
123
124
  y: number;
124
125
  lineNumber: number;
125
126
  }
126
127
 
127
- export interface QuadrantLabels {
128
+ interface QuadrantLabels {
128
129
  topRight: QuadrantLabel | null;
129
130
  topLeft: QuadrantLabel | null;
130
131
  bottomLeft: QuadrantLabel | null;
@@ -205,9 +206,14 @@ import {
205
206
  LEGEND_ENTRY_FONT_SIZE as TL_LEGEND_ENTRY_FONT_SIZE,
206
207
  LEGEND_ENTRY_DOT_GAP as TL_LEGEND_ENTRY_DOT_GAP,
207
208
  LEGEND_ENTRY_TRAIL as TL_LEGEND_ENTRY_TRAIL,
208
- LEGEND_GROUP_GAP as TL_LEGEND_GROUP_GAP,
209
209
  measureLegendText,
210
210
  } from './utils/legend-constants';
211
+ import { renderLegendD3 } from './utils/legend-d3';
212
+ import type {
213
+ LegendConfig,
214
+ LegendState,
215
+ LegendCallbacks,
216
+ } from './utils/legend-types';
211
217
  import {
212
218
  TITLE_FONT_SIZE,
213
219
  TITLE_FONT_WEIGHT,
@@ -4520,8 +4526,7 @@ export function renderTimeline(
4520
4526
  .attr('dy', '0.35em')
4521
4527
  .attr('text-anchor', 'start')
4522
4528
  .attr('fill', textColor)
4523
- .attr('font-size', '14px')
4524
- .attr('font-weight', '700')
4529
+ .attr('font-size', '13px')
4525
4530
  .text(ev.label);
4526
4531
  } else {
4527
4532
  // Text outside bar - check if it fits on left or must go right
@@ -4810,8 +4815,7 @@ export function renderTimeline(
4810
4815
  .attr('dy', '0.35em')
4811
4816
  .attr('text-anchor', 'start')
4812
4817
  .attr('fill', textColor)
4813
- .attr('font-size', '14px')
4814
- .attr('font-weight', '700')
4818
+ .attr('font-size', '13px')
4815
4819
  .text(ev.label);
4816
4820
  } else {
4817
4821
  // Text outside bar - check if it fits on left or must go right
@@ -4866,7 +4870,7 @@ export function renderTimeline(
4866
4870
  const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
4867
4871
  const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
4868
4872
  const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
4869
- const LG_GROUP_GAP = TL_LEGEND_GROUP_GAP;
4873
+ // LG_GROUP_GAP no longer needed — centralized legend handles spacing
4870
4874
  const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space) — local
4871
4875
 
4872
4876
  const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
@@ -4875,10 +4879,6 @@ export function renderTimeline(
4875
4879
  // Position legend at top, below title
4876
4880
  const legendY = title ? 50 : 10;
4877
4881
 
4878
- const groupBg = isDark
4879
- ? mix(palette.surface, palette.bg, 50)
4880
- : mix(palette.surface, palette.bg, 30);
4881
-
4882
4882
  // Pre-compute group widths (minified and expanded)
4883
4883
  type LegendGroup = {
4884
4884
  group: TagGroup;
@@ -4980,20 +4980,6 @@ export function renderTimeline(
4980
4980
 
4981
4981
  if (visibleGroups.length === 0) return;
4982
4982
 
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
4983
  // Legend container for data-legend-active attribute
4998
4984
  const legendContainer = mainSvg
4999
4985
  .append('g')
@@ -5005,177 +4991,113 @@ export function renderTimeline(
5005
4991
  );
5006
4992
  }
5007
4993
 
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
- }
4994
+ // Render tag groups via centralized legend system
4995
+ const iconAddon = viewMode ? 0 : LG_ICON_W;
4996
+ const centralGroups = visibleGroups.map((lg) => ({
4997
+ name: lg.group.name,
4998
+ entries: lg.group.entries.map((e) => ({
4999
+ value: e.value,
5000
+ color: e.color,
5001
+ })),
5002
+ }));
5003
+
5004
+ // Determine effective active group for centralized renderer
5005
+ const centralActive = viewMode ? effectiveColorKey : currentActiveGroup;
5006
+
5007
+ const centralConfig: LegendConfig = {
5008
+ groups: centralGroups,
5009
+ position: { placement: 'top-center', titleRelation: 'below-title' },
5010
+ mode: 'fixed',
5011
+ capsulePillAddonWidth: iconAddon,
5012
+ };
5013
+ const centralState: LegendState = { activeGroup: centralActive };
5014
+
5015
+ const centralCallbacks: LegendCallbacks = viewMode
5016
+ ? {}
5017
+ : {
5018
+ onGroupToggle: (groupName) => {
5019
+ currentActiveGroup =
5020
+ currentActiveGroup === groupName.toLowerCase()
5021
+ ? null
5022
+ : groupName.toLowerCase();
5023
+ drawLegend();
5024
+ recolorEvents();
5025
+ onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
5026
+ },
5027
+ onEntryHover: (groupName, entryValue) => {
5028
+ const tagKey = groupName.toLowerCase();
5029
+ if (entryValue) {
5030
+ const tagVal = entryValue.toLowerCase();
5031
+ fadeToTagValue(mainG, tagKey, tagVal);
5032
+ mainSvg
5033
+ .selectAll<SVGGElement, unknown>('[data-legend-entry]')
5034
+ .each(function () {
5035
+ const el = d3Selection.select(this);
5036
+ const ev = el.attr('data-legend-entry');
5037
+ const eg =
5038
+ el.attr('data-tag-group') ??
5039
+ (el.node() as Element)
5040
+ ?.closest?.('[data-tag-group]')
5041
+ ?.getAttribute('data-tag-group');
5042
+ el.attr(
5043
+ 'opacity',
5044
+ eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY
5045
+ );
5046
+ });
5047
+ } else {
5048
+ fadeReset(mainG);
5049
+ mainSvg
5050
+ .selectAll<SVGGElement, unknown>('[data-legend-entry]')
5051
+ .attr('opacity', 1);
5052
+ }
5053
+ },
5054
+ onGroupRendered: (groupName, groupEl, isActive) => {
5055
+ const groupKey = groupName.toLowerCase();
5056
+ groupEl.attr('data-tag-group', groupKey);
5057
+ if (isActive && !viewMode) {
5058
+ const isSwimActive =
5059
+ currentSwimlaneGroup != null &&
5060
+ currentSwimlaneGroup.toLowerCase() === groupKey;
5061
+ const pillWidth =
5062
+ measureLegendText(groupName, LG_PILL_FONT_SIZE) +
5063
+ LG_PILL_PAD;
5064
+ const pillXOff = LG_CAPSULE_PAD;
5065
+ const iconX = pillXOff + pillWidth + 5;
5066
+ const iconY = (LG_HEIGHT - 10) / 2;
5067
+ const iconEl = drawSwimlaneIcon(
5068
+ groupEl,
5069
+ iconX,
5070
+ iconY,
5071
+ isSwimActive
5072
+ );
5073
+ iconEl
5074
+ .attr('data-swimlane-toggle', groupKey)
5075
+ .on('click', (event: MouseEvent) => {
5076
+ event.stopPropagation();
5077
+ currentSwimlaneGroup =
5078
+ currentSwimlaneGroup === groupKey ? null : groupKey;
5079
+ onTagStateChange?.(
5080
+ currentActiveGroup,
5081
+ currentSwimlaneGroup
5082
+ );
5083
+ relayout();
5084
+ });
5085
+ }
5086
+ },
5087
+ };
5088
+
5089
+ const legendInnerG = legendContainer
5090
+ .append('g')
5091
+ .attr('transform', `translate(0, ${legendY})`);
5092
+ renderLegendD3(
5093
+ legendInnerG,
5094
+ centralConfig,
5095
+ centralState,
5096
+ palette,
5097
+ isDark,
5098
+ centralCallbacks,
5099
+ width
5100
+ );
5179
5101
  }
5180
5102
 
5181
5103
  // Build a quick lineNumber→event lookup
@@ -5538,7 +5460,7 @@ export function renderVenn(
5538
5460
  exportDims?: D3ExportDimensions
5539
5461
  ): void {
5540
5462
  const { vennSets, vennOverlaps, title } = parsed;
5541
- if (vennSets.length < 2) return;
5463
+ if (vennSets.length < 2 || vennSets.length > 3) return;
5542
5464
 
5543
5465
  const init = initD3Chart(container, palette, exportDims);
5544
5466
  if (!init) return;
@@ -5616,7 +5538,9 @@ export function renderVenn(
5616
5538
  // Suppress WebKit focus ring on interactive SVG elements
5617
5539
  svg
5618
5540
  .append('style')
5619
- .text('circle:focus, circle:focus-visible { outline: none !important; }');
5541
+ .text(
5542
+ 'circle:focus, circle:focus-visible { outline-solid: none !important; }'
5543
+ );
5620
5544
 
5621
5545
  // Title
5622
5546
  renderChartTitle(
@@ -5947,7 +5871,7 @@ export function renderVenn(
5947
5871
  .attr('class', 'venn-hit-target')
5948
5872
  .attr('data-line-number', String(vennSets[i].lineNumber))
5949
5873
  .style('cursor', onClickItem ? 'pointer' : 'default')
5950
- .style('outline', 'none')
5874
+ .style('outline-solid', 'none')
5951
5875
  .on('mouseenter', () => {
5952
5876
  showRegionOverlay([i]);
5953
5877
  })
@@ -6003,7 +5927,7 @@ export function renderVenn(
6003
5927
  .attr('class', 'venn-hit-target')
6004
5928
  .attr('data-line-number', declaredOv ? String(declaredOv.lineNumber) : '')
6005
5929
  .style('cursor', onClickItem && declaredOv ? 'pointer' : 'default')
6006
- .style('outline', 'none')
5930
+ .style('outline-solid', 'none')
6007
5931
  .on('mouseenter', () => {
6008
5932
  showRegionOverlay(idxs);
6009
5933
  })
@@ -6493,40 +6417,88 @@ export function renderQuadrant(
6493
6417
  return 'bottom-right';
6494
6418
  };
6495
6419
 
6420
+ // Build obstacle rects from quadrant watermark labels for collision avoidance
6421
+ const POINT_RADIUS = 6;
6422
+ const POINT_LABEL_FONT_SIZE = 12;
6423
+ const quadrantLabelObstacles: LabelRect[] = quadrantDefsWithLabel.map((d) => {
6424
+ const layout = labelLayouts.get(d.label!.text)!;
6425
+ const totalW =
6426
+ Math.max(...layout.lines.map((l) => l.length)) *
6427
+ layout.fontSize *
6428
+ CHAR_WIDTH_RATIO;
6429
+ const totalH = layout.lines.length * layout.fontSize * 1.2;
6430
+ return {
6431
+ x: d.labelX - totalW / 2,
6432
+ y: d.labelY - totalH / 2,
6433
+ w: totalW,
6434
+ h: totalH,
6435
+ };
6436
+ });
6437
+
6438
+ // Compute collision-free label positions for all points
6439
+ const pointPixels = quadrantPoints.map((point) => ({
6440
+ label: point.label,
6441
+ cx: xScale(point.x),
6442
+ cy: yScale(point.y),
6443
+ }));
6444
+
6445
+ const placedPointLabels = computeQuadrantPointLabels(
6446
+ pointPixels,
6447
+ { left: 0, top: 0, right: chartWidth, bottom: chartHeight },
6448
+ quadrantLabelObstacles,
6449
+ POINT_RADIUS,
6450
+ POINT_LABEL_FONT_SIZE
6451
+ );
6452
+
6496
6453
  // Draw data points (circles and labels)
6497
6454
  const pointsG = chartG.append('g').attr('class', 'points');
6498
6455
 
6499
- quadrantPoints.forEach((point) => {
6456
+ quadrantPoints.forEach((point, i) => {
6500
6457
  const cx = xScale(point.x);
6501
6458
  const cy = yScale(point.y);
6502
6459
  const quadrant = getPointQuadrant(point.x, point.y);
6503
6460
  const quadDef = quadrantDefs.find((d) => d.position === quadrant);
6504
6461
  const pointColor =
6505
6462
  quadDef?.label?.color ?? defaultColors[quadDef?.colorIdx ?? 0];
6463
+ const placed = placedPointLabels[i];
6506
6464
 
6507
6465
  const pointG = pointsG
6508
6466
  .append('g')
6509
6467
  .attr('class', 'point-group')
6510
6468
  .attr('data-line-number', String(point.lineNumber));
6511
6469
 
6470
+ // Connector line (drawn first so it renders behind circle and label)
6471
+ if (placed.connectorLine) {
6472
+ pointG
6473
+ .append('line')
6474
+ .attr('x1', placed.connectorLine.x1)
6475
+ .attr('y1', placed.connectorLine.y1)
6476
+ .attr('x2', placed.connectorLine.x2)
6477
+ .attr('y2', placed.connectorLine.y2)
6478
+ .attr('stroke', pointColor)
6479
+ .attr('stroke-width', 1)
6480
+ .attr('opacity', 0.5);
6481
+ }
6482
+
6512
6483
  // Circle with white fill and colored border for visibility on opaque quadrants
6513
6484
  pointG
6514
6485
  .append('circle')
6515
6486
  .attr('cx', cx)
6516
6487
  .attr('cy', cy)
6517
- .attr('r', 6)
6488
+ .attr('r', POINT_RADIUS)
6518
6489
  .attr('fill', '#ffffff')
6519
6490
  .attr('stroke', pointColor)
6520
6491
  .attr('stroke-width', 2);
6521
6492
 
6522
- // Label (palette text color adapts to light/dark mode)
6493
+ // Label at computed position
6523
6494
  pointG
6524
6495
  .append('text')
6525
- .attr('x', cx)
6526
- .attr('y', cy - 10)
6527
- .attr('text-anchor', 'middle')
6496
+ .attr('x', placed.x)
6497
+ .attr('y', placed.y)
6498
+ .attr('text-anchor', placed.anchor)
6499
+ .attr('dominant-baseline', 'central')
6528
6500
  .attr('fill', textColor)
6529
- .attr('font-size', '12px')
6501
+ .attr('font-size', `${POINT_LABEL_FONT_SIZE}px`)
6530
6502
  .attr('font-weight', '700')
6531
6503
  .style('text-shadow', `0 1px 2px ${shadowColor}`)
6532
6504
  .text(point.label);
@@ -6545,7 +6517,7 @@ export function renderQuadrant(
6545
6517
  })
6546
6518
  .on('mouseleave', () => {
6547
6519
  hideTooltip(tooltip);
6548
- pointG.select('circle').attr('r', 6);
6520
+ pointG.select('circle').attr('r', POINT_RADIUS);
6549
6521
  })
6550
6522
  .on('click', () => {
6551
6523
  if (onClickItem && point.lineNumber) onClickItem(point.lineNumber);