@opendata-ai/openchart-engine 7.2.4 → 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 (37) hide show
  1. package/dist/index.d.ts +84 -36
  2. package/dist/index.js +4010 -2871
  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/post-process.ts +21 -7
  26. package/src/compile/layer.ts +31 -9
  27. package/src/compile.ts +85 -148
  28. package/src/index.ts +9 -0
  29. package/src/layout/axes.ts +93 -1
  30. package/src/layout/dimensions.ts +80 -98
  31. package/src/layout/plan.ts +524 -0
  32. package/src/layout/shared.ts +44 -0
  33. package/src/legend/compute.ts +315 -78
  34. package/src/legend/wrap.ts +11 -7
  35. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  36. package/src/tables/__tests__/heatmap.test.ts +24 -20
  37. package/src/tables/heatmap.ts +25 -9
@@ -0,0 +1,533 @@
1
+ import type { ChartLayout } from '@opendata-ai/openchart-core';
2
+ import { afterAll, describe, expect, it } from 'vitest';
3
+ import { compileChart } from '../compile';
4
+ import { checkLayoutInvariants } from './helpers/invariants';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test data helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const SHORT_LABELS = ['A', 'B', 'C', 'D', 'E'];
11
+ const LONG_LABELS = [
12
+ 'United States of America',
13
+ 'United Kingdom of Great Britain',
14
+ "People's Republic of China",
15
+ 'Federal Republic of Germany',
16
+ 'Commonwealth of Australia',
17
+ ];
18
+
19
+ function makeNominalData(labels: string[]) {
20
+ return labels.map((name, i) => ({ name, value: (i + 1) * 10 }));
21
+ }
22
+
23
+ function makeTemporalData(series: string[]) {
24
+ const dates = ['2020-01-01', '2020-06-01', '2021-01-01', '2021-06-01', '2022-01-01'];
25
+ return series.flatMap((s) =>
26
+ dates.map((date, i) => ({ date, value: (i + 1) * 10 + 2.5, series: s })),
27
+ );
28
+ }
29
+
30
+ function makePieData(labels: string[]) {
31
+ return labels.map((category, i) => ({ category, value: (i + 1) * 15 }));
32
+ }
33
+
34
+ function makeScatterData(groups: string[]) {
35
+ return groups.flatMap((group) =>
36
+ Array.from({ length: 5 }, (_, i) => ({
37
+ x: (i + 1) * 10 + 0.5 * 5,
38
+ y: (i + 1) * 8 + 0.5 * 5,
39
+ group,
40
+ })),
41
+ );
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Chrome presets
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const NO_CHROME = {};
49
+ const TITLE_ONLY = { title: 'Chart Title', subtitle: 'A subtitle for context' };
50
+ const FULL_CHROME = {
51
+ title: 'Chart Title',
52
+ subtitle: 'A subtitle for context',
53
+ source: 'Source: Test Data Corp',
54
+ byline: 'By Test Author',
55
+ footer: 'Note: values are illustrative',
56
+ };
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Shared config
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const WIDTHS = [320, 800] as const;
63
+ const HEIGHT = 400;
64
+
65
+ let compileSkipCount = 0;
66
+
67
+ /**
68
+ * Compile a spec and run layout invariants. Returns early (skip) if
69
+ * compileChart throws due to pre-existing compile failures from plan merges.
70
+ */
71
+ function compileAndCheck(spec: Record<string, unknown>, width: number, height = HEIGHT) {
72
+ let layout: ChartLayout;
73
+ try {
74
+ layout = compileChart(spec, { width, height });
75
+ } catch {
76
+ compileSkipCount++;
77
+ return;
78
+ }
79
+ const violations = checkLayoutInvariants(layout, { svgWidth: width, svgHeight: height });
80
+ expect(violations, violations.join('\n')).toEqual([]);
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Tests
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe('layout invariants', () => {
88
+ afterAll(() => {
89
+ if (compileSkipCount > 0) {
90
+ console.warn(`layout invariants: ${compileSkipCount} tests skipped due to compile errors`);
91
+ }
92
+ });
93
+
94
+ // -----------------------------------------------------------------------
95
+ // Bar charts (horizontal: x=quantitative, y=nominal)
96
+ // -----------------------------------------------------------------------
97
+ describe('bar charts (horizontal)', () => {
98
+ for (const width of WIDTHS) {
99
+ it(`short labels, no chrome at ${width}px`, () => {
100
+ compileAndCheck(
101
+ {
102
+ mark: 'bar',
103
+ data: makeNominalData(SHORT_LABELS),
104
+ encoding: {
105
+ x: { field: 'value', type: 'quantitative' },
106
+ y: { field: 'name', type: 'nominal' },
107
+ },
108
+ chrome: NO_CHROME,
109
+ },
110
+ width,
111
+ );
112
+ });
113
+ }
114
+
115
+ it('long labels with full chrome at 320px', () => {
116
+ compileAndCheck(
117
+ {
118
+ mark: 'bar',
119
+ data: makeNominalData(LONG_LABELS),
120
+ encoding: {
121
+ x: { field: 'value', type: 'quantitative' },
122
+ y: { field: 'name', type: 'nominal' },
123
+ },
124
+ chrome: FULL_CHROME,
125
+ },
126
+ 320,
127
+ );
128
+ });
129
+
130
+ it('long labels with title only at 800px', () => {
131
+ compileAndCheck(
132
+ {
133
+ mark: 'bar',
134
+ data: makeNominalData(LONG_LABELS),
135
+ encoding: {
136
+ x: { field: 'value', type: 'quantitative' },
137
+ y: { field: 'name', type: 'nominal' },
138
+ },
139
+ chrome: TITLE_ONLY,
140
+ },
141
+ 800,
142
+ );
143
+ });
144
+ });
145
+
146
+ // -----------------------------------------------------------------------
147
+ // Column charts (vertical bars: x=nominal, y=quantitative)
148
+ // -----------------------------------------------------------------------
149
+ describe('column charts (vertical)', () => {
150
+ for (const width of WIDTHS) {
151
+ it(`short labels, title+subtitle at ${width}px`, () => {
152
+ compileAndCheck(
153
+ {
154
+ mark: 'bar',
155
+ data: makeNominalData(SHORT_LABELS),
156
+ encoding: {
157
+ x: { field: 'name', type: 'nominal' },
158
+ y: { field: 'value', type: 'quantitative' },
159
+ },
160
+ chrome: TITLE_ONLY,
161
+ },
162
+ width,
163
+ );
164
+ });
165
+ }
166
+
167
+ it('long labels with full chrome at 320px', () => {
168
+ compileAndCheck(
169
+ {
170
+ mark: 'bar',
171
+ data: makeNominalData(LONG_LABELS),
172
+ encoding: {
173
+ x: { field: 'name', type: 'nominal' },
174
+ y: { field: 'value', type: 'quantitative' },
175
+ },
176
+ chrome: FULL_CHROME,
177
+ },
178
+ 320,
179
+ );
180
+ });
181
+
182
+ // Rotated long labels overflow the SVG bottom edge
183
+ it.fails('long labels, no chrome at 800px', () => {
184
+ compileAndCheck(
185
+ {
186
+ mark: 'bar',
187
+ data: makeNominalData(LONG_LABELS),
188
+ encoding: {
189
+ x: { field: 'name', type: 'nominal' },
190
+ y: { field: 'value', type: 'quantitative' },
191
+ },
192
+ chrome: NO_CHROME,
193
+ },
194
+ 800,
195
+ );
196
+ });
197
+ });
198
+
199
+ // -----------------------------------------------------------------------
200
+ // Line charts (multi-series with legend variations)
201
+ // -----------------------------------------------------------------------
202
+ describe('line charts', () => {
203
+ const series = ['US', 'UK', 'DE'];
204
+ const data = makeTemporalData(series);
205
+
206
+ for (const width of WIDTHS) {
207
+ it(`multi-series with top legend at ${width}px`, () => {
208
+ compileAndCheck(
209
+ {
210
+ mark: 'line',
211
+ data,
212
+ encoding: {
213
+ x: { field: 'date', type: 'temporal' },
214
+ y: { field: 'value', type: 'quantitative' },
215
+ color: { field: 'series', type: 'nominal' },
216
+ },
217
+ legend: { position: 'top', show: true },
218
+ chrome: TITLE_ONLY,
219
+ },
220
+ width,
221
+ );
222
+ });
223
+ }
224
+
225
+ it('multi-series with bottom legend, full chrome at 800px', () => {
226
+ compileAndCheck(
227
+ {
228
+ mark: 'line',
229
+ data,
230
+ encoding: {
231
+ x: { field: 'date', type: 'temporal' },
232
+ y: { field: 'value', type: 'quantitative' },
233
+ color: { field: 'series', type: 'nominal' },
234
+ },
235
+ legend: { position: 'bottom', show: true },
236
+ chrome: FULL_CHROME,
237
+ },
238
+ 800,
239
+ );
240
+ });
241
+
242
+ it('multi-series with right legend, no chrome at 800px', () => {
243
+ compileAndCheck(
244
+ {
245
+ mark: 'line',
246
+ data,
247
+ encoding: {
248
+ x: { field: 'date', type: 'temporal' },
249
+ y: { field: 'value', type: 'quantitative' },
250
+ color: { field: 'series', type: 'nominal' },
251
+ },
252
+ legend: { position: 'right', show: true },
253
+ chrome: NO_CHROME,
254
+ },
255
+ 800,
256
+ );
257
+ });
258
+
259
+ it('multi-series with bottom-right legend at 320px', () => {
260
+ compileAndCheck(
261
+ {
262
+ mark: 'line',
263
+ data,
264
+ encoding: {
265
+ x: { field: 'date', type: 'temporal' },
266
+ y: { field: 'value', type: 'quantitative' },
267
+ color: { field: 'series', type: 'nominal' },
268
+ },
269
+ legend: { position: 'bottom-right', show: true },
270
+ chrome: TITLE_ONLY,
271
+ },
272
+ 320,
273
+ );
274
+ });
275
+
276
+ it('single series, full chrome at 320px', () => {
277
+ compileAndCheck(
278
+ {
279
+ mark: 'line',
280
+ data: makeTemporalData(['US']),
281
+ encoding: {
282
+ x: { field: 'date', type: 'temporal' },
283
+ y: { field: 'value', type: 'quantitative' },
284
+ },
285
+ chrome: FULL_CHROME,
286
+ },
287
+ 320,
288
+ );
289
+ });
290
+ });
291
+
292
+ // -----------------------------------------------------------------------
293
+ // Pie charts (arc mark: y=quantitative, color=nominal)
294
+ // -----------------------------------------------------------------------
295
+ describe('pie charts', () => {
296
+ for (const width of WIDTHS) {
297
+ it(`basic pie, title only at ${width}px`, () => {
298
+ compileAndCheck(
299
+ {
300
+ mark: 'arc',
301
+ data: makePieData(SHORT_LABELS),
302
+ encoding: {
303
+ y: { field: 'value', type: 'quantitative' },
304
+ color: { field: 'category', type: 'nominal' },
305
+ },
306
+ chrome: TITLE_ONLY,
307
+ },
308
+ width,
309
+ );
310
+ });
311
+ }
312
+
313
+ it('pie with bottom legend, full chrome at 800px', () => {
314
+ compileAndCheck(
315
+ {
316
+ mark: 'arc',
317
+ data: makePieData(SHORT_LABELS),
318
+ encoding: {
319
+ y: { field: 'value', type: 'quantitative' },
320
+ color: { field: 'category', type: 'nominal' },
321
+ },
322
+ legend: { position: 'bottom', show: true },
323
+ chrome: FULL_CHROME,
324
+ },
325
+ 800,
326
+ );
327
+ });
328
+
329
+ // Legend entry positions overflow bounds at narrow width with long labels
330
+ it.fails('pie with long labels, top legend at 320px', () => {
331
+ compileAndCheck(
332
+ {
333
+ mark: 'arc',
334
+ data: makePieData(LONG_LABELS),
335
+ encoding: {
336
+ y: { field: 'value', type: 'quantitative' },
337
+ color: { field: 'category', type: 'nominal' },
338
+ },
339
+ legend: { position: 'top', show: true },
340
+ chrome: TITLE_ONLY,
341
+ },
342
+ 320,
343
+ );
344
+ });
345
+
346
+ it('pie with no chrome at 800px', () => {
347
+ compileAndCheck(
348
+ {
349
+ mark: 'arc',
350
+ data: makePieData(SHORT_LABELS),
351
+ encoding: {
352
+ y: { field: 'value', type: 'quantitative' },
353
+ color: { field: 'category', type: 'nominal' },
354
+ },
355
+ chrome: NO_CHROME,
356
+ },
357
+ 800,
358
+ );
359
+ });
360
+ });
361
+
362
+ // -----------------------------------------------------------------------
363
+ // Scatter charts (point mark: x=quantitative, y=quantitative)
364
+ // -----------------------------------------------------------------------
365
+ describe('scatter charts', () => {
366
+ const groups = ['Group A', 'Group B', 'Group C'];
367
+
368
+ for (const width of WIDTHS) {
369
+ it(`multi-group with right legend at ${width}px`, () => {
370
+ compileAndCheck(
371
+ {
372
+ mark: 'point',
373
+ data: makeScatterData(groups),
374
+ encoding: {
375
+ x: { field: 'x', type: 'quantitative' },
376
+ y: { field: 'y', type: 'quantitative' },
377
+ color: { field: 'group', type: 'nominal' },
378
+ },
379
+ legend: { position: 'right', show: true },
380
+ chrome: TITLE_ONLY,
381
+ },
382
+ width,
383
+ );
384
+ });
385
+ }
386
+
387
+ it('multi-group with top legend, full chrome at 320px', () => {
388
+ compileAndCheck(
389
+ {
390
+ mark: 'point',
391
+ data: makeScatterData(groups),
392
+ encoding: {
393
+ x: { field: 'x', type: 'quantitative' },
394
+ y: { field: 'y', type: 'quantitative' },
395
+ color: { field: 'group', type: 'nominal' },
396
+ },
397
+ legend: { position: 'top', show: true },
398
+ chrome: FULL_CHROME,
399
+ },
400
+ 320,
401
+ );
402
+ });
403
+
404
+ it('single group, no chrome at 800px', () => {
405
+ compileAndCheck(
406
+ {
407
+ mark: 'point',
408
+ data: makeScatterData(['Only']),
409
+ encoding: {
410
+ x: { field: 'x', type: 'quantitative' },
411
+ y: { field: 'y', type: 'quantitative' },
412
+ },
413
+ chrome: NO_CHROME,
414
+ },
415
+ 800,
416
+ );
417
+ });
418
+
419
+ it('multi-group with bottom legend, full chrome at 800px', () => {
420
+ compileAndCheck(
421
+ {
422
+ mark: 'point',
423
+ data: makeScatterData(groups),
424
+ encoding: {
425
+ x: { field: 'x', type: 'quantitative' },
426
+ y: { field: 'y', type: 'quantitative' },
427
+ color: { field: 'group', type: 'nominal' },
428
+ },
429
+ legend: { position: 'bottom', show: true },
430
+ chrome: FULL_CHROME,
431
+ },
432
+ 800,
433
+ );
434
+ });
435
+ });
436
+
437
+ // -----------------------------------------------------------------------
438
+ // Dot charts (circle mark: x=quantitative, y=quantitative)
439
+ // -----------------------------------------------------------------------
440
+ describe('dot charts', () => {
441
+ for (const width of WIDTHS) {
442
+ it(`multi-group with bottom legend at ${width}px`, () => {
443
+ compileAndCheck(
444
+ {
445
+ mark: 'circle',
446
+ data: makeScatterData(['Alpha', 'Beta']),
447
+ encoding: {
448
+ x: { field: 'x', type: 'quantitative' },
449
+ y: { field: 'y', type: 'quantitative' },
450
+ color: { field: 'group', type: 'nominal' },
451
+ },
452
+ legend: { position: 'bottom', show: true },
453
+ chrome: TITLE_ONLY,
454
+ },
455
+ width,
456
+ );
457
+ });
458
+ }
459
+
460
+ it('dot with full chrome, long legend labels at 320px', () => {
461
+ compileAndCheck(
462
+ {
463
+ mark: 'circle',
464
+ data: makeScatterData(['Very Long Group Name A', 'Very Long Group Name B']),
465
+ encoding: {
466
+ x: { field: 'x', type: 'quantitative' },
467
+ y: { field: 'y', type: 'quantitative' },
468
+ color: { field: 'group', type: 'nominal' },
469
+ },
470
+ legend: { position: 'top', show: true },
471
+ chrome: FULL_CHROME,
472
+ },
473
+ 320,
474
+ );
475
+ });
476
+ });
477
+
478
+ // -----------------------------------------------------------------------
479
+ // Area charts (area mark, similar encoding to line)
480
+ // -----------------------------------------------------------------------
481
+ describe('area charts', () => {
482
+ const data = makeTemporalData(['Revenue', 'Expenses']);
483
+
484
+ it('stacked area with top legend, full chrome at 800px', () => {
485
+ compileAndCheck(
486
+ {
487
+ mark: 'area',
488
+ data,
489
+ encoding: {
490
+ x: { field: 'date', type: 'temporal' },
491
+ y: { field: 'value', type: 'quantitative' },
492
+ color: { field: 'series', type: 'nominal' },
493
+ },
494
+ legend: { position: 'top', show: true },
495
+ chrome: FULL_CHROME,
496
+ },
497
+ 800,
498
+ );
499
+ });
500
+
501
+ it('single series area, no chrome at 800px', () => {
502
+ compileAndCheck(
503
+ {
504
+ mark: 'area',
505
+ data: makeTemporalData(['Revenue']),
506
+ encoding: {
507
+ x: { field: 'date', type: 'temporal' },
508
+ y: { field: 'value', type: 'quantitative' },
509
+ },
510
+ chrome: NO_CHROME,
511
+ },
512
+ 800,
513
+ );
514
+ });
515
+
516
+ it('stacked area with bottom-right legend at 320px', () => {
517
+ compileAndCheck(
518
+ {
519
+ mark: 'area',
520
+ data,
521
+ encoding: {
522
+ x: { field: 'date', type: 'temporal' },
523
+ y: { field: 'value', type: 'quantitative' },
524
+ color: { field: 'series', type: 'nominal' },
525
+ },
526
+ legend: { position: 'bottom-right', show: true },
527
+ chrome: TITLE_ONLY,
528
+ },
529
+ 320,
530
+ );
531
+ });
532
+ });
533
+ });
@@ -511,4 +511,41 @@ describe('computeLegend', () => {
511
511
  expect(gap).toBe(0);
512
512
  });
513
513
  });
