@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
@@ -11,7 +11,12 @@
11
11
  * At compact breakpoints, annotations are simplified or hidden.
12
12
  */
13
13
 
14
- import type { LayoutStrategy, Rect, ResolvedAnnotation } from '@opendata-ai/openchart-core';
14
+ import type {
15
+ LayoutStrategy,
16
+ Rect,
17
+ ResolvedAnnotation,
18
+ TextAnnotation,
19
+ } from '@opendata-ai/openchart-core';
15
20
  import type { NormalizedChartSpec } from '../compiler/types';
16
21
  import type { ResolvedScales } from '../layout/scales';
17
22
  import {
@@ -19,9 +24,30 @@ import {
19
24
  nudgeAnnotationFromObstacles,
20
25
  resolveAnnotationCollisions,
21
26
  } from './collisions';
27
+ import { DEFAULT_ANNOTATION_FONT_WEIGHT, SUBTITLE_FONT_SIZE_RATIO } from './constants';
28
+ import type { AnnotationMeasureTextFn } from './geometry';
29
+ import { heuristicMeasure } from './geometry';
30
+ import {
31
+ findBestPlacement,
32
+ isAutoPlacement,
33
+ normalizeObstacles,
34
+ type PlacementObstacle,
35
+ } from './placement';
36
+ import { resolvePosition } from './position';
22
37
  import { resolveRangeAnnotation } from './resolve-range';
23
38
  import { resolveRefLineAnnotation } from './resolve-refline';
24
- import { resolveTextAnnotation } from './resolve-text';
39
+ import { makeAnnotationLabelStyle, resolveTextAnnotation } from './resolve-text';
40
+
41
+ export interface AnnotationContext {
42
+ scales: ResolvedScales;
43
+ chartArea: Rect;
44
+ strategy: LayoutStrategy;
45
+ isDark: boolean;
46
+ obstacles: PlacementObstacle[];
47
+ svg: { width: number; height: number };
48
+ measure: AnnotationMeasureTextFn;
49
+ debugPlacement?: boolean;
50
+ }
25
51
 
26
52
  /**
27
53
  * Compute resolved annotations from spec annotations using the resolved scales.
@@ -36,21 +62,65 @@ import { resolveTextAnnotation } from './resolve-text';
36
62
  * annotation collisions are resolved using a greedy placement algorithm.
37
63
  * Finally, labels are clamped to stay within the total SVG bounds.
38
64
  */
65
+ export function computeAnnotations(
66
+ spec: NormalizedChartSpec,
67
+ ctx: AnnotationContext,
68
+ ): ResolvedAnnotation[];
69
+
70
+ /**
71
+ * @deprecated Use the context-object overload instead.
72
+ */
39
73
  export function computeAnnotations(
40
74
  spec: NormalizedChartSpec,
41
75
  scales: ResolvedScales,
42
76
  chartArea: Rect,
43
77
  strategy: LayoutStrategy,
44
- isDark = false,
45
- obstacles: Rect[] = [],
78
+ isDark?: boolean,
79
+ obstacles?: Rect[],
80
+ svgDimensions?: { width: number; height: number },
81
+ ): ResolvedAnnotation[];
82
+
83
+ export function computeAnnotations(
84
+ spec: NormalizedChartSpec,
85
+ scalesOrCtx: ResolvedScales | AnnotationContext,
86
+ chartArea?: Rect,
87
+ strategy?: LayoutStrategy,
88
+ isDark?: boolean,
89
+ obstacles?: Rect[],
46
90
  svgDimensions?: { width: number; height: number },
47
91
  ): ResolvedAnnotation[] {
48
- const isCompact = strategy.annotationPosition === 'tooltip-only';
92
+ let ctx: AnnotationContext;
93
+
94
+ if ('measure' in scalesOrCtx) {
95
+ ctx = scalesOrCtx as AnnotationContext;
96
+ } else {
97
+ ctx = {
98
+ scales: scalesOrCtx as ResolvedScales,
99
+ chartArea: chartArea!,
100
+ strategy: strategy!,
101
+ isDark: isDark ?? false,
102
+ obstacles: obstacles ? normalizeObstacles(obstacles) : [],
103
+ svg: svgDimensions ?? { width: 0, height: 0 },
104
+ measure: heuristicMeasure,
105
+ };
106
+ }
49
107
 
108
+ const isCompact = ctx.strategy.annotationPosition === 'tooltip-only';
109
+
110
+ const svgRect: Rect = { x: 0, y: 0, width: ctx.svg.width, height: ctx.svg.height };
50
111
  const annotations: ResolvedAnnotation[] = [];
51
112
 
113
+ // Deferred auto-placement entries (resolved in Pass 2)
114
+ const autoQueue: Array<{
115
+ annotation: TextAnnotation;
116
+ anchorX: number;
117
+ anchorY: number;
118
+ resolved: ResolvedAnnotation;
119
+ index: number;
120
+ }> = [];
121
+
122
+ // ---- Pass 1: resolve explicit annotations and queue auto ones ----
52
123
  for (const annotation of spec.annotations) {
53
- // At compact breakpoints, skip annotations unless they opt out with responsive: false
54
124
  if (isCompact && annotation.responsive !== false) {
55
125
  continue;
56
126
  }
@@ -58,31 +128,202 @@ export function computeAnnotations(
58
128
 
59
129
  switch (annotation.type) {
60
130
  case 'text':
61
- resolved = resolveTextAnnotation(annotation, scales, chartArea, isDark);
131
+ resolved = resolveTextAnnotation(
132
+ annotation,
133
+ ctx.scales,
134
+ ctx.chartArea,
135
+ ctx.isDark,
136
+ ctx.measure,
137
+ );
138
+ if (resolved && ctx.svg.width > 0 && ctx.svg.height > 0 && isAutoPlacement(annotation)) {
139
+ const px = resolvePosition(annotation.x, ctx.scales.x);
140
+ const py = resolvePosition(annotation.y, ctx.scales.y);
141
+ if (px !== null && py !== null) {
142
+ autoQueue.push({
143
+ annotation,
144
+ anchorX: px,
145
+ anchorY: py,
146
+ resolved,
147
+ index: annotations.length,
148
+ });
149
+ annotations.push(resolved);
150
+ continue;
151
+ }
152
+ }
62
153
  break;
63
154
  case 'range':
64
- resolved = resolveRangeAnnotation(annotation, scales, chartArea, isDark);
155
+ resolved = resolveRangeAnnotation(annotation, ctx.scales, ctx.chartArea, ctx.isDark);
65
156
  break;
66
157
  case 'refline':
67
- resolved = resolveRefLineAnnotation(annotation, scales, chartArea, isDark);
158
+ resolved = resolveRefLineAnnotation(annotation, ctx.scales, ctx.chartArea, ctx.isDark);
68
159
  break;
69
160
  }
70
161
 
71
162
  if (resolved) {
72
- // For text annotations, check for collisions with obstacles and nudge if needed
73
- if (annotation.type === 'text' && obstacles.length > 0) {
74
- nudgeAnnotationFromObstacles(resolved, annotation, scales, chartArea, obstacles);
163
+ if (annotation.type === 'text' && ctx.obstacles.length > 0) {
164
+ nudgeAnnotationFromObstacles(
165
+ resolved,
166
+ annotation,
167
+ ctx.scales,
168
+ ctx.chartArea,
169
+ ctx.obstacles,
170
+ ctx.measure,
171
+ );
75
172
  }
76
173
  annotations.push(resolved);
77
174
  }
78
175
  }
79
176
 
80
- // Resolve annotation-to-annotation collisions (greedy, order-preserving)
81
- resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
177
+ // ---- Pass 2: scored placement search for auto annotations ----
178
+ if (autoQueue.length > 0 && ctx.svg.width > 0 && ctx.svg.height > 0) {
179
+ // Build the working obstacle list: base obstacles + explicitly placed annotations
180
+ const workingObstacles: PlacementObstacle[] = [...ctx.obstacles];
181
+ for (let i = 0; i < annotations.length; i++) {
182
+ const a = annotations[i];
183
+ if (a.bounds && !autoQueue.some((q) => q.index === i)) {
184
+ workingObstacles.push({ ...a.bounds, kind: 'annotation' } as PlacementObstacle);
185
+ }
186
+ }
187
+
188
+ for (const entry of autoQueue) {
189
+ const { annotation, anchorX, anchorY, resolved } = entry;
190
+ const labelStyle = makeAnnotationLabelStyle(
191
+ annotation.fontSize,
192
+ annotation.fontWeight,
193
+ undefined,
194
+ ctx.isDark,
195
+ );
196
+
197
+ const subtitleStyle = annotation.subtitle
198
+ ? {
199
+ fontSize: labelStyle.fontSize * SUBTITLE_FONT_SIZE_RATIO,
200
+ fontWeight: DEFAULT_ANNOTATION_FONT_WEIGHT,
201
+ lineHeight: labelStyle.lineHeight,
202
+ }
203
+ : undefined;
204
+
205
+ const result = findBestPlacement(
206
+ anchorX,
207
+ anchorY,
208
+ annotation.text,
209
+ {
210
+ fontSize: labelStyle.fontSize,
211
+ fontWeight: Number(labelStyle.fontWeight) || 400,
212
+ lineHeight: labelStyle.lineHeight,
213
+ },
214
+ annotation.subtitle,
215
+ subtitleStyle,
216
+ workingObstacles,
217
+ ctx.chartArea,
218
+ svgRect,
219
+ ctx.measure,
220
+ ctx.debugPlacement,
221
+ );
222
+
223
+ // Apply placement result to the resolved annotation
224
+ if (resolved.label) {
225
+ resolved.label.x = result.labelX;
226
+ resolved.label.y = result.labelY;
227
+ resolved.label.style = {
228
+ ...resolved.label.style,
229
+ textAnchor: result.textAnchor,
230
+ };
231
+ resolved.label.bounds = result.bounds;
232
+
233
+ if (resolved.label.connector) {
234
+ const box = result.bounds;
235
+ const cx = box.x + box.width / 2;
236
+ const cy = box.y + box.height / 2;
237
+ const isCurve = resolved.label.connector.style === 'curve';
238
+ if (isCurve) {
239
+ resolved.label.connector.from = { x: box.x + box.width, y: cy };
240
+ } else {
241
+ const halfW = box.width / 2 || 1;
242
+ const halfH = box.height / 2 || 1;
243
+ const ndx = (anchorX - cx) / halfW;
244
+ const ndy = (anchorY - cy) / halfH;
245
+ if (Math.abs(ndy) >= Math.abs(ndx)) {
246
+ resolved.label.connector.from = {
247
+ x: cx,
248
+ y: ndy < 0 ? box.y : box.y + box.height,
249
+ };
250
+ } else {
251
+ resolved.label.connector.from = {
252
+ x: ndx < 0 ? box.x : box.x + box.width,
253
+ y: cy,
254
+ };
255
+ }
256
+ }
257
+ }
258
+ }
259
+ resolved.bounds = result.bounds;
260
+
261
+ // Add this annotation's bounds as obstacle for subsequent auto annotations
262
+ workingObstacles.push({ ...result.bounds, kind: 'annotation' } as PlacementObstacle);
263
+ }
264
+ } else if (autoQueue.length > 0) {
265
+ // Fallback: no SVG dimensions, use legacy nudge for auto annotations
266
+ for (const entry of autoQueue) {
267
+ if (ctx.obstacles.length > 0) {
268
+ nudgeAnnotationFromObstacles(
269
+ entry.resolved,
270
+ entry.annotation,
271
+ ctx.scales,
272
+ ctx.chartArea,
273
+ ctx.obstacles,
274
+ ctx.measure,
275
+ );
276
+ }
277
+ }
278
+ }
279
+
280
+ // Resolve annotation-to-annotation collisions for non-auto-placed annotations.
281
+ // When auto placement ran, those annotations already avoid each other via the
282
+ // obstacle-accumulation loop above, so only explicitly placed ones need this.
283
+ if (autoQueue.length > 0) {
284
+ // Build aligned pairs of explicit annotations + their specs
285
+ const autoIndices = new Set(autoQueue.map((q) => q.index));
286
+ const explicitAnnotations: ResolvedAnnotation[] = [];
287
+ const explicitSpecs: NormalizedChartSpec['annotations'] = [];
288
+ let specIdx = 0;
289
+ for (let i = 0; i < annotations.length; i++) {
290
+ // Walk specIdx past any compact-skipped specs
291
+ while (specIdx < spec.annotations.length) {
292
+ const s = spec.annotations[specIdx];
293
+ if (!isCompact || s.responsive === false) break;
294
+ specIdx++;
295
+ }
296
+ if (specIdx >= spec.annotations.length) break;
297
+ if (autoIndices.has(i)) {
298
+ specIdx++;
299
+ continue;
300
+ }
301
+ explicitAnnotations.push(annotations[i]);
302
+ explicitSpecs.push(spec.annotations[specIdx]);
303
+ specIdx++;
304
+ }
305
+ if (explicitAnnotations.length > 1) {
306
+ resolveAnnotationCollisions(
307
+ explicitAnnotations,
308
+ explicitSpecs,
309
+ ctx.scales,
310
+ ctx.chartArea,
311
+ ctx.measure,
312
+ );
313
+ }
314
+ } else {
315
+ resolveAnnotationCollisions(
316
+ annotations,
317
+ spec.annotations,
318
+ ctx.scales,
319
+ ctx.chartArea,
320
+ ctx.measure,
321
+ );
322
+ }
82
323
 
83
324
  // Clamp labels that overflow the SVG boundary back inside
84
- if (svgDimensions) {
85
- clampAnnotationsToBounds(annotations, svgDimensions.width, svgDimensions.height);
325
+ if (ctx.svg.width > 0 && ctx.svg.height > 0) {
326
+ clampAnnotationsToBounds(annotations, ctx.svg.width, ctx.svg.height, ctx.measure);
86
327
  }
87
328
 
88
329
  // Sort by zIndex (lower first, undefined treated as 0)
@@ -16,28 +16,64 @@ import {
16
16
  DEFAULT_LINE_HEIGHT,
17
17
  } from './constants';
18
18
 
19
+ export type AnnotationMeasureTextFn = (
20
+ text: string,
21
+ font: { fontSize: number; fontWeight: number; fontFamily?: string },
22
+ ) => number;
23
+
24
+ export const heuristicMeasure: AnnotationMeasureTextFn = (text, { fontSize, fontWeight }) =>
25
+ estimateTextWidth(text, fontSize, fontWeight);
26
+
19
27
  /**
20
- * Compute the bounding box of annotation text at a given label position.
21
- * Multi-line text is centered at labelX; single-line starts at labelX.
28
+ * Compute the bounding box of a text block, aware of textAnchor and multi-line layout.
29
+ * labelY is the first-line baseline.
22
30
  */
23
- export function computeTextBounds(
31
+ export function computeTextBlockBounds(
24
32
  labelX: number,
25
33
  labelY: number,
26
34
  text: string,
27
- fontSize: number,
28
- fontWeight: number,
35
+ style: {
36
+ fontSize: number;
37
+ fontWeight: number;
38
+ lineHeight: number;
39
+ textAnchor?: 'start' | 'middle' | 'end';
40
+ },
41
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
29
42
  ): Rect {
30
43
  const lines = text.split('\n');
31
- const isMultiLine = lines.length > 1;
32
- const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
33
- const totalHeight = lines.length * fontSize * DEFAULT_LINE_HEIGHT;
34
- const x = isMultiLine ? labelX - maxWidth / 2 : labelX;
44
+ const maxWidth = Math.max(
45
+ ...lines.map((line) =>
46
+ measure(line, { fontSize: style.fontSize, fontWeight: style.fontWeight }),
47
+ ),
48
+ );
49
+ const height =
50
+ style.fontSize + (lines.length - 1) * style.fontSize * style.lineHeight + style.fontSize * 0.3;
51
+
52
+ let x: number;
53
+ if (style.textAnchor === 'middle') {
54
+ x = labelX - maxWidth / 2;
55
+ } else if (style.textAnchor === 'end') {
56
+ x = labelX - maxWidth;
57
+ } else {
58
+ x = labelX;
59
+ }
35
60
 
36
61
  return {
37
62
  x,
38
- y: labelY - fontSize,
63
+ y: labelY - style.fontSize,
39
64
  width: maxWidth,
40
- height: totalHeight,
65
+ height,
66
+ };
67
+ }
68
+
69
+ export function unionRects(a: Rect, b: Rect): Rect {
70
+ const x = Math.min(a.x, b.x);
71
+ const y = Math.min(a.y, b.y);
72
+ return {
73
+ x,
74
+ y,
75
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
76
+ height: Math.max(a.y + a.height, b.y + b.height) - y,
41
77
  };
42
78
  }
43
79
 
@@ -98,7 +134,15 @@ export function computeConnectorOrigin(
98
134
  targetY: number,
99
135
  connectorStyle: 'straight' | 'curve',
100
136
  ): { x: number; y: number } {
101
- const box = computeTextBounds(labelX, labelY, text, fontSize, fontWeight);
137
+ const lines = text.split('\n');
138
+ const isMultiLine = lines.length > 1;
139
+ const anchor: 'start' | 'middle' = isMultiLine ? 'middle' : 'start';
140
+ const box = computeTextBlockBounds(labelX, labelY, text, {
141
+ fontSize,
142
+ fontWeight,
143
+ lineHeight: DEFAULT_LINE_HEIGHT,
144
+ textAnchor: anchor,
145
+ });
102
146
  const boxCenterX = box.x + box.width / 2;
103
147
  const boxCenterY = box.y + box.height / 2;
104
148
 
@@ -130,11 +174,25 @@ export function computeConnectorOrigin(
130
174
  : { x: box.x + box.width, y: boxCenterY }; // right
131
175
  }
132
176
 
133
- /** Estimate the bounding box of an annotation label. */
134
- export function estimateLabelBounds(label: ResolvedLabel): Rect {
177
+ /** Estimate the bounding box of an annotation label using its resolved style. */
178
+ export function estimateLabelBounds(
179
+ label: ResolvedLabel,
180
+ measure: AnnotationMeasureTextFn = heuristicMeasure,
181
+ ): Rect {
135
182
  const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
136
183
  const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
137
- return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
184
+ return computeTextBlockBounds(
185
+ label.x,
186
+ label.y,
187
+ label.text,
188
+ {
189
+ fontSize,
190
+ fontWeight,
191
+ lineHeight: label.style.lineHeight ?? DEFAULT_LINE_HEIGHT,
192
+ textAnchor: label.style.textAnchor ?? 'start',
193
+ },
194
+ measure,
195
+ );
138
196
  }
139
197
 
140
198
  /**