@opendata-ai/openchart-engine 7.2.3 → 7.3.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 (40) hide show
  1. package/dist/index.d.ts +84 -36
  2. package/dist/index.js +4013 -2872
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
  6. package/src/__tests__/axes.test.ts +67 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +7 -2
  10. package/src/__tests__/helpers/invariants.ts +267 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +342 -0
  13. package/src/__tests__/layout-invariants.test.ts +533 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/bar/__tests__/compute.test.ts +67 -0
  26. package/src/charts/bar/compute.ts +8 -1
  27. package/src/charts/post-process.ts +21 -7
  28. package/src/compile/layer.ts +31 -9
  29. package/src/compile.ts +85 -148
  30. package/src/index.ts +9 -0
  31. package/src/layout/__tests__/metrics.test.ts +97 -0
  32. package/src/layout/axes.ts +93 -1
  33. package/src/layout/dimensions.ts +80 -98
  34. package/src/layout/plan.ts +524 -0
  35. package/src/layout/shared.ts +44 -0
  36. package/src/legend/compute.ts +315 -78
  37. package/src/legend/wrap.ts +11 -7
  38. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  39. package/src/tables/__tests__/heatmap.test.ts +24 -20
  40. package/src/tables/heatmap.ts +25 -9
@@ -0,0 +1,316 @@
1
+ import type { Rect } from '@opendata-ai/openchart-core';
2
+ import { overlapArea } from '@opendata-ai/openchart-core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import {
5
+ findBestPlacement,
6
+ isAutoPlacement,
7
+ type PlacementObstacle,
8
+ samplePolylineObstacles,
9
+ } from '../placement';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // samplePolylineObstacles
13
+ // ---------------------------------------------------------------------------
14
+
15
+ describe('samplePolylineObstacles', () => {
16
+ it('returns empty array for empty input', () => {
17
+ expect(samplePolylineObstacles([], 'line')).toEqual([]);
18
+ });
19
+
20
+ it('returns single padded rect for a single point', () => {
21
+ const pad = 3;
22
+ const result = samplePolylineObstacles([{ x: 50, y: 100 }], 'line', 24, pad);
23
+ expect(result).toHaveLength(1);
24
+ expect(result[0]).toEqual({
25
+ x: 50 - pad,
26
+ y: 100 - pad,
27
+ width: pad * 2,
28
+ height: pad * 2,
29
+ kind: 'line',
30
+ });
31
+ });
32
+
33
+ it('returns single vertical rect when all points share the same x', () => {
34
+ const pad = 3;
35
+ const points = [
36
+ { x: 100, y: 10 },
37
+ { x: 100, y: 50 },
38
+ { x: 100, y: 30 },
39
+ ];
40
+ const result = samplePolylineObstacles(points, 'line', 24, pad);
41
+ expect(result).toHaveLength(1);
42
+ expect(result[0].x).toBe(100 - pad);
43
+ expect(result[0].y).toBe(10 - pad);
44
+ expect(result[0].width).toBe(pad * 2);
45
+ expect(result[0].height).toBe(50 - 10 + pad * 2);
46
+ });
47
+
48
+ it('produces gap-free coverage for a steep sine wave', () => {
49
+ // Generate a sine wave with enough amplitude to cross many buckets vertically
50
+ const bucketWidth = 20;
51
+ const points: Array<{ x: number; y: number }> = [];
52
+ for (let i = 0; i <= 200; i++) {
53
+ const x = i;
54
+ const y = Math.sin(x * 0.1) * 100;
55
+ points.push({ x, y });
56
+ }
57
+
58
+ const obstacles = samplePolylineObstacles(points, 'line', bucketWidth, 0);
59
+ expect(obstacles.length).toBeGreaterThan(0);
60
+
61
+ // The union of obstacle x-ranges should cover the entire polyline x-extent
62
+ const xMin = Math.min(...points.map((p) => p.x));
63
+ const xMax = Math.max(...points.map((p) => p.x));
64
+
65
+ // Sort obstacles by x and verify continuous coverage
66
+ const sorted = [...obstacles].sort((a, b) => a.x - b.x);
67
+ expect(sorted[0].x).toBeLessThanOrEqual(xMin);
68
+ const lastObs = sorted[sorted.length - 1];
69
+ expect(lastObs.x + lastObs.width).toBeGreaterThanOrEqual(xMax);
70
+
71
+ // Check no gaps between consecutive obstacles
72
+ for (let i = 1; i < sorted.length; i++) {
73
+ const prevEnd = sorted[i - 1].x + sorted[i - 1].width;
74
+ expect(sorted[i].x).toBeLessThanOrEqual(prevEnd + 0.001);
75
+ }
76
+ });
77
+
78
+ it('captures min/max y within each bucket for monotone increasing data', () => {
79
+ const bucketWidth = 50;
80
+ const pad = 0;
81
+ // Monotone increasing: y = x
82
+ const points: Array<{ x: number; y: number }> = [];
83
+ for (let i = 0; i <= 200; i += 10) {
84
+ points.push({ x: i, y: i });
85
+ }
86
+
87
+ const obstacles = samplePolylineObstacles(points, 'line', bucketWidth, pad);
88
+
89
+ for (const obs of obstacles) {
90
+ // Find all points within this bucket's x-range
91
+ const bucketXStart = obs.x;
92
+ const bucketXEnd = obs.x + obs.width;
93
+ const inBucket = points.filter((p) => p.x >= bucketXStart && p.x < bucketXEnd);
94
+
95
+ if (inBucket.length === 0) continue;
96
+
97
+ const minY = Math.min(...inBucket.map((p) => p.y));
98
+ const maxY = Math.max(...inBucket.map((p) => p.y));
99
+
100
+ // The obstacle's y range should contain the points' y range within this bucket
101
+ // (it may be larger due to interpolation at bucket edges, but should not be smaller)
102
+ expect(obs.y).toBeLessThanOrEqual(minY);
103
+ expect(obs.y + obs.height).toBeGreaterThanOrEqual(maxY);
104
+ }
105
+ });
106
+
107
+ it('bucket width parameter controls granularity', () => {
108
+ const points = [
109
+ { x: 0, y: 0 },
110
+ { x: 100, y: 100 },
111
+ ];
112
+
113
+ const narrow = samplePolylineObstacles(points, 'line', 10, 0);
114
+ const wide = samplePolylineObstacles(points, 'line', 50, 0);
115
+
116
+ // Narrower buckets should produce more obstacles
117
+ expect(narrow.length).toBeGreaterThan(wide.length);
118
+ // Each obstacle's width matches the bucket width
119
+ for (const obs of narrow) expect(obs.width).toBe(10);
120
+ for (const obs of wide) expect(obs.width).toBe(50);
121
+ });
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // overlapArea (from @opendata-ai/openchart-core)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ describe('overlapArea', () => {
129
+ it('returns 0 for disjoint rects', () => {
130
+ const a: Rect = { x: 0, y: 0, width: 10, height: 10 };
131
+ const b: Rect = { x: 20, y: 20, width: 10, height: 10 };
132
+ expect(overlapArea(a, b)).toBe(0);
133
+ });
134
+
135
+ it('returns correct area for partial overlap', () => {
136
+ const a: Rect = { x: 0, y: 0, width: 10, height: 10 };
137
+ const b: Rect = { x: 5, y: 5, width: 10, height: 10 };
138
+ // Overlap region: x=[5,10], y=[5,10] => 5*5 = 25
139
+ expect(overlapArea(a, b)).toBe(25);
140
+ });
141
+
142
+ it('returns smaller rects area when one rect is fully contained', () => {
143
+ const outer: Rect = { x: 0, y: 0, width: 100, height: 100 };
144
+ const inner: Rect = { x: 10, y: 10, width: 20, height: 30 };
145
+ expect(overlapArea(outer, inner)).toBe(20 * 30);
146
+ // Symmetric
147
+ expect(overlapArea(inner, outer)).toBe(20 * 30);
148
+ });
149
+
150
+ it('returns full area for identical rects', () => {
151
+ const r: Rect = { x: 5, y: 5, width: 40, height: 60 };
152
+ expect(overlapArea(r, r)).toBe(40 * 60);
153
+ });
154
+ });
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Scorer (via findBestPlacement)
158
+ // ---------------------------------------------------------------------------
159
+
160
+ describe('findBestPlacement scorer', () => {
161
+ // A large chart area and SVG rect so nothing clips
162
+ const chartArea: Rect = { x: 0, y: 0, width: 800, height: 600 };
163
+ const svgRect: Rect = { x: 0, y: 0, width: 800, height: 600 };
164
+ const style = { fontSize: 12, fontWeight: 400, lineHeight: 1.3 };
165
+
166
+ it('picks NE (direction index 1) first for a lower-half anchor on an empty chart', () => {
167
+ // Anchor in the lower half of the chart
168
+ const result = findBestPlacement(
169
+ 400,
170
+ 400,
171
+ 'Test',
172
+ style,
173
+ undefined,
174
+ undefined,
175
+ [],
176
+ chartArea,
177
+ svgRect,
178
+ undefined,
179
+ true,
180
+ );
181
+
182
+ // LOWER_HALF_ORDER = [1, 0, 7, 2, 3, 4, 5, 6] => first tried is NE (index 1)
183
+ // On empty chart, the first direction tried gets the lowest tiebreak and wins
184
+ // (all candidates score close to 0 for obstacles/clipping, tiebreak resolves it)
185
+ expect(result.debug).toBeDefined();
186
+ // The best candidate should be direction index 1 (NE)
187
+ const best = result.debug!.reduce((a, b) => (a.total <= b.total ? a : b));
188
+ expect(best.direction).toBe('NE');
189
+ });
190
+
191
+ it('picks SE (direction index 3) first for an upper-half anchor on an empty chart', () => {
192
+ // Anchor in the upper half of the chart
193
+ const result = findBestPlacement(
194
+ 400,
195
+ 100,
196
+ 'Test',
197
+ style,
198
+ undefined,
199
+ undefined,
200
+ [],
201
+ chartArea,
202
+ svgRect,
203
+ undefined,
204
+ true,
205
+ );
206
+
207
+ // UPPER_HALF_ORDER = [3, 4, 5, 2, 1, 0, 7, 6] => first tried is SE (index 3)
208
+ const best = result.debug!.reduce((a, b) => (a.total <= b.total ? a : b));
209
+ expect(best.direction).toBe('SE');
210
+ });
211
+
212
+ it('avoids the N direction when blocked by a hard obstacle', () => {
213
+ // Place a large hard obstacle directly above the anchor so that the N direction
214
+ // would overlap it heavily
215
+ const anchorX = 400;
216
+ const anchorY = 400;
217
+ const obstacle: PlacementObstacle = {
218
+ x: anchorX - 100,
219
+ y: anchorY - 80,
220
+ width: 200,
221
+ height: 60,
222
+ kind: 'line',
223
+ };
224
+
225
+ const result = findBestPlacement(
226
+ anchorX,
227
+ anchorY,
228
+ 'Test label',
229
+ style,
230
+ undefined,
231
+ undefined,
232
+ [obstacle],
233
+ chartArea,
234
+ svgRect,
235
+ undefined,
236
+ true,
237
+ );
238
+
239
+ // The winning direction should NOT be N (index 0), because the obstacle blocks it
240
+ const best = result.debug!.reduce((a, b) => (a.total <= b.total ? a : b));
241
+ expect(best.direction).not.toBe('N');
242
+ });
243
+
244
+ it('penalizes area-fill overlap less than line overlap of equal area', () => {
245
+ const anchorX = 400;
246
+ const anchorY = 300;
247
+
248
+ // Surround the anchor with obstacles on all sides so every candidate
249
+ // overlaps at least one. The winning candidate will necessarily overlap,
250
+ // making the obstacle kind weight the differentiating factor.
251
+ function makeWall(kind: 'line' | 'area-fill'): PlacementObstacle[] {
252
+ return [{ x: anchorX - 200, y: anchorY - 200, width: 400, height: 400, kind }];
253
+ }
254
+
255
+ const lineResult = findBestPlacement(
256
+ anchorX,
257
+ anchorY,
258
+ 'Test',
259
+ style,
260
+ undefined,
261
+ undefined,
262
+ makeWall('line'),
263
+ chartArea,
264
+ svgRect,
265
+ undefined,
266
+ true,
267
+ );
268
+
269
+ const areaResult = findBestPlacement(
270
+ anchorX,
271
+ anchorY,
272
+ 'Test',
273
+ style,
274
+ undefined,
275
+ undefined,
276
+ makeWall('area-fill'),
277
+ chartArea,
278
+ svgRect,
279
+ undefined,
280
+ true,
281
+ );
282
+
283
+ // Both winners overlap their obstacle, but area-fill weight (1.5) is much
284
+ // lower than line weight (20), so the area-fill score should be smaller.
285
+ expect(areaResult.score).toBeLessThan(lineResult.score);
286
+ });
287
+ });
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // isAutoPlacement
291
+ // ---------------------------------------------------------------------------
292
+
293
+ describe('isAutoPlacement', () => {
294
+ it('returns true for plain annotation without offset/anchor/drop-line', () => {
295
+ expect(isAutoPlacement({})).toBe(true);
296
+ });
297
+
298
+ it('returns false when offset is set', () => {
299
+ expect(isAutoPlacement({ offset: { dx: 10 } })).toBe(false);
300
+ });
301
+
302
+ it('returns false when anchor is not auto', () => {
303
+ expect(isAutoPlacement({ anchor: 'top' })).toBe(false);
304
+ expect(isAutoPlacement({ anchor: 'left' })).toBe(false);
305
+ expect(isAutoPlacement({ anchor: 'right' })).toBe(false);
306
+ expect(isAutoPlacement({ anchor: 'bottom' })).toBe(false);
307
+ });
308
+
309
+ it('returns false when connector is drop-line', () => {
310
+ expect(isAutoPlacement({ connector: 'drop-line' })).toBe(false);
311
+ });
312
+
313
+ it('returns true when anchor is explicitly auto', () => {
314
+ expect(isAutoPlacement({ anchor: 'auto' })).toBe(true);
315
+ });
316
+ });
@@ -13,7 +13,12 @@ import { detectCollision } from '@opendata-ai/openchart-core';
13
13
  import type { NormalizedChartSpec } from '../compiler/types';
