@opendata-ai/openchart-engine 6.11.0 → 6.13.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/dist/index.d.ts +7 -0
- package/dist/index.js +944 -629
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/axes.test.ts +12 -30
- package/src/__tests__/compile-chart.test.ts +4 -4
- package/src/__tests__/dimensions.test.ts +2 -2
- package/src/__tests__/encoding-sugar.test.ts +389 -0
- package/src/annotations/collisions.ts +268 -0
- package/src/annotations/compute.ts +9 -912
- package/src/annotations/constants.ts +32 -0
- package/src/annotations/geometry.ts +167 -0
- package/src/annotations/position.ts +95 -0
- package/src/annotations/resolve-range.ts +98 -0
- package/src/annotations/resolve-refline.ts +148 -0
- package/src/annotations/resolve-text.ts +134 -0
- package/src/charts/__tests__/post-process.test.ts +258 -0
- package/src/charts/bar/__tests__/labels.test.ts +31 -0
- package/src/charts/bar/compute.ts +27 -6
- package/src/charts/bar/labels.ts +7 -1
- package/src/charts/column/__tests__/compute.test.ts +99 -0
- package/src/charts/column/compute.ts +27 -6
- package/src/charts/line/area.ts +19 -2
- package/src/charts/post-process.ts +215 -0
- package/src/compile.ts +113 -169
- package/src/compiler/__tests__/normalize.test.ts +110 -0
- package/src/compiler/normalize.ts +22 -3
- package/src/compiler/types.ts +4 -0
- package/src/graphs/compile-graph.ts +8 -0
- package/src/graphs/types.ts +2 -0
- package/src/layout/axes.ts +10 -13
- package/src/layout/dimensions.ts +6 -3
- package/src/layout/scales.ts +106 -29
- package/src/legend/compute.ts +3 -1
- package/src/sankey/compile-sankey.ts +12 -2
- package/src/sankey/types.ts +1 -0
- package/src/tables/compile-table.ts +5 -0
- package/src/tooltips/__tests__/compute.test.ts +188 -0
- package/src/tooltips/compute.ts +25 -11
- package/src/transforms/__tests__/aggregate.test.ts +159 -0
- package/src/transforms/__tests__/fold.test.ts +79 -0
- package/src/transforms/aggregate.ts +130 -0
- package/src/transforms/fold.ts +49 -0
- package/src/transforms/index.ts +8 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collision avoidance for annotations: nudging away from obstacles,
|
|
3
|
+
* resolving annotation-to-annotation overlaps, and clamping to SVG bounds.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
Rect,
|
|
8
|
+
ResolvedAnnotation,
|
|
9
|
+
ResolvedLabel,
|
|
10
|
+
TextAnnotation,
|
|
11
|
+
} from '@opendata-ai/openchart-core';
|
|
12
|
+
import { detectCollision } from '@opendata-ai/openchart-core';
|
|
13
|
+
import type { NormalizedChartSpec } from '../compiler/types';
|
|
14
|
+
import type { ResolvedScales } from '../layout/scales';
|
|
15
|
+
import { CLAMP_MARGIN, NUDGE_PADDING } from './constants';
|
|
16
|
+
import { estimateLabelBounds, recomputeConnector } from './geometry';
|
|
17
|
+
import { resolvePosition } from './position';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate candidate displacement vectors to move `selfBounds` clear of each
|
|
21
|
+
* obstacle in 4 directions (below, above, left, right), sorted by smallest
|
|
22
|
+
* movement first.
|
|
23
|
+
*/
|
|
24
|
+
export function generateNudgeCandidates(
|
|
25
|
+
selfBounds: Rect,
|
|
26
|
+
obstacles: Rect[],
|
|
27
|
+
padding: number,
|
|
28
|
+
): { dx: number; dy: number; distance: number }[] {
|
|
29
|
+
const candidates: { dx: number; dy: number; distance: number }[] = [];
|
|
30
|
+
|
|
31
|
+
for (const obs of obstacles) {
|
|
32
|
+
// Below: shift self so its top edge clears the obstacle bottom
|
|
33
|
+
const belowDy = obs.y + obs.height + padding - selfBounds.y;
|
|
34
|
+
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
35
|
+
|
|
36
|
+
// Above: shift self so its bottom edge clears the obstacle top
|
|
37
|
+
const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
|
|
38
|
+
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
39
|
+
|
|
40
|
+
// Left: shift self so its right edge clears the obstacle left
|
|
41
|
+
const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
|
|
42
|
+
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
43
|
+
|
|
44
|
+
// Right: shift self so its left edge clears the obstacle right
|
|
45
|
+
const rightDx = obs.x + obs.width + padding - selfBounds.x;
|
|
46
|
+
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
50
|
+
return candidates;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Try to reposition a text annotation to avoid overlapping with obstacle rects
|
|
55
|
+
* (legend bounds, etc.). First tries standard anchor alternatives, then
|
|
56
|
+
* calculates specific offsets needed to clear obstacles. Returns true if moved.
|
|
57
|
+
*/
|
|
58
|
+
export function nudgeAnnotationFromObstacles(
|
|
59
|
+
annotation: ResolvedAnnotation,
|
|
60
|
+
originalAnnotation: TextAnnotation,
|
|
61
|
+
scales: ResolvedScales,
|
|
62
|
+
chartArea: Rect,
|
|
63
|
+
obstacles: Rect[],
|
|
64
|
+
): boolean {
|
|
65
|
+
if (annotation.type !== 'text' || !annotation.label) return false;
|
|
66
|
+
|
|
67
|
+
const labelBounds = estimateLabelBounds(annotation.label);
|
|
68
|
+
const collidingObs = obstacles.filter(
|
|
69
|
+
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (collidingObs.length === 0) return false;
|
|
73
|
+
|
|
74
|
+
// Resolve the data point pixel position for offset calculations
|
|
75
|
+
const px = resolvePosition(originalAnnotation.x, scales.x);
|
|
76
|
+
const py = resolvePosition(originalAnnotation.y, scales.y);
|
|
77
|
+
if (px === null || py === null) return false;
|
|
78
|
+
|
|
79
|
+
const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
|
|
80
|
+
const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split('\n').length);
|
|
81
|
+
|
|
82
|
+
for (const { dx, dy } of candidates) {
|
|
83
|
+
const newLabelX = annotation.label.x + dx;
|
|
84
|
+
const newLabelY = annotation.label.y + dy;
|
|
85
|
+
|
|
86
|
+
const candidateLabel: ResolvedLabel = {
|
|
87
|
+
...annotation.label,
|
|
88
|
+
x: newLabelX,
|
|
89
|
+
y: newLabelY,
|
|
90
|
+
connector: recomputeConnector({ ...annotation.label, x: newLabelX, y: newLabelY }, px, py),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
94
|
+
|
|
95
|
+
// Check no collisions with any obstacle
|
|
96
|
+
const stillCollides = obstacles.some(
|
|
97
|
+
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs),
|
|
98
|
+
);
|
|
99
|
+
if (stillCollides) continue;
|
|
100
|
+
|
|
101
|
+
// Annotations render outside the clip path, so they can extend into margins.
|
|
102
|
+
// Only check that the label center is reasonably within the chart and that
|
|
103
|
+
// the text doesn't go completely off-screen.
|
|
104
|
+
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
105
|
+
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
106
|
+
// Allow nudged labels to extend into the chrome region below the chart
|
|
107
|
+
// (source/footer area) since annotations near the bottom edge often
|
|
108
|
+
// need to shift into that space to avoid marks or the brand watermark.
|
|
109
|
+
const inBounds =
|
|
110
|
+
labelCenterX >= chartArea.x &&
|
|
111
|
+
labelCenterX <= chartArea.x + chartArea.width + 10 &&
|
|
112
|
+
labelCenterY >= chartArea.y - fontSize &&
|
|
113
|
+
labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
|
|
114
|
+
|
|
115
|
+
if (inBounds) {
|
|
116
|
+
annotation.label = candidateLabel;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve collisions between text annotation labels using a greedy algorithm.
|
|
126
|
+
*
|
|
127
|
+
* Iterates through text annotations in order, building a list of "placed"
|
|
128
|
+
* bounding rects. When a later annotation overlaps an already-placed one,
|
|
129
|
+
* it tries offset positions (below, above, left, right) to find a
|
|
130
|
+
* non-colliding spot. Recomputes the connector origin after nudging.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveAnnotationCollisions(
|
|
133
|
+
annotations: ResolvedAnnotation[],
|
|
134
|
+
originalSpecs: NormalizedChartSpec['annotations'],
|
|
135
|
+
scales: ResolvedScales,
|
|
136
|
+
chartArea: Rect,
|
|
137
|
+
): void {
|
|
138
|
+
const placedBounds: Rect[] = [];
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < annotations.length; i++) {
|
|
141
|
+
const annotation = annotations[i];
|
|
142
|
+
if (annotation.type !== 'text' || !annotation.label) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const bounds = estimateLabelBounds(annotation.label);
|
|
147
|
+
|
|
148
|
+
// Check against all previously placed annotation labels
|
|
149
|
+
const collidingBounds = placedBounds.filter(
|
|
150
|
+
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (collidingBounds.length > 0) {
|
|
154
|
+
// Find the original spec to get data point coordinates for connector recomputation
|
|
155
|
+
const originalSpec = originalSpecs[i];
|
|
156
|
+
|
|
157
|
+
if (originalSpec?.type === 'text') {
|
|
158
|
+
const px = resolvePosition(originalSpec.x, scales.x);
|
|
159
|
+
const py = resolvePosition(originalSpec.y, scales.y);
|
|
160
|
+
|
|
161
|
+
if (px !== null && py !== null) {
|
|
162
|
+
const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
|
|
163
|
+
const fontSize = bounds.height / Math.max(1, annotation.label.text.split('\n').length);
|
|
164
|
+
|
|
165
|
+
for (const { dx, dy } of candidates) {
|
|
166
|
+
const newLabelX = annotation.label.x + dx;
|
|
167
|
+
const newLabelY = annotation.label.y + dy;
|
|
168
|
+
|
|
169
|
+
const candidateLabel: ResolvedLabel = {
|
|
170
|
+
...annotation.label,
|
|
171
|
+
x: newLabelX,
|
|
172
|
+
y: newLabelY,
|
|
173
|
+
};
|
|
174
|
+
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
175
|
+
|
|
176
|
+
// Check no collisions with any placed label
|
|
177
|
+
const stillCollides = placedBounds.some(
|
|
178
|
+
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb),
|
|
179
|
+
);
|
|
180
|
+
if (stillCollides) continue;
|
|
181
|
+
|
|
182
|
+
// Check the label center stays reasonably in bounds
|
|
183
|
+
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
184
|
+
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
185
|
+
const inBounds =
|
|
186
|
+
labelCenterX >= chartArea.x &&
|
|
187
|
+
labelCenterX <= chartArea.x + chartArea.width + 10 &&
|
|
188
|
+
labelCenterY >= chartArea.y - fontSize &&
|
|
189
|
+
labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
190
|
+
|
|
191
|
+
if (inBounds) {
|
|
192
|
+
annotation.label = {
|
|
193
|
+
...annotation.label,
|
|
194
|
+
x: newLabelX,
|
|
195
|
+
y: newLabelY,
|
|
196
|
+
connector: recomputeConnector(
|
|
197
|
+
{ ...annotation.label, x: newLabelX, y: newLabelY },
|
|
198
|
+
px,
|
|
199
|
+
py,
|
|
200
|
+
),
|
|
201
|
+
};
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Add this annotation's final bounds to the placed list
|
|
210
|
+
placedBounds.push(estimateLabelBounds(annotation.label));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Shift text annotation labels so they stay within the total SVG bounds.
|
|
216
|
+
* If a label overflows the right, left, top, or bottom edge, its position
|
|
217
|
+
* is adjusted inward by the overflow amount. Connector geometry is updated
|
|
218
|
+
* to match.
|
|
219
|
+
*/
|
|
220
|
+
export function clampAnnotationsToBounds(
|
|
221
|
+
annotations: ResolvedAnnotation[],
|
|
222
|
+
svgWidth: number,
|
|
223
|
+
svgHeight: number,
|
|
224
|
+
): void {
|
|
225
|
+
for (const annotation of annotations) {
|
|
226
|
+
if (annotation.type !== 'text' || !annotation.label) continue;
|
|
227
|
+
|
|
228
|
+
const bounds = estimateLabelBounds(annotation.label);
|
|
229
|
+
let dx = 0;
|
|
230
|
+
let dy = 0;
|
|
231
|
+
|
|
232
|
+
// Right overflow
|
|
233
|
+
if (bounds.x + bounds.width > svgWidth - CLAMP_MARGIN) {
|
|
234
|
+
dx = svgWidth - CLAMP_MARGIN - (bounds.x + bounds.width);
|
|
235
|
+
}
|
|
236
|
+
// Left overflow
|
|
237
|
+
if (bounds.x + dx < CLAMP_MARGIN) {
|
|
238
|
+
dx = CLAMP_MARGIN - bounds.x;
|
|
239
|
+
}
|
|
240
|
+
// Top overflow
|
|
241
|
+
if (bounds.y < CLAMP_MARGIN) {
|
|
242
|
+
dy = CLAMP_MARGIN - bounds.y;
|
|
243
|
+
}
|
|
244
|
+
// Bottom overflow
|
|
245
|
+
if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
|
|
246
|
+
dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (dx === 0 && dy === 0) continue;
|
|
250
|
+
|
|
251
|
+
const newX = annotation.label.x + dx;
|
|
252
|
+
const newY = annotation.label.y + dy;
|
|
253
|
+
|
|
254
|
+
const connector = annotation.label.connector;
|
|
255
|
+
annotation.label = {
|
|
256
|
+
...annotation.label,
|
|
257
|
+
x: newX,
|
|
258
|
+
y: newY,
|
|
259
|
+
connector: connector
|
|
260
|
+
? recomputeConnector(
|
|
261
|
+
{ ...annotation.label, x: newX, y: newY },
|
|
262
|
+
connector.to.x,
|
|
263
|
+
connector.to.y,
|
|
264
|
+
)
|
|
265
|
+
: undefined,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|