@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
@@ -3,13 +3,12 @@
3
3
  * optional callout connector to the data point.
4
4
  */
5
5
 
6
- import {
7
- estimateTextWidth,
8
- type Rect,
9
- type ResolvedAnnotation,
10
- type ResolvedLabel,
11
- type TextAnnotation,
12
- type TextStyle,
6
+ import type {
7
+ Rect,
8
+ ResolvedAnnotation,
9
+ ResolvedLabel,
10
+ TextAnnotation,
11
+ TextStyle,
13
12
  } from '@opendata-ai/openchart-core';
14
13
  import type { ResolvedScales } from '../layout/scales';
15
14
  import {
@@ -27,7 +26,15 @@ import {
27
26
  SUBTITLE_FONT_SIZE_RATIO,
28
27
  SUBTITLE_GAP,
29
28
  } from './constants';
30
- import { applyOffset, computeAnchorOffset, computeConnectorOrigin } from './geometry';
29
+ import {
30
+ type AnnotationMeasureTextFn,
31
+ applyOffset,
32
+ computeAnchorOffset,
33
+ computeConnectorOrigin,
34
+ computeTextBlockBounds,
35
+ heuristicMeasure,
36
+ unionRects,
37
+ } from './geometry';
31
38
  import { resolvePosition } from './position';
32
39
 
33
40
  /** Horizontal gap between the drop-line and the label text. */
@@ -59,6 +66,7 @@ export function resolveTextAnnotation(
59
66
  scales: ResolvedScales,
60
67
  chartArea: Rect,
61
68
  isDark: boolean,
69
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
62
70
  ): ResolvedAnnotation | null {
63
71
  const px = resolvePosition(annotation.x, scales.x);
64
72
  const py = resolvePosition(annotation.y, scales.y);
@@ -78,7 +86,23 @@ export function resolveTextAnnotation(
78
86
  // label sitting flush beside it. Auto-flips to the opposite side if the
79
87
  // chosen side would overflow the chart area.
80
88
  if (annotation.connector === 'drop-line') {
81
- return resolveDropLineAnnotation(annotation, px, py, chartArea, labelStyle, defaultTextFill);
89
+ return resolveDropLineAnnotation(
90
+ annotation,
91
+ px,
92
+ py,
93
+ chartArea,
94
+ labelStyle,
95
+ defaultTextFill,
96
+ measure,
97
+ );
98
+ }
99
+
100
+ // Multi-line non-drop-line: engine sets textAnchor to 'middle' so bounds
101
+ // and rendering derive from the same anchor (previously the renderer
102
+ // forced this override, causing bounds to lie).
103
+ const isMultiLine = annotation.text.includes('\n');
104
+ if (isMultiLine) {
105
+ labelStyle.textAnchor = 'middle';
82
106
  }
83
107
 
84
108
  // Compute position from anchor direction + user offset
@@ -130,6 +154,8 @@ export function resolveTextAnnotation(
130
154
  ? { x: adjustedToRaw.x - (cdx / dist) * GAP, y: adjustedToRaw.y - (cdy / dist) * GAP }
131
155
  : adjustedToRaw;
132
156
 
157
+ const labelBounds = computeTextBlockBounds(labelX, labelY, annotation.text, labelStyle, measure);
158
+
133
159
  const label: ResolvedLabel = {
134
160
  text: annotation.text,
135
161
  x: labelX,
@@ -147,6 +173,7 @@ export function resolveTextAnnotation(
147
173
  : undefined,
148
174
  background: annotation.background,
149
175
  halo: annotation.halo,
176
+ bounds: labelBounds,
150
177
  };
151
178
 
152
179
  // Resolve dot marker. Uses the connector's "to" endpoint coordinates
@@ -170,6 +197,7 @@ export function resolveTextAnnotation(
170
197
  // Resolve subtitle. Positioned below the primary text block by
171
198
  // (lineHeight * primaryLineCount * fontSize) + gap.
172
199
  let subtitle: ResolvedAnnotation['subtitle'] | undefined;
200
+ let annotationBounds: Rect = labelBounds;
173
201
  if (annotation.subtitle) {
174
202
  const primaryLineCount = annotation.text.split('\n').length;
175
203
  const subtitleFontSize = Math.round(fontSize * SUBTITLE_FONT_SIZE_RATIO);
@@ -187,6 +215,14 @@ export function resolveTextAnnotation(
187
215
  y: subtitleY,
188
216
  style: subtitleStyle,
189
217
  };
218
+ const subtitleBounds = computeTextBlockBounds(
219
+ labelX,
220
+ subtitleY,
221
+ annotation.subtitle,
222
+ subtitleStyle,
223
+ measure,
224
+ );
225
+ annotationBounds = unionRects(labelBounds, subtitleBounds);
190
226
  }
191
227
 
192
228
  return {
@@ -199,6 +235,7 @@ export function resolveTextAnnotation(
199
235
  zIndex: annotation.zIndex,
200
236
  dot,
201
237
  subtitle,
238
+ bounds: annotationBounds,
202
239
  };
203
240
  }
204
241
 
@@ -214,13 +251,14 @@ function resolveDropLineAnnotation(
214
251
  chartArea: Rect,
215
252
  labelStyle: TextStyle,
216
253
  defaultTextFill: string,
254
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
217
255
  ): ResolvedAnnotation {
218
256
  const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
219
257
  const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
220
258
  const lines = annotation.text.split('\n');
221
259
  const estimatedWidth = Math.max(
222
260
  0,
223
- ...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)),
261
+ ...lines.map((line) => measure(line, { fontSize, fontWeight })),
224
262
  );
225
263
 
226
264
  // Pick initial side from anchor; default to 'left' (label sits to the left
@@ -261,6 +299,14 @@ function resolveDropLineAnnotation(
261
299
  const from = { x: px, y: labelTopY - DROP_LINE_TOP_GAP };
262
300
  const to = { x: px, y: py - DROP_LINE_BOTTOM_GAP };
263
301
 
302
+ const labelBounds = computeTextBlockBounds(
303
+ labelX,
304
+ labelBaselineY,
305
+ annotation.text,
306
+ resolvedStyle,
307
+ measure,
308
+ );
309
+
264
310
  const label: ResolvedLabel = {
265
311
  text: annotation.text,
266
312
  x: labelX,
@@ -276,6 +322,7 @@ function resolveDropLineAnnotation(
276
322
  },
277
323
  background: annotation.background,
278
324
  halo: annotation.halo,
325
+ bounds: labelBounds,
279
326
  };
280
327
 
281
328
  return {
@@ -286,5 +333,6 @@ function resolveDropLineAnnotation(
286
333
  fill: annotation.fill,
287
334
  opacity: annotation.opacity,
288
335
  zIndex: annotation.zIndex,
336
+ bounds: labelBounds,
289
337
  };
290
338
  }
@@ -16,8 +16,8 @@ describe('computeMarkObstacles', () => {
16
16
  const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
17
17
  const result = computeMarkObstacles(marks, scales);
18
18
  expect(result).toHaveLength(2);
19
- expect(result[0]).toEqual({ x: 10, y: 20, width: 50, height: 30 });
20
- expect(result[1]).toEqual({ x: 80, y: 20, width: 50, height: 30 });
19
+ expect(result[0]).toEqual({ x: 10, y: 20, width: 50, height: 30, kind: 'mark' });
20
+ expect(result[1]).toEqual({ x: 80, y: 20, width: 50, height: 30, kind: 'mark' });
21
21
  });
22
22
 
23
23
  it('returns point mark bounds as bounding box from cx/cy/r', () => {
@@ -25,7 +25,7 @@ describe('computeMarkObstacles', () => {
25
25
  const scales = { y: { type: 'linear' } } as unknown as ResolvedScales;
26
26
  const result = computeMarkObstacles(marks, scales);
27
27
  expect(result).toHaveLength(1);
28
- expect(result[0]).toEqual({ x: 90, y: 90, width: 20, height: 20 });
28
+ expect(result[0]).toEqual({ x: 90, y: 90, width: 20, height: 20, kind: 'mark' });
29
29
  });
30
30
 
31
31
  it('returns grouped row obstacles for band-scale charts', () => {
@@ -490,6 +490,73 @@ describe('computeBarMarks', () => {
490
490
  });
491
491
  });
492
492
 
493
+ describe('x-domain excludes zero', () => {
494
+ it('simple bars anchor at the domain minimum, not xScale(0)', () => {
495
+ const spec = makeSimpleBarSpec();
496
+ (spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
497
+ const scales = computeScales(spec, chartArea, spec.data);
498
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
499
+
500
+ const xScale = scales.x!.scale as (v: number) => number;
501
+ const left = xScale(65);
502
+
503
+ // Every bar starts at the plot-area left edge (xScale of the domain min),
504
+ // not at the far-left extrapolation of xScale(0).
505
+ for (const mark of marks) {
506
+ expect(mark.x).toBeCloseTo(left, 5);
507
+ // Bars never bleed left of the plot area into the y-axis label gutter.
508
+ expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
509
+ }
510
+ // Guard against the old behavior: xScale(0) lands well left of the plot area.
511
+ expect(xScale(0)).toBeLessThan(chartArea.x);
512
+ });
513
+
514
+ it('grouped bars anchor at the domain minimum, not xScale(0)', () => {
515
+ const spec = makeGroupedBarSpec();
516
+ (spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
517
+ // Lift values into the [65, 95] window so widths stay positive.
518
+ spec.data = spec.data.map((d) => ({ ...d, value: 70 + (d.value as number) / 10 }));
519
+ const scales = computeScales(spec, chartArea, spec.data);
520
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
521
+
522
+ const xScale = scales.x!.scale as (v: number) => number;
523
+ const left = xScale(65);
524
+
525
+ for (const mark of marks) {
526
+ expect(mark.x).toBeCloseTo(left, 5);
527
+ expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
528
+ }
529
+ });
530
+ });
531
+
532
+ describe('x-domain includes zero (no regression)', () => {
533
+ it('simple bars still anchor at xScale(0) for a default [0, max] domain', () => {
534
+ const spec = makeSimpleBarSpec();
535
+ const scales = computeScales(spec, chartArea, spec.data);
536
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
537
+
538
+ const xScale = scales.x!.scale as (v: number) => number;
539
+ for (const mark of marks) {
540
+ expect(mark.x).toBeCloseTo(xScale(0), 5);
541
+ }
542
+ });
543
+
544
+ it('diverging bars (domain crosses zero) still anchor at xScale(0)', () => {
545
+ const spec = makeNegativeBarSpec();
546
+ const scales = computeScales(spec, chartArea, spec.data);
547
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
548
+
549
+ const xScale = scales.x!.scale as (v: number) => number;
550
+ const zero = xScale(0);
551
+
552
+ const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
553
+ const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
554
+ // Positive bar starts at zero; negative bar ends at zero.
555
+ expect(growth.x).toBeCloseTo(zero, 5);
556
+ expect(decline.x + decline.width).toBeCloseTo(zero, 5);
557
+ });
558
+ });
559
+
493
560
  describe('edge cases', () => {
494
561
  it('returns empty array when no x encoding', () => {
495
562
  const spec: NormalizedChartSpec = {
@@ -88,7 +88,14 @@ export function computeBarMarks(
88
88
  }
89
89
 
90
90
  const bandwidth = yScale.bandwidth();
91
- const baseline = xScale(0);
91
+ // Baseline = pixel x where the bar's left edge anchors. When the x-domain
92
+ // includes zero (the common case), this is xScale(0). When the domain
93
+ // excludes zero (explicit non-zero domain or scale.zero: false), xScale(0)
94
+ // would extrapolate outside the plot area and overrun the y-axis labels, so
95
+ // anchor to the domain minimum (the plot-area left edge) instead.
96
+ const xDomain = xScale.domain() as [number, number];
97
+ const xIncludesZero = xDomain[0] <= 0 && xDomain[1] >= 0;
98
+ const baseline = xIncludesZero ? xScale(0) : xScale(xDomain[0]);
92
99
  const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
93
100
  const conditionalColor =
94
101
  encoding.color && isConditionalValueDef(encoding.color)
@@ -5,14 +5,17 @@
5
5
  */
6
6
 
7
7
  import type {
8
+ AreaMark,
8
9
  Encoding,
10
+ LineMark,
9
11
  Mark,
10
12
  MarkDef,
11
13
  PointMark,
12
- Rect,
13
14
  RectMark,
14
15
  ResolvedAnimation,
15
16
  } from '@opendata-ai/openchart-core';
17
+ import type { PlacementObstacle } from '../annotations/placement';
18
+ import { sampleAreaObstacles, samplePolylineObstacles } from '../annotations/placement';
16
19
  import type { ResolvedScales } from '../layout/scales';
17
20
 
18
21
  // ---------------------------------------------------------------------------
@@ -28,18 +31,17 @@ import type { ResolvedScales } from '../layout/scales';
28
31
  * For other charts (column, scatter): returns individual mark bounds so
29
32
  * annotations avoid overlapping any visible data mark.
30
33
  */
31
- export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
34
+ export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): PlacementObstacle[] {
32
35
  // Band-scale y-axis: group marks by row for efficient obstacle computation
33
36
  if (scales.y?.type === 'band') {
34
37
  return computeBandRowObstacles(marks, scales);
35
38
  }
36
39
 
37
- // All other charts: use individual rect/point mark bounds as obstacles
38
- const obstacles: Rect[] = [];
40
+ const obstacles: PlacementObstacle[] = [];
39
41
  for (const mark of marks) {
40
42
  if (mark.type === 'rect') {
41
43
  const rm = mark as RectMark;
42
- obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
44
+ obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height, kind: 'mark' });
43
45
  } else if (mark.type === 'point') {
44
46
  const pm = mark as PointMark;
45
47
  obstacles.push({
@@ -47,14 +49,25 @@ export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rec
47
49
  y: pm.cy - pm.r,
48
50
  width: pm.r * 2,
49
51
  height: pm.r * 2,
52
+ kind: 'mark',
50
53
  });
54
+ } else if (mark.type === 'line') {
55
+ const lm = mark as LineMark;
56
+ if (lm.points.length > 0) {
57
+ obstacles.push(...samplePolylineObstacles(lm.points, 'line'));
58
+ }
59
+ } else if (mark.type === 'area') {
60
+ const am = mark as AreaMark;
61
+ if (am.topPoints.length > 0) {
62
+ obstacles.push(...sampleAreaObstacles(am.topPoints, am.bottomPoints));
63
+ }
51
64
  }
52
65
  }
53
66
  return obstacles;
54
67
  }
55
68
 
56
69
  /** Group band-scale marks by row, returning one obstacle per band. */
57
- function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
70
+ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): PlacementObstacle[] {
58
71
  const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
59
72
 
60
73
  for (const mark of marks) {
@@ -92,13 +105,14 @@ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[]
92
105
  const bandwidth = bandScale.bandwidth?.() ?? 0;
93
106
  if (bandwidth === 0) return [];
94
107
 
95
- const obstacles: Rect[] = [];
108
+ const obstacles: PlacementObstacle[] = [];
96
109
  for (const { minX, maxX, bandY } of rows.values()) {
97
110
  obstacles.push({
98
111
  x: minX,
99
112
  y: bandY - bandwidth / 2,
100
113
  width: maxX - minX,
101
114
  height: bandwidth,
115
+ kind: 'mark',
102
116
  });
103
117
  }
104
118
 
@@ -9,8 +9,10 @@ import type {
9
9
  } from '@opendata-ai/openchart-core';
10
10
  import {
11
11
  AXIS_TITLE_TRAILING_PAD,
12
+ abbreviateNumber,
12
13
  BREAKPOINT_COMPACT_MAX,
13
14
  estimateTextWidth,
15
+ formatNumber,
14
16
  getAxisTitleOffset,
15
17
  resolveTheme,
16
18
  TICK_LABEL_OFFSET,
@@ -19,6 +21,8 @@ import { format as d3Format } from 'd3-format';
19
21
  import { scaleLinear } from 'd3-scale';
20
22
  import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
21
23
  import { flattenLayers } from '../compiler/index';
24
+ import type { MeasureFn } from '../layout/plan';
25
+ import { createMeasureFn } from '../layout/plan';
22
26
 
23
27
  type ChartCompiler = (spec: unknown, options: CompileOptions) => ChartLayout;
24
28
 
@@ -100,18 +104,21 @@ function estimateYAxisLabelWidth(
100
104
  data: DataRow[],
101
105
  encoding: Encoding | undefined,
102
106
  baseFontSize: number,
107
+ measure?: MeasureFn,
103
108
  ): number {
104
109
  if (!encoding?.y) return 40;
105
110
  const yEnc = encoding.y;
106
111
  const yField = yEnc.field;
107
112
  if (!yField) return 40;
108
113
 
114
+ const m = measure ?? estimateTextWidth;
115
+
109
116
  const yType = yEnc.type;
110
117
  if (yType === 'nominal' || yType === 'ordinal') {
111
118
  let maxWidth = 0;
112
119
  for (const row of data) {
113
120
  const label = String(row[yField] ?? '');
114
- const w = estimateTextWidth(label, baseFontSize, 400);
121
+ const w = m(label, baseFontSize, 400);
115
122
  if (w > maxWidth) maxWidth = w;
116
123
  }
117
124
  return maxWidth > 0 ? maxWidth + 10 : 40;
@@ -134,16 +141,13 @@ function estimateYAxisLabelWidth(
134
141
  sampleLabel = String(maxAbsVal);
135
142
  }
136
143
  } else {
137
- if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
138
- else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
139
- else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
140
- else if (maxAbsVal >= 100) sampleLabel = '100';
141
- else if (maxAbsVal >= 10) sampleLabel = '10';
142
- else sampleLabel = '0.0';
144
+ // Use the same formatting as tick labels instead of hardcoded magnitude guesses
145
+ sampleLabel =
146
+ Math.abs(maxAbsVal) >= 1000 ? abbreviateNumber(maxAbsVal) : formatNumber(maxAbsVal);
143
147
  }
144
148
  const hasNeg = data.some((r) => Number(r[yField]) < 0);
145
149
  const labelEst = (hasNeg ? '-' : '') + sampleLabel;
146
- return estimateTextWidth(labelEst, baseFontSize, 400) + 10;
150
+ return m(labelEst, baseFontSize, 400) + 10;
147
151
  }
148
152
 
149
153
  function compileLayerIndependent(
@@ -173,7 +177,13 @@ function compileLayerIndependent(
173
177
 
174
178
  const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
175
179
  const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
176
- const rightAxisWidth = estimateYAxisLabelWidth(leaf1.data, leaf1.encoding, axisFontSize);
180
+ const measureFn = createMeasureFn(options.measureText);
181
+ const rightAxisWidth = estimateYAxisLabelWidth(
182
+ leaf1.data,
183
+ leaf1.encoding,
184
+ axisFontSize,
185
+ measureFn,
186
+ );
177
187
  const yAxisConfig = leaf1.encoding?.y?.axis || undefined;
178
188
  const hasRightAxisTitle = !!yAxisConfig?.title;
179
189
  const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
@@ -227,6 +237,18 @@ function compileLayerIndependent(
227
237
  x: layout0.area.x + layout0.area.width,
228
238
  y: layout0.area.y + layout0.area.height,
229
239
  },
240
+ ...(layout1.axes.y.label
241
+ ? {
242
+ titlePosition: {
243
+ x:
244
+ layout0.area.x +
245
+ layout0.area.width +
246
+ getAxisTitleOffset(layout0.dimensions.width),
247
+ y: layout0.area.y + layout0.area.height / 2,
248
+ angle: 90,
249
+ },
250
+ }
251
+ : {}),
230
252
  }
231
253
  : undefined;
232
254