@opendata-ai/openchart-engine 6.12.0 → 6.15.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.
Files changed (43) hide show
  1. package/dist/index.js +1022 -648
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/src/__tests__/axes.test.ts +12 -30
  5. package/src/__tests__/compile-chart.test.ts +4 -4
  6. package/src/__tests__/dimensions.test.ts +2 -2
  7. package/src/__tests__/encoding-sugar.test.ts +390 -0
  8. package/src/annotations/collisions.ts +268 -0
  9. package/src/annotations/compute.ts +9 -912
  10. package/src/annotations/constants.ts +32 -0
  11. package/src/annotations/geometry.ts +167 -0
  12. package/src/annotations/position.ts +95 -0
  13. package/src/annotations/resolve-range.ts +98 -0
  14. package/src/annotations/resolve-refline.ts +148 -0
  15. package/src/annotations/resolve-text.ts +134 -0
  16. package/src/charts/__tests__/post-process.test.ts +258 -0
  17. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  18. package/src/charts/bar/compute.ts +27 -6
  19. package/src/charts/bar/index.ts +3 -0
  20. package/src/charts/bar/labels.ts +38 -14
  21. package/src/charts/column/__tests__/compute.test.ts +99 -0
  22. package/src/charts/column/compute.ts +27 -6
  23. package/src/charts/column/index.ts +3 -0
  24. package/src/charts/column/labels.ts +35 -13
  25. package/src/charts/dot/index.ts +10 -1
  26. package/src/charts/dot/labels.ts +37 -6
  27. package/src/charts/line/area.ts +31 -6
  28. package/src/charts/line/compute.ts +7 -2
  29. package/src/charts/line/index.ts +33 -2
  30. package/src/charts/post-process.ts +215 -0
  31. package/src/compile.ts +91 -158
  32. package/src/compiler/normalize.ts +2 -2
  33. package/src/layout/axes.ts +12 -15
  34. package/src/layout/dimensions.ts +3 -3
  35. package/src/layout/scales.ts +116 -36
  36. package/src/legend/compute.ts +2 -4
  37. package/src/tooltips/__tests__/compute.test.ts +188 -0
  38. package/src/tooltips/compute.ts +54 -12
  39. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  40. package/src/transforms/__tests__/fold.test.ts +79 -0
  41. package/src/transforms/aggregate.ts +130 -0
  42. package/src/transforms/fold.ts +49 -0
  43. 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
+ }