@diagrammo/dgmo 0.8.10 → 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.
- package/dist/cli.cjs +245 -672
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.d.cts +2 -3
- package/dist/editor.d.ts +2 -3
- package/dist/editor.js.map +1 -1
- package/dist/index.cjs +306 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +304 -77
- package/dist/index.js.map +1 -1
- package/package.json +14 -17
- package/src/boxes-and-lines/renderer.ts +1 -1
- package/src/c4/layout.ts +31 -10
- package/src/d3.ts +80 -31
- package/src/echarts.ts +56 -57
- package/src/editor/index.ts +1 -2
- package/src/gantt/resolver.ts +19 -14
- package/src/index.ts +2 -0
- package/src/kanban/renderer.ts +10 -7
- package/src/label-layout.ts +286 -0
- package/src/sequence/parser.ts +4 -0
- package/src/sequence/renderer.ts +16 -3
- package/src/utils/arrows.ts +1 -1
- package/src/utils/legend-layout.ts +1 -5
- package/src/utils/legend-svg.ts +2 -2
- package/src/utils/legend-types.ts +0 -3
- package/src/utils/parsing.ts +1 -1
- package/src/utils/tag-groups.ts +1 -1
package/src/echarts.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { FONT_FAMILY } from './fonts';
|
|
|
4
4
|
import { injectBranding } from './branding';
|
|
5
5
|
import { renderLegendSvg } from './utils/legend-svg';
|
|
6
6
|
import type { LegendGroupData } from './utils/legend-svg';
|
|
7
|
+
import {
|
|
8
|
+
type LabelRect,
|
|
9
|
+
type PointCircle,
|
|
10
|
+
rectsOverlap,
|
|
11
|
+
rectCircleOverlap,
|
|
12
|
+
} from './label-layout';
|
|
7
13
|
|
|
8
14
|
// ============================================================
|
|
9
15
|
// Types
|
|
@@ -17,14 +23,14 @@ export type ExtendedChartType =
|
|
|
17
23
|
| 'heatmap'
|
|
18
24
|
| 'funnel';
|
|
19
25
|
|
|
20
|
-
|
|
26
|
+
interface ExtendedChartDataPoint {
|
|
21
27
|
label: string;
|
|
22
28
|
value: number;
|
|
23
29
|
color?: string;
|
|
24
30
|
lineNumber: number;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
interface ParsedSankeyLink {
|
|
28
34
|
source: string;
|
|
29
35
|
target: string;
|
|
30
36
|
value: number;
|
|
@@ -33,14 +39,14 @@ export interface ParsedSankeyLink {
|
|
|
33
39
|
lineNumber: number;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
interface ParsedFunction {
|
|
37
43
|
name: string;
|
|
38
44
|
expression: string;
|
|
39
45
|
color?: string;
|
|
40
46
|
lineNumber: number;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
|
|
49
|
+
interface ParsedScatterPoint {
|
|
44
50
|
name: string;
|
|
45
51
|
x: number;
|
|
46
52
|
y: number;
|
|
@@ -50,7 +56,7 @@ export interface ParsedScatterPoint {
|
|
|
50
56
|
lineNumber: number;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
interface ParsedHeatmapRow {
|
|
54
60
|
label: string;
|
|
55
61
|
values: number[];
|
|
56
62
|
lineNumber: number;
|
|
@@ -733,7 +739,8 @@ export function buildExtendedChartOption(
|
|
|
733
739
|
|
|
734
740
|
// Sankey chart has different structure
|
|
735
741
|
if (parsed.type === 'sankey') {
|
|
736
|
-
|
|
742
|
+
const bg = isDark ? palette.surface : palette.bg;
|
|
743
|
+
return buildSankeyOption(parsed, textColor, colors, bg, titleConfig);
|
|
737
744
|
}
|
|
738
745
|
|
|
739
746
|
// Chord diagram
|
|
@@ -794,6 +801,7 @@ function buildSankeyOption(
|
|
|
794
801
|
parsed: ParsedExtendedChart,
|
|
795
802
|
textColor: string,
|
|
796
803
|
colors: string[],
|
|
804
|
+
bg: string,
|
|
797
805
|
titleConfig: EChartsOption['title']
|
|
798
806
|
): EChartsOption {
|
|
799
807
|
// Extract unique nodes from links
|
|
@@ -805,12 +813,18 @@ function buildSankeyOption(
|
|
|
805
813
|
}
|
|
806
814
|
}
|
|
807
815
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
816
|
+
// Tint colors with background so the diagram feels less saturated.
|
|
817
|
+
// Nodes get a lighter tint so they stand out; links get more tinting.
|
|
818
|
+
const tintNode = (c: string) => mix(c, bg, 75);
|
|
819
|
+
const tintLink = (c: string) => mix(c, bg, 45);
|
|
820
|
+
|
|
821
|
+
const nodeColorMap = new Map<string, string>();
|
|
822
|
+
const nodes = Array.from(nodeSet).map((name, index) => {
|
|
823
|
+
const raw = parsed.nodeColors?.[name] ?? colors[index % colors.length];
|
|
824
|
+
const tinted = tintNode(raw);
|
|
825
|
+
nodeColorMap.set(name, tintLink(raw));
|
|
826
|
+
return { name, itemStyle: { color: tinted } };
|
|
827
|
+
});
|
|
814
828
|
|
|
815
829
|
return {
|
|
816
830
|
...CHART_BASE,
|
|
@@ -834,11 +848,15 @@ function buildSankeyOption(
|
|
|
834
848
|
source: link.source,
|
|
835
849
|
target: link.target,
|
|
836
850
|
value: link.value,
|
|
837
|
-
|
|
851
|
+
lineStyle: {
|
|
852
|
+
color: link.color
|
|
853
|
+
? tintLink(link.color)
|
|
854
|
+
: nodeColorMap.get(link.source),
|
|
855
|
+
},
|
|
838
856
|
})),
|
|
839
857
|
lineStyle: {
|
|
840
|
-
color: 'gradient',
|
|
841
858
|
curveness: 0.5,
|
|
859
|
+
opacity: 0.6,
|
|
842
860
|
},
|
|
843
861
|
label: {
|
|
844
862
|
color: textColor,
|
|
@@ -1182,37 +1200,6 @@ export function getExtendedChartLegendGroups(
|
|
|
1182
1200
|
// Scatter label collision avoidance — greedy placement algorithm
|
|
1183
1201
|
// ---------------------------------------------------------------------------
|
|
1184
1202
|
|
|
1185
|
-
interface LabelRect {
|
|
1186
|
-
x: number;
|
|
1187
|
-
y: number;
|
|
1188
|
-
w: number;
|
|
1189
|
-
h: number;
|
|
1190
|
-
}
|
|
1191
|
-
interface PointCircle {
|
|
1192
|
-
cx: number;
|
|
1193
|
-
cy: number;
|
|
1194
|
-
r: number;
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
/** Axis-aligned bounding box overlap test. @internal exported for testing */
|
|
1198
|
-
export function rectsOverlap(a: LabelRect, b: LabelRect): boolean {
|
|
1199
|
-
return (
|
|
1200
|
-
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
|
1201
|
-
);
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
/** Rect vs circle overlap using nearest-point-on-rect distance check. @internal exported for testing */
|
|
1205
|
-
export function rectCircleOverlap(
|
|
1206
|
-
rect: LabelRect,
|
|
1207
|
-
circle: PointCircle
|
|
1208
|
-
): boolean {
|
|
1209
|
-
const nearestX = Math.max(rect.x, Math.min(circle.cx, rect.x + rect.w));
|
|
1210
|
-
const nearestY = Math.max(rect.y, Math.min(circle.cy, rect.y + rect.h));
|
|
1211
|
-
const dx = nearestX - circle.cx;
|
|
1212
|
-
const dy = nearestY - circle.cy;
|
|
1213
|
-
return dx * dx + dy * dy < circle.r * circle.r;
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
1203
|
export interface ScatterLabelPoint {
|
|
1217
1204
|
name: string;
|
|
1218
1205
|
px: number;
|
|
@@ -1734,12 +1721,22 @@ function buildHeatmapOption(
|
|
|
1734
1721
|
});
|
|
1735
1722
|
});
|
|
1736
1723
|
|
|
1724
|
+
// Rotate column labels only when they'd overlap at the default font size.
|
|
1725
|
+
// Estimate: each char ~7px at 12px font; rotate if longest label exceeds
|
|
1726
|
+
// an even share of a ~900px-wide chart.
|
|
1727
|
+
const CHAR_WIDTH = 7;
|
|
1728
|
+
const ESTIMATED_CHART_WIDTH = 900;
|
|
1729
|
+
const longestCol = Math.max(...columns.map((c) => c.length), 0);
|
|
1730
|
+
const slotWidth =
|
|
1731
|
+
columns.length > 0 ? ESTIMATED_CHART_WIDTH / columns.length : Infinity;
|
|
1732
|
+
const needsRotation = longestCol * CHAR_WIDTH > slotWidth * 0.85;
|
|
1733
|
+
|
|
1737
1734
|
return {
|
|
1738
1735
|
...CHART_BASE,
|
|
1739
1736
|
title: titleConfig,
|
|
1740
1737
|
grid: {
|
|
1741
1738
|
left: '3%',
|
|
1742
|
-
right: '
|
|
1739
|
+
right: '3%',
|
|
1743
1740
|
bottom: '3%',
|
|
1744
1741
|
top: parsed.title ? '15%' : '5%',
|
|
1745
1742
|
containLabel: true,
|
|
@@ -1747,6 +1744,7 @@ function buildHeatmapOption(
|
|
|
1747
1744
|
xAxis: {
|
|
1748
1745
|
type: 'category',
|
|
1749
1746
|
data: columns,
|
|
1747
|
+
position: 'top',
|
|
1750
1748
|
splitArea: {
|
|
1751
1749
|
show: true,
|
|
1752
1750
|
},
|
|
@@ -1755,12 +1753,19 @@ function buildHeatmapOption(
|
|
|
1755
1753
|
},
|
|
1756
1754
|
axisLabel: {
|
|
1757
1755
|
color: textColor,
|
|
1758
|
-
fontSize:
|
|
1756
|
+
fontSize: 12,
|
|
1757
|
+
interval: 0,
|
|
1758
|
+
...(needsRotation && {
|
|
1759
|
+
rotate: -45,
|
|
1760
|
+
width: 200,
|
|
1761
|
+
overflow: 'none' as const,
|
|
1762
|
+
}),
|
|
1759
1763
|
},
|
|
1760
1764
|
},
|
|
1761
1765
|
yAxis: {
|
|
1762
1766
|
type: 'category',
|
|
1763
1767
|
data: rowLabels,
|
|
1768
|
+
inverse: true,
|
|
1764
1769
|
splitArea: {
|
|
1765
1770
|
show: true,
|
|
1766
1771
|
},
|
|
@@ -1769,16 +1774,14 @@ function buildHeatmapOption(
|
|
|
1769
1774
|
},
|
|
1770
1775
|
axisLabel: {
|
|
1771
1776
|
color: textColor,
|
|
1772
|
-
fontSize:
|
|
1777
|
+
fontSize: 12,
|
|
1778
|
+
interval: 0,
|
|
1773
1779
|
},
|
|
1774
1780
|
},
|
|
1775
1781
|
visualMap: {
|
|
1782
|
+
show: false,
|
|
1776
1783
|
min: minValue,
|
|
1777
1784
|
max: maxValue,
|
|
1778
|
-
calculable: true,
|
|
1779
|
-
orient: 'vertical',
|
|
1780
|
-
right: '2%',
|
|
1781
|
-
top: 'center',
|
|
1782
1785
|
inRange: {
|
|
1783
1786
|
color: [
|
|
1784
1787
|
mix(palette.primary, bg, 30),
|
|
@@ -1787,9 +1790,6 @@ function buildHeatmapOption(
|
|
|
1787
1790
|
mix(palette.colors.orange, bg, 30),
|
|
1788
1791
|
],
|
|
1789
1792
|
},
|
|
1790
|
-
textStyle: {
|
|
1791
|
-
color: textColor,
|
|
1792
|
-
},
|
|
1793
1793
|
},
|
|
1794
1794
|
series: [
|
|
1795
1795
|
{
|
|
@@ -1806,9 +1806,8 @@ function buildHeatmapOption(
|
|
|
1806
1806
|
fontWeight: 'bold' as const,
|
|
1807
1807
|
},
|
|
1808
1808
|
emphasis: {
|
|
1809
|
-
|
|
1809
|
+
disabled: true,
|
|
1810
1810
|
},
|
|
1811
|
-
blur: BLUR_DIM,
|
|
1812
1811
|
},
|
|
1813
1812
|
],
|
|
1814
1813
|
};
|
package/src/editor/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { LRLanguage, LanguageSupport } from '@codemirror/language';
|
|
2
|
-
import type { Extension } from '@codemirror/state';
|
|
3
2
|
import { parser } from './dgmo.grammar.js';
|
|
4
3
|
import { dgmoHighlighting } from './highlight';
|
|
5
4
|
|
|
@@ -25,4 +24,4 @@ export const dgmoLanguageSupport = new LanguageSupport(dgmoLanguage);
|
|
|
25
24
|
* Consumers should add indentationMarkers() separately if desired
|
|
26
25
|
* (from @replit/codemirror-indentation-markers).
|
|
27
26
|
*/
|
|
28
|
-
export const dgmoExtension
|
|
27
|
+
export const dgmoExtension = dgmoLanguageSupport;
|
package/src/gantt/resolver.ts
CHANGED
|
@@ -7,16 +7,16 @@
|
|
|
7
7
|
|
|
8
8
|
import type { GanttTask, GanttNode } from './types';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
interface ResolverMatch {
|
|
11
11
|
task: GanttTask;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface ResolverError {
|
|
15
15
|
kind: 'not_found' | 'ambiguous';
|
|
16
16
|
message: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
type ResolverResult = ResolverMatch | ResolverError;
|
|
20
20
|
|
|
21
21
|
export function isResolverError(r: ResolverResult): r is ResolverError {
|
|
22
22
|
return 'kind' in r;
|
|
@@ -53,23 +53,23 @@ export function collectTasks(nodes: GanttNode[]): GanttTask[] {
|
|
|
53
53
|
*/
|
|
54
54
|
export function resolveTaskName(
|
|
55
55
|
name: string,
|
|
56
|
-
allTasks: GanttTask[]
|
|
56
|
+
allTasks: GanttTask[]
|
|
57
57
|
): ResolverResult {
|
|
58
58
|
const trimmed = name.trim();
|
|
59
59
|
|
|
60
60
|
// 1. Try exact label match (no dots involved)
|
|
61
|
-
const exactMatches = allTasks.filter(t => t.label === trimmed);
|
|
61
|
+
const exactMatches = allTasks.filter((t) => t.label === trimmed);
|
|
62
62
|
if (exactMatches.length === 1) {
|
|
63
63
|
return { task: exactMatches[0] };
|
|
64
64
|
}
|
|
65
65
|
if (exactMatches.length > 1) {
|
|
66
66
|
// Multiple tasks with same name — need disambiguation
|
|
67
|
-
const suggestions = exactMatches.map(t =>
|
|
67
|
+
const suggestions = exactMatches.map((t) =>
|
|
68
68
|
t.groupPath.length > 0 ? `${t.groupPath.join('.')}.${t.label}` : t.label
|
|
69
69
|
);
|
|
70
70
|
return {
|
|
71
71
|
kind: 'ambiguous',
|
|
72
|
-
message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map(s => `\`${s}\``).join(' or ')}?`,
|
|
72
|
+
message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map((s) => `\`${s}\``).join(' or ')}?`,
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -80,7 +80,7 @@ export function resolveTaskName(
|
|
|
80
80
|
const taskLabel = trimmed.substring(lastDotIdx + 1);
|
|
81
81
|
|
|
82
82
|
// Find tasks whose label matches and whose group path ends with the prefix
|
|
83
|
-
const matches = allTasks.filter(t => {
|
|
83
|
+
const matches = allTasks.filter((t) => {
|
|
84
84
|
if (t.label !== taskLabel) return false;
|
|
85
85
|
return matchesGroupPath(t.groupPath, groupPrefix);
|
|
86
86
|
});
|
|
@@ -89,12 +89,12 @@ export function resolveTaskName(
|
|
|
89
89
|
return { task: matches[0] };
|
|
90
90
|
}
|
|
91
91
|
if (matches.length > 1) {
|
|
92
|
-
const suggestions = matches.map(t =>
|
|
92
|
+
const suggestions = matches.map((t) =>
|
|
93
93
|
t.groupPath.length > 0 ? `${t.groupPath.join('.')}.${t.label}` : t.label
|
|
94
94
|
);
|
|
95
95
|
return {
|
|
96
96
|
kind: 'ambiguous',
|
|
97
|
-
message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map(s => `\`${s}\``).join(' or ')}?`,
|
|
97
|
+
message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map((s) => `\`${s}\``).join(' or ')}?`,
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -106,8 +106,8 @@ export function resolveTaskName(
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
// 3. No match found — try case-insensitive as a fallback for suggestions
|
|
109
|
-
const caseInsensitive = allTasks.filter(
|
|
110
|
-
t.label.toLowerCase() === trimmed.toLowerCase()
|
|
109
|
+
const caseInsensitive = allTasks.filter(
|
|
110
|
+
(t) => t.label.toLowerCase() === trimmed.toLowerCase()
|
|
111
111
|
);
|
|
112
112
|
if (caseInsensitive.length > 0) {
|
|
113
113
|
return {
|
|
@@ -134,11 +134,16 @@ export function resolveTaskName(
|
|
|
134
134
|
function matchesGroupPath(groupPath: string[], prefix: string): boolean {
|
|
135
135
|
// Simple case: prefix is a single segment
|
|
136
136
|
if (!prefix.includes('.')) {
|
|
137
|
-
return groupPath.some(g => g === prefix);
|
|
137
|
+
return groupPath.some((g) => g === prefix);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
// Multi-segment prefix: try matching from the start of the group path
|
|
141
141
|
const pathStr = groupPath.join('.');
|
|
142
142
|
// Check if the full prefix matches any contiguous section of the path
|
|
143
|
-
return
|
|
143
|
+
return (
|
|
144
|
+
pathStr === prefix ||
|
|
145
|
+
pathStr.endsWith('.' + prefix) ||
|
|
146
|
+
pathStr.startsWith(prefix + '.') ||
|
|
147
|
+
pathStr.includes('.' + prefix + '.')
|
|
148
|
+
);
|
|
144
149
|
}
|
package/src/index.ts
CHANGED
package/src/kanban/renderer.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
} from './types';
|
|
16
16
|
import { parseKanban } from './parser';
|
|
17
17
|
import { isArchiveColumn } from './mutations';
|
|
18
|
-
import { LEGEND_HEIGHT } from '../utils/legend-constants';
|
|
18
|
+
import { LEGEND_HEIGHT, measureLegendText } from '../utils/legend-constants';
|
|
19
19
|
import { renderLegendD3 } from '../utils/legend-d3';
|
|
20
20
|
import type { LegendConfig, LegendState } from '../utils/legend-types';
|
|
21
21
|
|
|
@@ -200,8 +200,7 @@ function computeLayout(
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
const totalWidth = currentX - COLUMN_GAP + DIAGRAM_PADDING;
|
|
203
|
-
const
|
|
204
|
-
const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING + legendSpace;
|
|
203
|
+
const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING;
|
|
205
204
|
|
|
206
205
|
return { columns: columnLayouts, totalWidth, totalHeight };
|
|
207
206
|
}
|
|
@@ -249,9 +248,13 @@ export function renderKanban(
|
|
|
249
248
|
.text(parsed.title);
|
|
250
249
|
}
|
|
251
250
|
|
|
252
|
-
// Legend (
|
|
251
|
+
// Legend (top-right, inline with title)
|
|
253
252
|
if (parsed.tagGroups.length > 0) {
|
|
254
|
-
const
|
|
253
|
+
const titleTextWidth = parsed.title
|
|
254
|
+
? measureLegendText(parsed.title, TITLE_FONT_SIZE) + 16
|
|
255
|
+
: 0;
|
|
256
|
+
const legendX = DIAGRAM_PADDING + titleTextWidth;
|
|
257
|
+
const legendY = DIAGRAM_PADDING + (TITLE_FONT_SIZE - LEGEND_HEIGHT) / 2;
|
|
255
258
|
const legendConfig: LegendConfig = {
|
|
256
259
|
groups: parsed.tagGroups,
|
|
257
260
|
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
@@ -261,7 +264,7 @@ export function renderKanban(
|
|
|
261
264
|
const legendG = svg
|
|
262
265
|
.append('g')
|
|
263
266
|
.attr('class', 'kanban-legend')
|
|
264
|
-
.attr('transform', `translate(${
|
|
267
|
+
.attr('transform', `translate(${legendX},${legendY})`);
|
|
265
268
|
renderLegendD3(
|
|
266
269
|
legendG,
|
|
267
270
|
legendConfig,
|
|
@@ -269,7 +272,7 @@ export function renderKanban(
|
|
|
269
272
|
palette,
|
|
270
273
|
isDark,
|
|
271
274
|
undefined,
|
|
272
|
-
width -
|
|
275
|
+
width - legendX - DIAGRAM_PADDING
|
|
273
276
|
);
|
|
274
277
|
}
|
|
275
278
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared label collision detection and placement utilities
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface LabelRect {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
w: number;
|
|
9
|
+
h: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PointCircle {
|
|
13
|
+
cx: number;
|
|
14
|
+
cy: number;
|
|
15
|
+
r: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Axis-aligned bounding box overlap test. */
|
|
19
|
+
export function rectsOverlap(a: LabelRect, b: LabelRect): boolean {
|
|
20
|
+
return (
|
|
21
|
+
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Rect vs circle overlap using nearest-point-on-rect distance check. */
|
|
26
|
+
export function rectCircleOverlap(
|
|
27
|
+
rect: LabelRect,
|
|
28
|
+
circle: PointCircle
|
|
29
|
+
): boolean {
|
|
30
|
+
const nearestX = Math.max(rect.x, Math.min(circle.cx, rect.x + rect.w));
|
|
31
|
+
const nearestY = Math.max(rect.y, Math.min(circle.cy, rect.y + rect.h));
|
|
32
|
+
const dx = nearestX - circle.cx;
|
|
33
|
+
const dy = nearestY - circle.cy;
|
|
34
|
+
return dx * dx + dy * dy < circle.r * circle.r;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Quadrant chart point label placement
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const CHAR_WIDTH_RATIO = 0.6;
|
|
42
|
+
|
|
43
|
+
export interface QuadrantLabelPoint {
|
|
44
|
+
label: string;
|
|
45
|
+
cx: number; // pixel x
|
|
46
|
+
cy: number; // pixel y
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface PlacedQuadrantLabel {
|
|
50
|
+
label: string;
|
|
51
|
+
x: number; // text x
|
|
52
|
+
y: number; // text y (center of label)
|
|
53
|
+
anchor: 'middle' | 'start' | 'end';
|
|
54
|
+
connectorLine?: { x1: number; y1: number; x2: number; y2: number };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Greedy label placement for quadrant chart points.
|
|
59
|
+
* Avoids collisions with other point labels, point circles, and obstacle rects
|
|
60
|
+
* (quadrant watermark labels). Labels are constrained within chartBounds.
|
|
61
|
+
*
|
|
62
|
+
* Pure function — no DOM dependency.
|
|
63
|
+
*/
|
|
64
|
+
export function computeQuadrantPointLabels(
|
|
65
|
+
points: QuadrantLabelPoint[],
|
|
66
|
+
chartBounds: { left: number; top: number; right: number; bottom: number },
|
|
67
|
+
obstacles: LabelRect[],
|
|
68
|
+
pointRadius: number,
|
|
69
|
+
fontSize: number
|
|
70
|
+
): PlacedQuadrantLabel[] {
|
|
71
|
+
const labelHeight = fontSize + 4;
|
|
72
|
+
const stepSize = labelHeight + 2;
|
|
73
|
+
const minGap = pointRadius + 4;
|
|
74
|
+
|
|
75
|
+
// Build collision circles for all points
|
|
76
|
+
const pointCircles: PointCircle[] = points.map((p) => ({
|
|
77
|
+
cx: p.cx,
|
|
78
|
+
cy: p.cy,
|
|
79
|
+
r: pointRadius,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
const placedLabels: LabelRect[] = [];
|
|
83
|
+
const results: PlacedQuadrantLabel[] = [];
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < points.length; i++) {
|
|
86
|
+
const pt = points[i];
|
|
87
|
+
const labelWidth = pt.label.length * fontSize * CHAR_WIDTH_RATIO + 8;
|
|
88
|
+
|
|
89
|
+
// Try 4 directions: above, below, left, right
|
|
90
|
+
// Each direction generates candidate (labelX, labelY, anchor)
|
|
91
|
+
type Candidate = {
|
|
92
|
+
rect: LabelRect;
|
|
93
|
+
textX: number;
|
|
94
|
+
textY: number;
|
|
95
|
+
anchor: 'middle' | 'start' | 'end';
|
|
96
|
+
dist: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let best: Candidate | null = null;
|
|
100
|
+
|
|
101
|
+
// Direction generators: for a given offset, produce a candidate rect + text position
|
|
102
|
+
const directions: Array<{
|
|
103
|
+
gen: (offset: number) => {
|
|
104
|
+
rect: LabelRect;
|
|
105
|
+
textX: number;
|
|
106
|
+
textY: number;
|
|
107
|
+
anchor: 'middle' | 'start' | 'end';
|
|
108
|
+
} | null;
|
|
109
|
+
}> = [
|
|
110
|
+
{
|
|
111
|
+
// Above
|
|
112
|
+
gen: (offset) => {
|
|
113
|
+
const lx = pt.cx - labelWidth / 2;
|
|
114
|
+
const ly = pt.cy - offset - labelHeight;
|
|
115
|
+
if (
|
|
116
|
+
ly < chartBounds.top ||
|
|
117
|
+
lx < chartBounds.left ||
|
|
118
|
+
lx + labelWidth > chartBounds.right
|
|
119
|
+
)
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
|
|
123
|
+
textX: pt.cx,
|
|
124
|
+
textY: ly + labelHeight / 2,
|
|
125
|
+
anchor: 'middle',
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
// Below
|
|
131
|
+
gen: (offset) => {
|
|
132
|
+
const lx = pt.cx - labelWidth / 2;
|
|
133
|
+
const ly = pt.cy + offset;
|
|
134
|
+
if (
|
|
135
|
+
ly + labelHeight > chartBounds.bottom ||
|
|
136
|
+
lx < chartBounds.left ||
|
|
137
|
+
lx + labelWidth > chartBounds.right
|
|
138
|
+
)
|
|
139
|
+
return null;
|
|
140
|
+
return {
|
|
141
|
+
rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
|
|
142
|
+
textX: pt.cx,
|
|
143
|
+
textY: ly + labelHeight / 2,
|
|
144
|
+
anchor: 'middle',
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
// Right
|
|
150
|
+
gen: (offset) => {
|
|
151
|
+
const lx = pt.cx + offset;
|
|
152
|
+
const ly = pt.cy - labelHeight / 2;
|
|
153
|
+
if (
|
|
154
|
+
lx + labelWidth > chartBounds.right ||
|
|
155
|
+
ly < chartBounds.top ||
|
|
156
|
+
ly + labelHeight > chartBounds.bottom
|
|
157
|
+
)
|
|
158
|
+
return null;
|
|
159
|
+
return {
|
|
160
|
+
rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
|
|
161
|
+
textX: lx,
|
|
162
|
+
textY: pt.cy,
|
|
163
|
+
anchor: 'start',
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
// Left
|
|
169
|
+
gen: (offset) => {
|
|
170
|
+
const lx = pt.cx - offset - labelWidth;
|
|
171
|
+
const ly = pt.cy - labelHeight / 2;
|
|
172
|
+
if (
|
|
173
|
+
lx < chartBounds.left ||
|
|
174
|
+
ly < chartBounds.top ||
|
|
175
|
+
ly + labelHeight > chartBounds.bottom
|
|
176
|
+
)
|
|
177
|
+
return null;
|
|
178
|
+
return {
|
|
179
|
+
rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
|
|
180
|
+
textX: lx + labelWidth,
|
|
181
|
+
textY: pt.cy,
|
|
182
|
+
anchor: 'end',
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
for (const { gen } of directions) {
|
|
189
|
+
for (let offset = minGap; ; offset += stepSize) {
|
|
190
|
+
const cand = gen(offset);
|
|
191
|
+
if (!cand) break; // out of bounds in this direction
|
|
192
|
+
|
|
193
|
+
// Check collisions with placed labels
|
|
194
|
+
let collision = false;
|
|
195
|
+
for (const pl of placedLabels) {
|
|
196
|
+
if (rectsOverlap(cand.rect, pl)) {
|
|
197
|
+
collision = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Check collisions with point circles
|
|
203
|
+
if (!collision) {
|
|
204
|
+
for (const circle of pointCircles) {
|
|
205
|
+
if (rectCircleOverlap(cand.rect, circle)) {
|
|
206
|
+
collision = true;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check collisions with obstacle rects (quadrant labels)
|
|
213
|
+
if (!collision) {
|
|
214
|
+
for (const obs of obstacles) {
|
|
215
|
+
if (rectsOverlap(cand.rect, obs)) {
|
|
216
|
+
collision = true;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!collision) {
|
|
223
|
+
const dist = offset;
|
|
224
|
+
if (!best || dist < best.dist) {
|
|
225
|
+
best = {
|
|
226
|
+
rect: cand.rect,
|
|
227
|
+
textX: cand.textX,
|
|
228
|
+
textY: cand.textY,
|
|
229
|
+
anchor: cand.anchor,
|
|
230
|
+
dist,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
break; // best for this direction found
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Fallback: place above at minGap (may overlap, but at least visible)
|
|
239
|
+
if (!best) {
|
|
240
|
+
const lx = pt.cx - labelWidth / 2;
|
|
241
|
+
const ly = pt.cy - minGap - labelHeight;
|
|
242
|
+
best = {
|
|
243
|
+
rect: { x: lx, y: ly, w: labelWidth, h: labelHeight },
|
|
244
|
+
textX: pt.cx,
|
|
245
|
+
textY: ly + labelHeight / 2,
|
|
246
|
+
anchor: 'middle',
|
|
247
|
+
dist: minGap,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
placedLabels.push(best.rect);
|
|
252
|
+
|
|
253
|
+
// Connector line when label is pushed beyond immediate adjacency
|
|
254
|
+
let connectorLine: PlacedQuadrantLabel['connectorLine'];
|
|
255
|
+
if (best.dist > minGap + stepSize) {
|
|
256
|
+
// Determine connector endpoints: from point edge to label edge
|
|
257
|
+
const dx = best.textX - pt.cx;
|
|
258
|
+
const dy = best.textY - pt.cy;
|
|
259
|
+
const angle = Math.atan2(dy, dx);
|
|
260
|
+
const x1 = pt.cx + Math.cos(angle) * pointRadius;
|
|
261
|
+
const y1 = pt.cy + Math.sin(angle) * pointRadius;
|
|
262
|
+
|
|
263
|
+
// Label edge: closest point on label rect to the point
|
|
264
|
+
const x2 = Math.max(
|
|
265
|
+
best.rect.x,
|
|
266
|
+
Math.min(pt.cx, best.rect.x + best.rect.w)
|
|
267
|
+
);
|
|
268
|
+
const y2 = Math.max(
|
|
269
|
+
best.rect.y,
|
|
270
|
+
Math.min(pt.cy, best.rect.y + best.rect.h)
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
connectorLine = { x1, y1, x2, y2 };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
results.push({
|
|
277
|
+
label: pt.label,
|
|
278
|
+
x: best.textX,
|
|
279
|
+
y: best.textY,
|
|
280
|
+
anchor: best.anchor,
|
|
281
|
+
connectorLine,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return results;
|
|
286
|
+
}
|