14
14
  import type { ResolvedScales } from '../layout/scales';
15
15
  import { CLAMP_MARGIN, NUDGE_PADDING } from './constants';
16
- import { estimateLabelBounds, recomputeConnector } from './geometry';
16
+ import {
17
+ type AnnotationMeasureTextFn,
18
+ estimateLabelBounds,
19
+ heuristicMeasure,
20
+ recomputeConnector,
21
+ } from './geometry';
17
22
  import { resolvePosition } from './position';
18
23
 
19
24
  /**
@@ -61,10 +66,11 @@ export function nudgeAnnotationFromObstacles(
61
66
  scales: ResolvedScales,
62
67
  chartArea: Rect,
63
68
  obstacles: Rect[],
69
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
64
70
  ): boolean {
65
71
  if (annotation.type !== 'text' || !annotation.label) return false;
66
72
 
67
- const labelBounds = estimateLabelBounds(annotation.label);
73
+ const labelBounds = estimateLabelBounds(annotation.label, measure);
68
74
  const collidingObs = obstacles.filter(
69
75
  (obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs),
70
76
  );
@@ -90,7 +96,7 @@ export function nudgeAnnotationFromObstacles(
90
96
  connector: recomputeConnector({ ...annotation.label, x: newLabelX, y: newLabelY }, px, py),
91
97
  };
92
98
 
93
- const candidateBounds = estimateLabelBounds(candidateLabel);
99
+ const candidateBounds = estimateLabelBounds(candidateLabel, measure);
94
100
 
95
101
  // Check no collisions with any obstacle
96
102
  const stillCollides = obstacles.some(
@@ -114,6 +120,7 @@ export function nudgeAnnotationFromObstacles(
114
120
 
115
121
  if (inBounds) {
116
122
  annotation.label = candidateLabel;
123
+ annotation.label.bounds = candidateBounds;
117
124
  return true;
118
125
  }
119
126
  }
@@ -134,6 +141,7 @@ export function resolveAnnotationCollisions(
134
141
  originalSpecs: NormalizedChartSpec['annotations'],
135
142
  scales: ResolvedScales,
136
143
  chartArea: Rect,
144
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
137
145
  ): void {
138
146
  const placedBounds: Rect[] = [];
139
147
 
@@ -143,7 +151,7 @@ export function resolveAnnotationCollisions(
143
151
  continue;
144
152
  }
145
153
 
146
- const bounds = estimateLabelBounds(annotation.label);
154
+ const bounds = estimateLabelBounds(annotation.label, measure);
147
155
 
148
156
  // Check against all previously placed annotation labels
149
157
  const collidingBounds = placedBounds.filter(
@@ -171,7 +179,7 @@ export function resolveAnnotationCollisions(
171
179
  x: newLabelX,
172
180
  y: newLabelY,
173
181
  };
174
- const candidateBounds = estimateLabelBounds(candidateLabel);
182
+ const candidateBounds = estimateLabelBounds(candidateLabel, measure);
175
183
 
176
184
  // Check no collisions with any placed label
177
185
  const stillCollides = placedBounds.some(
@@ -193,6 +201,7 @@ export function resolveAnnotationCollisions(
193
201
  ...annotation.label,
194
202
  x: newLabelX,
195
203
  y: newLabelY,
204
+ bounds: candidateBounds,
196
205
  connector: recomputeConnector(
197
206
  { ...annotation.label, x: newLabelX, y: newLabelY },
198
207
  px,
@@ -207,7 +216,7 @@ export function resolveAnnotationCollisions(
207
216
  }
208
217
 
209
218
  // Add this annotation's final bounds to the placed list
210
- placedBounds.push(estimateLabelBounds(annotation.label));
219
+ placedBounds.push(estimateLabelBounds(annotation.label, measure));
211
220
  }
212
221
  }
213
222
 
@@ -221,11 +230,12 @@ export function clampAnnotationsToBounds(
221
230
  annotations: ResolvedAnnotation[],
222
231
  svgWidth: number,
223
232
  svgHeight: number,
233
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
224
234
  ): void {
225
235
  for (const annotation of annotations) {
226
236
  if (annotation.type !== 'text' || !annotation.label) continue;
227
237
 
228
- const bounds = estimateLabelBounds(annotation.label);
238
+ const bounds = estimateLabelBounds(annotation.label, measure);
229
239
  let dx = 0;
230
240
  let dy = 0;
231
241
 
@@ -252,10 +262,12 @@ export function clampAnnotationsToBounds(
252
262
  const newY = annotation.label.y + dy;
253
263
 
254
264
  const connector = annotation.label.connector;
265
+ const newBounds = estimateLabelBounds({ ...annotation.label, x: newX, y: newY }, measure);
255
266
  annotation.label = {
256
267
  ...annotation.label,
257
268
  x: newX,
258
269
  y: newY,
270
+ bounds: newBounds,
259
271
  connector: connector
260
272
  ? recomputeConnector(
261
273
  { ...annotation.label, x: newX, y: newY },