@opendata-ai/openchart-core 8.0.0-rc.9 → 8.1.0
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/README.md +1 -1
- package/dist/index.d.ts +403 -45
- package/dist/index.js +50 -18
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +1 -2
- package/schema/chart.schema.json +375 -69
- package/schema/table.schema.json +35 -1
- package/schema/vizspec.schema.json +992 -171
- package/src/accessibility/__tests__/alt-text.test.ts +0 -110
- package/src/accessibility/__tests__/aria.test.ts +0 -125
- package/src/accessibility/alt-text.ts +0 -155
- package/src/accessibility/aria.ts +0 -75
- package/src/accessibility/index.ts +0 -6
- package/src/colors/__tests__/colorblind.test.ts +0 -75
- package/src/colors/__tests__/contrast.test.ts +0 -119
- package/src/colors/__tests__/palettes.test.ts +0 -74
- package/src/colors/colorblind.ts +0 -131
- package/src/colors/contrast.ts +0 -143
- package/src/colors/index.ts +0 -36
- package/src/colors/palettes.ts +0 -206
- package/src/helpers/__tests__/deep-merge.test.ts +0 -54
- package/src/helpers/__tests__/spec-builders.test.ts +0 -338
- package/src/helpers/deep-merge.ts +0 -29
- package/src/helpers/spec-builders.ts +0 -479
- package/src/index.ts +0 -215
- package/src/labels/__tests__/collision.test.ts +0 -310
- package/src/labels/collision.ts +0 -249
- package/src/labels/index.ts +0 -13
- package/src/layout/__tests__/chrome.test.ts +0 -208
- package/src/layout/__tests__/text-measure.test.ts +0 -57
- package/src/layout/chrome.ts +0 -470
- package/src/layout/index.ts +0 -18
- package/src/layout/text-measure.ts +0 -404
- package/src/layout/text-wrap.ts +0 -93
- package/src/locale/__tests__/format.test.ts +0 -606
- package/src/locale/format.ts +0 -459
- package/src/locale/index.ts +0 -21
- package/src/responsive/__tests__/breakpoints.test.ts +0 -146
- package/src/responsive/__tests__/metrics.test.ts +0 -52
- package/src/responsive/breakpoints.ts +0 -177
- package/src/responsive/index.ts +0 -53
- package/src/responsive/metrics.ts +0 -299
- package/src/schema/__tests__/generated-artifacts.test.ts +0 -42
- package/src/schema/__tests__/schema.test.ts +0 -179
- package/src/styles/__tests__/reduced-motion-selectors.test.ts +0 -28
- package/src/styles/animation.css +0 -213
- package/src/styles/base.css +0 -102
- package/src/styles/chrome.css +0 -174
- package/src/styles/dark.css +0 -44
- package/src/styles/graph.css +0 -135
- package/src/styles/index.css +0 -28
- package/src/styles/keyframes.css +0 -151
- package/src/styles/legend.css +0 -18
- package/src/styles/reduced-motion.css +0 -67
- package/src/styles/series-search.css +0 -153
- package/src/styles/sparkline.css +0 -16
- package/src/styles/table-animation.css +0 -93
- package/src/styles/table.css +0 -398
- package/src/styles/token-definitions.ts +0 -199
- package/src/styles/tokens.css +0 -156
- package/src/styles/tooltip.css +0 -126
- package/src/styles/you-draw-it.css +0 -126
- package/src/theme/__tests__/dark-mode.test.ts +0 -232
- package/src/theme/__tests__/defaults.test.ts +0 -73
- package/src/theme/__tests__/resolve.test.ts +0 -277
- package/src/theme/dark-mode.ts +0 -258
- package/src/theme/defaults.ts +0 -110
- package/src/theme/index.ts +0 -8
- package/src/theme/presets.ts +0 -64
- package/src/theme/resolve.ts +0 -312
- package/src/types/__tests__/encoding.test.ts +0 -368
- package/src/types/__tests__/gradient.test.ts +0 -95
- package/src/types/__tests__/spec.test.ts +0 -499
- package/src/types/encoding.ts +0 -280
- package/src/types/events.ts +0 -236
- package/src/types/index.ts +0 -290
- package/src/types/layout.ts +0 -2024
- package/src/types/schema-roots.ts +0 -32
- package/src/types/spec.ts +0 -2925
- package/src/types/table.ts +0 -108
- package/src/types/theme.ts +0 -209
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { ResolvedLabel, TextStyle } from '../../types/layout';
|
|
3
|
-
import {
|
|
4
|
-
computeLabelBounds,
|
|
5
|
-
detectCollision,
|
|
6
|
-
EXTENDED_OFFSET_STRATEGIES,
|
|
7
|
-
type LabelCandidate,
|
|
8
|
-
OFFSET_STRATEGIES,
|
|
9
|
-
resolveCollisions,
|
|
10
|
-
} from '../collision';
|
|
11
|
-
|
|
12
|
-
const defaultStyle: TextStyle = {
|
|
13
|
-
fontFamily: 'Inter',
|
|
14
|
-
fontSize: 12,
|
|
15
|
-
fontWeight: 400,
|
|
16
|
-
fill: '#333',
|
|
17
|
-
lineHeight: 1.3,
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
describe('detectCollision', () => {
|
|
21
|
-
it('detects overlapping rectangles', () => {
|
|
22
|
-
expect(
|
|
23
|
-
detectCollision(
|
|
24
|
-
{ x: 0, y: 0, width: 100, height: 50 },
|
|
25
|
-
{ x: 50, y: 25, width: 100, height: 50 },
|
|
26
|
-
),
|
|
27
|
-
).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('returns false for non-overlapping rectangles', () => {
|
|
31
|
-
expect(
|
|
32
|
-
detectCollision(
|
|
33
|
-
{ x: 0, y: 0, width: 50, height: 50 },
|
|
34
|
-
{ x: 100, y: 100, width: 50, height: 50 },
|
|
35
|
-
),
|
|
36
|
-
).toBe(false);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('returns false for adjacent rectangles (no overlap)', () => {
|
|
40
|
-
expect(
|
|
41
|
-
detectCollision(
|
|
42
|
-
{ x: 0, y: 0, width: 50, height: 50 },
|
|
43
|
-
{ x: 50, y: 0, width: 50, height: 50 },
|
|
44
|
-
),
|
|
45
|
-
).toBe(false);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('detects containment (one inside the other)', () => {
|
|
49
|
-
expect(
|
|
50
|
-
detectCollision(
|
|
51
|
-
{ x: 0, y: 0, width: 100, height: 100 },
|
|
52
|
-
{ x: 25, y: 25, width: 50, height: 50 },
|
|
53
|
-
),
|
|
54
|
-
).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('resolveCollisions', () => {
|
|
59
|
-
it('places non-overlapping labels at their anchors', () => {
|
|
60
|
-
const labels: LabelCandidate[] = [
|
|
61
|
-
{
|
|
62
|
-
text: 'A',
|
|
63
|
-
anchorX: 0,
|
|
64
|
-
anchorY: 0,
|
|
65
|
-
width: 20,
|
|
66
|
-
height: 14,
|
|
67
|
-
priority: 'data',
|
|
68
|
-
style: defaultStyle,
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
text: 'B',
|
|
72
|
-
anchorX: 100,
|
|
73
|
-
anchorY: 100,
|
|
74
|
-
width: 20,
|
|
75
|
-
height: 14,
|
|
76
|
-
priority: 'data',
|
|
77
|
-
style: defaultStyle,
|
|
78
|
-
},
|
|
79
|
-
];
|
|
80
|
-
|
|
81
|
-
const results = resolveCollisions(labels);
|
|
82
|
-
expect(results).toHaveLength(2);
|
|
83
|
-
expect(results[0].visible).toBe(true);
|
|
84
|
-
expect(results[1].visible).toBe(true);
|
|
85
|
-
expect(results[0].x).toBe(0);
|
|
86
|
-
expect(results[0].y).toBe(0);
|
|
87
|
-
expect(results[1].x).toBe(100);
|
|
88
|
-
expect(results[1].y).toBe(100);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('resolves overlapping labels by offsetting', () => {
|
|
92
|
-
const labels: LabelCandidate[] = [
|
|
93
|
-
{
|
|
94
|
-
text: 'A',
|
|
95
|
-
anchorX: 50,
|
|
96
|
-
anchorY: 50,
|
|
97
|
-
width: 40,
|
|
98
|
-
height: 14,
|
|
99
|
-
priority: 'data',
|
|
100
|
-
style: defaultStyle,
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
text: 'B',
|
|
104
|
-
anchorX: 55,
|
|
105
|
-
anchorY: 52,
|
|
106
|
-
width: 40,
|
|
107
|
-
height: 14,
|
|
108
|
-
priority: 'data',
|
|
109
|
-
style: defaultStyle,
|
|
110
|
-
},
|
|
111
|
-
];
|
|
112
|
-
|
|
113
|
-
const results = resolveCollisions(labels);
|
|
114
|
-
expect(results).toHaveLength(2);
|
|
115
|
-
// First label should be at anchor
|
|
116
|
-
expect(results[0].x).toBe(50);
|
|
117
|
-
// Second label should be offset
|
|
118
|
-
expect(results[1].x !== 55 || results[1].y !== 52).toBe(true);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('prioritizes data labels over annotation labels', () => {
|
|
122
|
-
const labels: LabelCandidate[] = [
|
|
123
|
-
{
|
|
124
|
-
text: 'Annotation',
|
|
125
|
-
anchorX: 50,
|
|
126
|
-
anchorY: 50,
|
|
127
|
-
width: 60,
|
|
128
|
-
height: 14,
|
|
129
|
-
priority: 'annotation',
|
|
130
|
-
style: defaultStyle,
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
text: 'Data',
|
|
134
|
-
anchorX: 50,
|
|
135
|
-
anchorY: 50,
|
|
136
|
-
width: 40,
|
|
137
|
-
height: 14,
|
|
138
|
-
priority: 'data',
|
|
139
|
-
style: defaultStyle,
|
|
140
|
-
},
|
|
141
|
-
];
|
|
142
|
-
|
|
143
|
-
const results = resolveCollisions(labels);
|
|
144
|
-
// Data label (higher priority) should get its preferred position
|
|
145
|
-
const dataResult = results.find((r) => r.text === 'Data');
|
|
146
|
-
expect(dataResult!.x).toBe(50);
|
|
147
|
-
expect(dataResult!.y).toBe(50);
|
|
148
|
-
expect(dataResult!.visible).toBe(true);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('demotes labels to tooltip-only when no position works', () => {
|
|
152
|
-
// Create many overlapping labels in a tiny area
|
|
153
|
-
const labels: LabelCandidate[] = Array.from({ length: 20 }, (_, i) => ({
|
|
154
|
-
text: `Label ${i}`,
|
|
155
|
-
anchorX: 50,
|
|
156
|
-
anchorY: 50,
|
|
157
|
-
width: 80,
|
|
158
|
-
height: 14,
|
|
159
|
-
priority: 'data' as const,
|
|
160
|
-
style: defaultStyle,
|
|
161
|
-
}));
|
|
162
|
-
|
|
163
|
-
const results = resolveCollisions(labels);
|
|
164
|
-
// Some should be demoted to tooltip-only
|
|
165
|
-
const hidden = results.filter((r) => !r.visible);
|
|
166
|
-
expect(hidden.length).toBeGreaterThan(0);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('adds connector when label is offset from anchor', () => {
|
|
170
|
-
const labels: LabelCandidate[] = [
|
|
171
|
-
{
|
|
172
|
-
text: 'A',
|
|
173
|
-
anchorX: 50,
|
|
174
|
-
anchorY: 50,
|
|
175
|
-
width: 40,
|
|
176
|
-
height: 14,
|
|
177
|
-
priority: 'data',
|
|
178
|
-
style: defaultStyle,
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
text: 'B',
|
|
182
|
-
anchorX: 55,
|
|
183
|
-
anchorY: 52,
|
|
184
|
-
width: 40,
|
|
185
|
-
height: 14,
|
|
186
|
-
priority: 'data',
|
|
187
|
-
style: defaultStyle,
|
|
188
|
-
},
|
|
189
|
-
];
|
|
190
|
-
|
|
191
|
-
const results = resolveCollisions(labels);
|
|
192
|
-
const offsetLabel = results.find((r) => r.connector !== undefined);
|
|
193
|
-
if (offsetLabel) {
|
|
194
|
-
expect(offsetLabel.connector!.to.x).toBe(
|
|
195
|
-
labels.find((l) => l.text === offsetLabel.text)!.anchorX,
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('handles empty input', () => {
|
|
201
|
-
expect(resolveCollisions([])).toEqual([]);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('accepts custom strategies without breaking default behavior', () => {
|
|
205
|
-
const labels: LabelCandidate[] = [
|
|
206
|
-
{
|
|
207
|
-
text: 'A',
|
|
208
|
-
anchorX: 0,
|
|
209
|
-
anchorY: 0,
|
|
210
|
-
width: 20,
|
|
211
|
-
height: 14,
|
|
212
|
-
priority: 'data',
|
|
213
|
-
style: defaultStyle,
|
|
214
|
-
},
|
|
215
|
-
{
|
|
216
|
-
text: 'B',
|
|
217
|
-
anchorX: 100,
|
|
218
|
-
anchorY: 100,
|
|
219
|
-
width: 20,
|
|
220
|
-
height: 14,
|
|
221
|
-
priority: 'data',
|
|
222
|
-
style: defaultStyle,
|
|
223
|
-
},
|
|
224
|
-
];
|
|
225
|
-
|
|
226
|
-
// Explicit default strategies should behave the same as no parameter
|
|
227
|
-
const defaultResults = resolveCollisions(labels);
|
|
228
|
-
const explicitResults = resolveCollisions(labels, OFFSET_STRATEGIES);
|
|
229
|
-
expect(explicitResults).toEqual(defaultResults);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('resolves more dense labels with extended strategies than default', () => {
|
|
233
|
-
// 10 labels at the exact same position, simulating many converging line endpoints.
|
|
234
|
-
// The default 7 strategies can place at most 7; extended strategies should place more.
|
|
235
|
-
const labels: LabelCandidate[] = Array.from({ length: 10 }, (_, i) => ({
|
|
236
|
-
text: `Series ${i}`,
|
|
237
|
-
anchorX: 400,
|
|
238
|
-
anchorY: 200,
|
|
239
|
-
width: 60,
|
|
240
|
-
height: 14,
|
|
241
|
-
priority: 'data' as const,
|
|
242
|
-
style: defaultStyle,
|
|
243
|
-
}));
|
|
244
|
-
|
|
245
|
-
const defaultResults = resolveCollisions(labels);
|
|
246
|
-
const extendedResults = resolveCollisions(labels, EXTENDED_OFFSET_STRATEGIES);
|
|
247
|
-
|
|
248
|
-
const defaultVisible = defaultResults.filter((r) => r.visible).length;
|
|
249
|
-
const extendedVisible = extendedResults.filter((r) => r.visible).length;
|
|
250
|
-
|
|
251
|
-
// Default has 7 strategies, so at most 7 can be placed
|
|
252
|
-
expect(defaultVisible).toBeLessThanOrEqual(OFFSET_STRATEGIES.length);
|
|
253
|
-
// Extended strategies should place more labels than default
|
|
254
|
-
expect(extendedVisible).toBeGreaterThan(defaultVisible);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('EXTENDED_OFFSET_STRATEGIES includes all base strategies', () => {
|
|
258
|
-
for (const strategy of OFFSET_STRATEGIES) {
|
|
259
|
-
expect(EXTENDED_OFFSET_STRATEGIES).toContainEqual(strategy);
|
|
260
|
-
}
|
|
261
|
-
expect(EXTENDED_OFFSET_STRATEGIES.length).toBeGreaterThan(OFFSET_STRATEGIES.length);
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
describe('computeLabelBounds', () => {
|
|
266
|
-
it('covers the glyph box for an alphabetic-baseline label (y is the baseline)', () => {
|
|
267
|
-
// Column value labels emit y on the alphabetic baseline (top + 0.8*fontSize).
|
|
268
|
-
// The bounds must cover the glyphs above the baseline, not sit below them.
|
|
269
|
-
const fontSize = 10;
|
|
270
|
-
const glyphTop = 100; // where the text visually starts
|
|
271
|
-
const label: ResolvedLabel = {
|
|
272
|
-
text: '42',
|
|
273
|
-
x: 50,
|
|
274
|
-
// Emission shifts the top-space y down to the baseline.
|
|
275
|
-
y: glyphTop + fontSize * 0.8,
|
|
276
|
-
style: { ...defaultStyle, fontSize, textAnchor: 'middle' },
|
|
277
|
-
visible: true,
|
|
278
|
-
};
|
|
279
|
-
const bounds = computeLabelBounds(label);
|
|
280
|
-
// Top edge returns to the glyph top, not the baseline.
|
|
281
|
-
expect(bounds.y).toBeCloseTo(glyphTop, 5);
|
|
282
|
-
// The glyph box (from its top for `height`) contains the baseline.
|
|
283
|
-
expect(bounds.y).toBeLessThan(label.y);
|
|
284
|
-
expect(bounds.y + bounds.height).toBeGreaterThan(label.y);
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it("treats y as the vertical center for dominant-baseline 'central'", () => {
|
|
288
|
-
const fontSize = 10;
|
|
289
|
-
const label: ResolvedLabel = {
|
|
290
|
-
text: '42',
|
|
291
|
-
x: 50,
|
|
292
|
-
y: 100,
|
|
293
|
-
style: { ...defaultStyle, fontSize, dominantBaseline: 'central' },
|
|
294
|
-
visible: true,
|
|
295
|
-
};
|
|
296
|
-
const bounds = computeLabelBounds(label);
|
|
297
|
-
expect(bounds.y).toBeCloseTo(100 - bounds.height / 2, 5);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it("treats y as the top edge for dominant-baseline 'hanging'", () => {
|
|
301
|
-
const label: ResolvedLabel = {
|
|
302
|
-
text: '42',
|
|
303
|
-
x: 50,
|
|
304
|
-
y: 100,
|
|
305
|
-
style: { ...defaultStyle, dominantBaseline: 'hanging' },
|
|
306
|
-
visible: true,
|
|
307
|
-
};
|
|
308
|
-
expect(computeLabelBounds(label).y).toBe(100);
|
|
309
|
-
});
|
|
310
|
-
});
|
package/src/labels/collision.ts
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Label collision detection and resolution.
|
|
3
|
-
*
|
|
4
|
-
* Greedy algorithm: sort by priority, place in order, try offset
|
|
5
|
-
* positions for conflicts, demote to tooltip-only if no position works.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { estimateTextWidth } from '../layout/text-measure';
|
|
9
|
-
import type { Rect, ResolvedLabel, TextStyle } from '../types/layout';
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Types
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
/** Priority levels for label placement. Data labels win over annotations which win over axes. */
|
|
16
|
-
export type LabelPriority = 'data' | 'annotation' | 'axis';
|
|
17
|
-
|
|
18
|
-
/** Priority sort order (lower = higher priority). */
|
|
19
|
-
const PRIORITY_ORDER: Record<LabelPriority, number> = {
|
|
20
|
-
data: 0,
|
|
21
|
-
annotation: 1,
|
|
22
|
-
axis: 2,
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* A label candidate for collision resolution.
|
|
27
|
-
* The collision engine decides its final position and visibility.
|
|
28
|
-
*/
|
|
29
|
-
export interface LabelCandidate {
|
|
30
|
-
/** The label text. */
|
|
31
|
-
text: string;
|
|
32
|
-
/** Preferred anchor position (before collision resolution). */
|
|
33
|
-
anchorX: number;
|
|
34
|
-
/** Preferred anchor position (before collision resolution). */
|
|
35
|
-
anchorY: number;
|
|
36
|
-
/** Estimated width of the label text. */
|
|
37
|
-
width: number;
|
|
38
|
-
/** Estimated height of the label text. */
|
|
39
|
-
height: number;
|
|
40
|
-
/** Label priority for collision resolution. */
|
|
41
|
-
priority: LabelPriority;
|
|
42
|
-
/** Text style to apply. */
|
|
43
|
-
style: TextStyle;
|
|
44
|
-
/**
|
|
45
|
-
* Optional caller-assigned source index. resolveCollisions may reorder its
|
|
46
|
-
* output (it sorts by priority), so callers that zip the result back against
|
|
47
|
-
* a parallel array should carry an index here and read it off ResolvedLabel
|
|
48
|
-
* rather than relying on positional order.
|
|
49
|
-
*/
|
|
50
|
-
index?: number;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Collision detection
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Detect AABB (axis-aligned bounding box) overlap between two rectangles.
|
|
59
|
-
*/
|
|
60
|
-
export function detectCollision(a: Rect, b: Rect): boolean {
|
|
61
|
-
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Compute the area of intersection between two axis-aligned rectangles. Returns 0 if disjoint. */
|
|
65
|
-
export function overlapArea(a: Rect, b: Rect): number {
|
|
66
|
-
const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
|
|
67
|
-
const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
|
|
68
|
-
return w > 0 && h > 0 ? w * h : 0;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// Offset strategies
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
/** An offset position to try when resolving a label collision. */
|
|
76
|
-
export interface OffsetStrategy {
|
|
77
|
-
dx: number;
|
|
78
|
-
dy: number;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Offsets to try when a label collides with an existing placement. */
|
|
82
|
-
export const OFFSET_STRATEGIES: readonly OffsetStrategy[] = [
|
|
83
|
-
{ dx: 0, dy: 0 }, // original position
|
|
84
|
-
{ dx: 0, dy: -1.2 }, // above (factor of height)
|
|
85
|
-
{ dx: 0, dy: 1.2 }, // below
|
|
86
|
-
{ dx: 1.1, dy: 0 }, // right
|
|
87
|
-
{ dx: -1.1, dy: 0 }, // left
|
|
88
|
-
{ dx: 1.1, dy: -1.2 }, // upper-right
|
|
89
|
-
{ dx: -1.1, dy: -1.2 }, // upper-left
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Extended offset strategies with additional vertical spread for dense
|
|
94
|
-
* multi-series endpoints (e.g. 5+ line series converging at similar y-values).
|
|
95
|
-
*/
|
|
96
|
-
export const EXTENDED_OFFSET_STRATEGIES: readonly OffsetStrategy[] = [
|
|
97
|
-
...OFFSET_STRATEGIES,
|
|
98
|
-
{ dx: 0, dy: -2.4 }, // further above
|
|
99
|
-
{ dx: 0, dy: 2.4 }, // further below
|
|
100
|
-
{ dx: 0, dy: -3.6 }, // even further above
|
|
101
|
-
{ dx: 0, dy: 3.6 }, // even further below
|
|
102
|
-
{ dx: 1.1, dy: -2.4 }, // upper-right far
|
|
103
|
-
{ dx: 1.1, dy: 2.4 }, // lower-right far
|
|
104
|
-
];
|
|
105
|
-
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
// Public API
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Resolve label collisions using a greedy placement algorithm.
|
|
112
|
-
*
|
|
113
|
-
* Sorts labels by priority (data > annotation > axis), then places
|
|
114
|
-
* each label at its preferred position. If it collides with an already-placed
|
|
115
|
-
* label, tries offset positions. If no position works, the label is
|
|
116
|
-
* demoted to tooltip-only (visible: false).
|
|
117
|
-
*
|
|
118
|
-
* @param labels - Array of label candidates to position.
|
|
119
|
-
* @param strategies - Optional offset strategies to use (defaults to OFFSET_STRATEGIES).
|
|
120
|
-
* @returns Array of resolved labels with computed positions and visibility.
|
|
121
|
-
*/
|
|
122
|
-
export function resolveCollisions(
|
|
123
|
-
labels: LabelCandidate[],
|
|
124
|
-
strategies: readonly OffsetStrategy[] = OFFSET_STRATEGIES,
|
|
125
|
-
): ResolvedLabel[] {
|
|
126
|
-
// Sort by priority (highest first)
|
|
127
|
-
const sorted = [...labels].sort(
|
|
128
|
-
(a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority],
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const placed: Rect[] = [];
|
|
132
|
-
const results: ResolvedLabel[] = [];
|
|
133
|
-
|
|
134
|
-
for (const label of sorted) {
|
|
135
|
-
let bestRect: Rect | null = null;
|
|
136
|
-
let bestX = label.anchorX;
|
|
137
|
-
let bestY = label.anchorY;
|
|
138
|
-
|
|
139
|
-
// Try each offset strategy
|
|
140
|
-
for (const offset of strategies) {
|
|
141
|
-
const candidateX = label.anchorX + offset.dx * label.width;
|
|
142
|
-
const candidateY = label.anchorY + offset.dy * label.height;
|
|
143
|
-
const candidateRect: Rect = {
|
|
144
|
-
x: candidateX,
|
|
145
|
-
y: candidateY,
|
|
146
|
-
width: label.width,
|
|
147
|
-
height: label.height,
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const hasCollision = placed.some((p) => detectCollision(candidateRect, p));
|
|
151
|
-
|
|
152
|
-
if (!hasCollision) {
|
|
153
|
-
bestRect = candidateRect;
|
|
154
|
-
bestX = candidateX;
|
|
155
|
-
bestY = candidateY;
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (bestRect) {
|
|
161
|
-
placed.push(bestRect);
|
|
162
|
-
const nudgeDx = bestX - label.anchorX;
|
|
163
|
-
const nudgeDy = bestY - label.anchorY;
|
|
164
|
-
const nudgeDist = Math.sqrt(nudgeDx * nudgeDx + nudgeDy * nudgeDy);
|
|
165
|
-
// Only show a connector when the label is nudged far enough that the
|
|
166
|
-
// reader can't tell which data point it belongs to. Short nudges between
|
|
167
|
-
// adjacent series labels produce visual noise without aiding comprehension.
|
|
168
|
-
const MIN_CONNECTOR_DISTANCE = 20;
|
|
169
|
-
const needsConnector = nudgeDist >= MIN_CONNECTOR_DISTANCE;
|
|
170
|
-
|
|
171
|
-
results.push({
|
|
172
|
-
text: label.text,
|
|
173
|
-
x: bestX,
|
|
174
|
-
y: bestY,
|
|
175
|
-
style: label.style,
|
|
176
|
-
visible: true,
|
|
177
|
-
// Only carry the index through when the caller set one, so labels that
|
|
178
|
-
// don't need it keep an unchanged output shape.
|
|
179
|
-
...(label.index !== undefined ? { index: label.index } : {}),
|
|
180
|
-
connector: needsConnector
|
|
181
|
-
? {
|
|
182
|
-
from: { x: bestX, y: bestY },
|
|
183
|
-
to: { x: label.anchorX, y: label.anchorY },
|
|
184
|
-
stroke: label.style.fill,
|
|
185
|
-
style: 'straight' as const,
|
|
186
|
-
arrow: false,
|
|
187
|
-
}
|
|
188
|
-
: undefined,
|
|
189
|
-
});
|
|
190
|
-
} else {
|
|
191
|
-
// No position works, demote to tooltip-only
|
|
192
|
-
results.push({
|
|
193
|
-
text: label.text,
|
|
194
|
-
x: label.anchorX,
|
|
195
|
-
y: label.anchorY,
|
|
196
|
-
style: label.style,
|
|
197
|
-
visible: false,
|
|
198
|
-
...(label.index !== undefined ? { index: label.index } : {}),
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return results;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
// Label bounds estimation
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Compute the bounding rect of a resolved label from its position and text.
|
|
212
|
-
* Uses heuristic text measurement so it works without DOM access.
|
|
213
|
-
*
|
|
214
|
-
* label.y is the SVG text anchor point, whose meaning depends on the label's
|
|
215
|
-
* dominant-baseline. The rect's top edge is derived accordingly so the box
|
|
216
|
-
* covers the actual glyphs (obstacles were sitting ~0.8*fontSize below the
|
|
217
|
-
* text for alphabetic-baseline labels, e.g. column value labels):
|
|
218
|
-
* - 'central': y is the vertical center, so top = y - height/2.
|
|
219
|
-
* - 'hanging'/'text-after-edge': y is the top edge already.
|
|
220
|
-
* - default (alphabetic): y is the baseline, so top = y - fontSize*0.8
|
|
221
|
-
* (matches textAscent, the top-to-baseline distance renderers apply).
|
|
222
|
-
*/
|
|
223
|
-
export function computeLabelBounds(label: ResolvedLabel): Rect {
|
|
224
|
-
const fontSize = label.style.fontSize;
|
|
225
|
-
const fontWeight = label.style.fontWeight;
|
|
226
|
-
const width = estimateTextWidth(label.text, fontSize, fontWeight);
|
|
227
|
-
const height = fontSize * (label.style.lineHeight ?? 1.2);
|
|
228
|
-
|
|
229
|
-
// Adjust x based on text anchor
|
|
230
|
-
let x = label.x;
|
|
231
|
-
if (label.style.textAnchor === 'middle') {
|
|
232
|
-
x = label.x - width / 2;
|
|
233
|
-
} else if (label.style.textAnchor === 'end') {
|
|
234
|
-
x = label.x - width;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Map the anchor y to the box top based on dominant-baseline.
|
|
238
|
-
const baseline = label.style.dominantBaseline;
|
|
239
|
-
let y: number;
|
|
240
|
-
if (baseline === 'central') {
|
|
241
|
-
y = label.y - height / 2;
|
|
242
|
-
} else if (baseline === 'hanging' || baseline === 'text-after-edge') {
|
|
243
|
-
y = label.y;
|
|
244
|
-
} else {
|
|
245
|
-
y = label.y - fontSize * 0.8;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return { x, y, width, height };
|
|
249
|
-
}
|
package/src/labels/index.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Labels module barrel export.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
export type { LabelCandidate, LabelPriority, OffsetStrategy } from './collision';
|
|
6
|
-
export {
|
|
7
|
-
computeLabelBounds,
|
|
8
|
-
detectCollision,
|
|
9
|
-
EXTENDED_OFFSET_STRATEGIES,
|
|
10
|
-
OFFSET_STRATEGIES,
|
|
11
|
-
overlapArea,
|
|
12
|
-
resolveCollisions,
|
|
13
|
-
} from './collision';
|