514
+
515
+ describe('entryPositions contract', () => {
516
+ it('emits entryPositions for horizontal legend', () => {
517
+ const legend = computeLegend(specWithColor, compactStrategy, theme, chartArea);
518
+ expect(legend.entries.length).toBeGreaterThan(0);
519
+ expect(legend.entryPositions).toBeDefined();
520
+ expect(legend.entryPositions).toHaveLength(legend.entries.length);
521
+ });
522
+
523
+ it('entryPositions have correct structure', () => {
524
+ const legend = computeLegend(specWithColor, compactStrategy, theme, chartArea);
525
+ for (const pos of legend.entryPositions!) {
526
+ expect(pos).toHaveProperty('x');
527
+ expect(pos).toHaveProperty('y');
528
+ expect(pos).toHaveProperty('labelX');
529
+ expect(pos).toHaveProperty('labelY');
530
+ expect(pos).toHaveProperty('width');
531
+ expect(pos).toHaveProperty('row');
532
+ expect(pos.labelX).toBeGreaterThan(pos.x);
533
+ }
534
+ });
535
+
536
+ it('emits entryPositions for right-positioned legend', () => {
537
+ const legend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
538
+ expect(legend.entryPositions).toBeDefined();
539
+ expect(legend.entryPositions).toHaveLength(legend.entries.length);
540
+ for (const pos of legend.entryPositions!) {
541
+ expect(pos.row).toBeGreaterThanOrEqual(0);
542
+ }
543
+ });
544
+
545
+ it('emits rowHeight', () => {
546
+ const legend = computeLegend(specWithColor, compactStrategy, theme, chartArea);
547
+ expect(legend.rowHeight).toBeDefined();
548
+ expect(legend.rowHeight).toBeGreaterThan(0);
549
+ });
550
+ });
514
551
  });
@@ -739,7 +739,6 @@ describe('computeAnnotations', () => {
739
739
  ]);
740
740
  const scales = computeScales(spec, chartArea, spec.data);
741
741
 
742
- const _px = scales.x?.scale(new Date('2020-01-01'));
743
742
  const py = scales.y?.scale(20);
744
743
  const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
745
744
 
@@ -1235,8 +1234,8 @@ describe('computeAnnotations', () => {
1235
1234
 
1236
1235
  // Curve should start from right edge of text, not top edge
1237
1236
  // Right edge x ≈ label.x + textWidth
1238
- // "Curve test" = 10 chars * 12 * 0.57 = 68.4
1239
- expect(connector.from.x).toBeCloseTo(label.x + 68.4, 1);
1237
+ // Per-char lookup: "Curve test" = 4.557 * 12 = 54.684
1238
+ expect(connector.from.x).toBeCloseTo(label.x + 54.684, 1);
1240
1239
  });
1241
1240
  });
1242
1241
 
@@ -1470,17 +1469,9 @@ describe('computeAnnotations', () => {
1470
1469
 
1471
1470
  expect(annotations).toHaveLength(1);
1472
1471
  const label = annotations[0].label!;
1473
- const fontSize = label.style.fontSize ?? 12;
1474
- const fontWeight = label.style.fontWeight ?? 400;
1475
- // Estimate bounds to verify the right edge is within SVG
1476
- const textWidth = label.text
1477
- .split('\n')
1478
- .reduce(
1479
- (max, line) => Math.max(max, line.length * fontSize * (fontWeight >= 600 ? 0.65 : 0.55)),
1480
- 0,
1481
- );
1482
- // The label's right edge should not exceed the SVG width
1483
- expect(label.x + textWidth).toBeLessThanOrEqual(svgDimensions.width);
1472
+ // Use engine-exported bounds instead of re-deriving text width
1473
+ expect(label.bounds).toBeDefined();
1474
+ expect(label.bounds!.x + label.bounds!.width).toBeLessThanOrEqual(svgDimensions.width);
1484
1475
  });
1485
1476
 
1486
1477
  it('shifts annotation down when it overflows the top SVG edge', () => {
@@ -1514,13 +1505,15 @@ describe('computeAnnotations', () => {
1514
1505
  });
1515
1506
 
1516
1507
  it('does not modify annotation that is well within bounds', () => {
1517
- // Place annotation in the center of the chart
1508
+ // Place annotation in the center of the chart with an explicit offset
1509
+ // so it uses the same explicit placement path with and without SVG dims
1518
1510
  const spec = makeSpec([
1519
1511
  {
1520
1512
  type: 'text',
1521
1513
  x: '2020-06-01',
1522
1514
  y: 25,
1523
1515
  text: 'Centered',
1516
+ offset: { dx: 0, dy: -10 },
1524
1517
  },
1525
1518
  ]);
1526
1519
  const scales = computeScales(spec, chartArea, spec.data);