@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
package/src/compile.ts CHANGED
@@ -13,18 +13,22 @@
13
13
 
14
14
  import type {
15
15
  AnimationSpec,
16
+ BinParams,
17
+ BinTransform,
16
18
  ChartLayout,
17
19
  ChartSpec,
18
20
  CompileOptions,
19
21
  CompileTableOptions,
22
+ EncodingChannel,
20
23
  LayerSpec,
21
24
  Mark,
22
- PointMark,
23
25
  Rect,
24
- RectMark,
25
26
  ResolvedAnnotation,
26
27
  ResolvedTheme,
27
28
  TableLayout,
29
+ TimeUnit,
30
+ TimeUnitTransform,
31
+ Transform,
28
32
  } from '@opendata-ai/openchart-core';
29
33
  import {
30
34
  adaptTheme,
@@ -43,6 +47,11 @@ import { columnRenderer } from './charts/column';
43
47
  import { dotRenderer } from './charts/dot';
44
48
  import { areaRenderer, lineRenderer } from './charts/line';
45
49
  import { donutRenderer, pieRenderer } from './charts/pie';
50
+ import {
51
+ assignAnimationIndices,
52
+ computeMarkObstacles,
53
+ resolveRendererKey,
54
+ } from './charts/post-process';
46
55
  import { type ChartRenderer, getChartRenderer, registerChartRenderer } from './charts/registry';
47
56
  import { ruleRenderer } from './charts/rule';
48
57
  import { scatterRenderer } from './charts/scatter';
@@ -90,7 +99,7 @@ import type { GraphCompilation } from './graphs/types';
90
99
  import { computeAxes } from './layout/axes';
91
100
  import { computeDimensions } from './layout/dimensions';
92
101
  import { computeGridlines } from './layout/gridlines';
93
- import { computeScales, type ResolvedScales } from './layout/scales';
102
+ import { computeScales } from './layout/scales';
94
103
  import { computeLegend } from './legend/compute';
95
104
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
96
105
  import { compileTableLayout } from './tables/compile-table';
@@ -98,93 +107,77 @@ import { computeTooltipDescriptors } from './tooltips/compute';
98
107
  import { runTransforms } from './transforms';
99
108
 
100
109
  // ---------------------------------------------------------------------------
101
- // Mark obstacles for annotation collision avoidance
110
+ // Encoding sugar expansion (bin, timeUnit on encoding channels)
102
111
  // ---------------------------------------------------------------------------
103
112
 
104
113
  /**
105
- * Compute bounding rects from marks to use as obstacles for annotation nudging.
114
+ * Expand encoding-level `bin` and `timeUnit` shorthand into explicit transforms.
106
115
  *
107
- * For band-scale charts (bar, dot): groups marks by band row and returns
108
- * a single obstacle per row spanning the full band height and x-range.
116
+ * Vega-Lite allows `encoding.x.bin: true` as sugar for a BinTransform.
117
+ * This function detects those shorthands, generates the corresponding transforms,
118
+ * updates encoding field references to the output field names, and prepends the
119
+ * transforms to the spec's transform array.
109
120
  *
110
- * For other charts (column, scatter): returns individual mark bounds so
111
- * annotations avoid overlapping any visible data mark.
121
+ * Mutates nothing; returns a new spec object (shallow copy).
112
122
  */
113
- function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
114
- // Band-scale y-axis: group marks by row for efficient obstacle computation
115
- if (scales.y?.type === 'band') {
116
- return computeBandRowObstacles(marks, scales);
117
- }
123
+ export function expandEncodingSugar(spec: Record<string, unknown>): Record<string, unknown> {
124
+ const encoding = spec.encoding as Record<string, EncodingChannel | undefined> | undefined;
125
+ if (!encoding) return spec;
126
+
127
+ const generatedTransforms: Transform[] = [];
128
+ const updatedEncoding = { ...encoding };
129
+ let changed = false;
130
+
131
+ for (const channel of Object.keys(encoding)) {
132
+ const ch = encoding[channel];
133
+ if (!ch || !ch.field) continue;
134
+
135
+ // Expand bin shorthand
136
+ if (ch.bin != null && ch.bin !== false) {
137
+ const field = ch.field;
138
+ const outputField = `bin_${field}`;
139
+ const binTransform: BinTransform = {
140
+ bin: ch.bin === true ? true : (ch.bin as BinParams),
141
+ field,
142
+ as: outputField,
143
+ };
144
+ generatedTransforms.push(binTransform);
118
145
 
119
- // All other charts: use individual rect/point mark bounds as obstacles
120
- const obstacles: Rect[] = [];
121
- for (const mark of marks) {
122
- if (mark.type === 'rect') {
123
- const rm = mark as RectMark;
124
- obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
125
- } else if (mark.type === 'point') {
126
- const pm = mark as PointMark;
127
- obstacles.push({
128
- x: pm.cx - pm.r,
129
- y: pm.cy - pm.r,
130
- width: pm.r * 2,
131
- height: pm.r * 2,
132
- });
146
+ // Update encoding to reference binned output field, remove bin property
147
+ const { bin: _bin, ...rest } = ch;
148
+ updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
149
+ changed = true;
133
150
  }
134
- }
135
- return obstacles;
136
- }
137
-
138
- /** Group band-scale marks by row, returning one obstacle per band. */
139
- function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
140
- const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
141
151
 
142
- for (const mark of marks) {
143
- let cy: number;
144
- let left: number;
145
- let right: number;
146
-
147
- if (mark.type === 'point') {
148
- const pm = mark as PointMark;
149
- cy = pm.cy;
150
- left = pm.cx - pm.r;
151
- right = pm.cx + pm.r;
152
- } else if (mark.type === 'rect') {
153
- const rm = mark as RectMark;
154
- cy = rm.y + rm.height / 2;
155
- left = rm.x;
156
- right = rm.x + rm.width;
157
- } else {
158
- continue;
159
- }
152
+ // Expand timeUnit shorthand (read from updated encoding in case bin already ran)
153
+ const current = updatedEncoding[channel] ?? ch;
154
+ if (current.timeUnit) {
155
+ const field = current.field;
156
+ const unit = current.timeUnit as TimeUnit;
157
+ const outputField = `${unit}_${field}`;
158
+ const timeUnitTransform: TimeUnitTransform = {
159
+ timeUnit: unit,
160
+ field,
161
+ as: outputField,
162
+ };
163
+ generatedTransforms.push(timeUnitTransform);
160
164
 
161
- // Round cy to group marks on the same band
162
- const key = Math.round(cy);
163
- const existing = rows.get(key);
164
- if (existing) {
165
- existing.minX = Math.min(existing.minX, left);
166
- existing.maxX = Math.max(existing.maxX, right);
167
- } else {
168
- rows.set(key, { minX: left, maxX: right, bandY: cy });
165
+ // Update encoding to reference timeUnit output field, remove timeUnit property
166
+ const { timeUnit: _tu, ...rest } = current;
167
+ updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
168
+ changed = true;
169
169
  }
170
170
  }
171
171
 
172
- // Get bandwidth from the band scale
173
- const bandScale = scales.y!.scale as { bandwidth?: () => number };
174
- const bandwidth = bandScale.bandwidth?.() ?? 0;
175
- if (bandwidth === 0) return [];
172
+ if (!changed) return spec;
176
173
 
177
- const obstacles: Rect[] = [];
178
- for (const { minX, maxX, bandY } of rows.values()) {
179
- obstacles.push({
180
- x: minX,
181
- y: bandY - bandwidth / 2,
182
- width: maxX - minX,
183
- height: bandwidth,
184
- });
185
- }
186
-
187
- return obstacles;
174
+ // Prepend generated transforms before any user-defined transforms
175
+ const existingTransforms = (spec.transform as Transform[] | undefined) ?? [];
176
+ return {
177
+ ...spec,
178
+ encoding: updatedEncoding,
179
+ transform: [...generatedTransforms, ...existingTransforms],
180
+ };
188
181
  }
189
182
 
190
183
  // ---------------------------------------------------------------------------
@@ -204,8 +197,15 @@ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[]
204
197
  * @throws Error if spec is invalid or not a chart type.
205
198
  */
206
199
  export function compileChart(spec: unknown, options: CompileOptions): ChartLayout {
200
+ // Expand encoding-level bin/timeUnit sugar before validation + normalization.
201
+ // This converts shorthand (e.g. encoding.x.bin: true) into explicit transforms.
202
+ const expandedSpec =
203
+ spec && typeof spec === 'object' && !Array.isArray(spec)
204
+ ? expandEncodingSugar(spec as Record<string, unknown>)
205
+ : spec;
206
+
207
207
  // Validate + normalize
208
- const { spec: normalized } = compileSpec(spec);
208
+ const { spec: normalized } = compileSpec(expandedSpec);
209
209
 
210
210
  if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
211
211
  throw new Error('compileChart received a table spec. Use compileTable instead.');
@@ -223,13 +223,14 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
223
223
  let chartSpec = normalized as NormalizedChartSpec;
224
224
 
225
225
  // Resolve watermark: explicit spec value wins, then options fallback, then default true.
226
- const rawWatermark = (spec as Record<string, unknown>).watermark;
226
+ const rawWatermark = (expandedSpec as Record<string, unknown>).watermark;
227
227
  const watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
228
228
 
229
229
  // Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
230
- // Transforms are defined on the original spec, not the normalized spec, since
231
- // NormalizedChartSpec doesn't carry the transform field.
232
- const rawTransforms = (spec as Record<string, unknown>).transform as
230
+ // Transforms are defined on the expanded spec (which includes any auto-generated
231
+ // transforms from encoding-level bin/timeUnit sugar), not the normalized spec,
232
+ // since NormalizedChartSpec doesn't carry the transform field.
233
+ const rawTransforms = (expandedSpec as Record<string, unknown>).transform as
233
234
  | import('@opendata-ai/openchart-core').Transform[]
234
235
  | undefined;
235
236
  if (rawTransforms && rawTransforms.length > 0) {
@@ -241,8 +242,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
241
242
  const heightClass = getHeightClass(options.height);
242
243
  const strategy = getLayoutStrategy(breakpoint, heightClass);
243
244
 
244
- // Apply breakpoint-conditional overrides from the original spec
245
- const rawSpec = spec as Record<string, unknown>;
245
+ // Apply breakpoint-conditional overrides from the expanded spec
246
+ const rawSpec = expandedSpec as Record<string, unknown>;
246
247
  const overrides = rawSpec.overrides as
247
248
  | Partial<
248
249
  Record<
@@ -408,26 +409,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
408
409
  }
409
410
 
410
411
  // Get chart renderer and compute marks (using filtered data).
411
- // For 'bar' mark, resolve orientation to pick horizontal vs vertical renderer.
412
- // For 'arc' mark, resolve innerRadius to pick pie vs donut renderer.
413
- let rendererKey = renderSpec.markType as string;
414
- if (rendererKey === 'bar') {
415
- // Infer orientation from encoding: if x is quantitative and y is categorical, horizontal (default)
416
- // If x is categorical and y is quantitative, vertical (old 'column')
417
- const xType = renderSpec.encoding.x?.type;
418
- const yType = renderSpec.encoding.y?.type;
419
- const isVertical =
420
- (xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
421
- yType === 'quantitative';
422
- if (isVertical) {
423
- rendererKey = 'bar:vertical';
424
- }
425
- } else if (rendererKey === 'arc') {
426
- const innerRadius = renderSpec.markDef.innerRadius;
427
- if (innerRadius && innerRadius > 0) {
428
- rendererKey = 'arc:donut';
429
- }
430
- }
412
+ const rendererKey = resolveRendererKey(
413
+ renderSpec.markType,
414
+ renderSpec.encoding,
415
+ renderSpec.markDef,
416
+ );
431
417
  const renderer = getChartRenderer(rendererKey);
432
418
  const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
433
419
 
@@ -491,44 +477,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
491
477
  );
492
478
 
493
479
  // Assign animationIndex for stagger ordering when animation is enabled
494
- // Assign animationIndex for value-based stagger ordering. Skip stacked rects
495
- // since they get group-based indices below (avoids wasted work that gets overwritten).
496
- if (resolvedAnimation?.enabled && resolvedAnimation.staggerOrder === 'value') {
497
- const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
498
- indexed.sort((a, b) => {
499
- const av = getMarkPrimaryValue(a.mark);
500
- const bv = getMarkPrimaryValue(b.mark);
501
- return av - bv;
502
- });
503
- for (let i = 0; i < indexed.length; i++) {
504
- const m = indexed[i].mark;
505
- if (m.type === 'rect' && (m as RectMark).stackGroup) continue;
506
- m.animationIndex = i;
507
- }
508
- }
509
-
510
- // For stacked bars/columns, assign the same animationIndex to all segments
511
- // sharing a stackGroup so they animate as one contiguous bar per category.
512
- // Also compute stackPos (segment position within each group: 0, 1, 2...)
513
- // so the renderer can chain segment animations sequentially.
514
- if (resolvedAnimation?.enabled) {
515
- const groupIndexMap = new Map<string, number>();
516
- const groupStackPos = new Map<string, number>();
517
- let nextGroupIndex = 0;
518
- for (const mark of marks) {
519
- if (mark.type === 'rect' && (mark as RectMark).stackGroup) {
520
- const rect = mark as RectMark;
521
- const group = rect.stackGroup!;
522
- if (!groupIndexMap.has(group)) {
523
- groupIndexMap.set(group, nextGroupIndex++);
524
- }
525
- rect.animationIndex = groupIndexMap.get(group)!;
526
- const pos = groupStackPos.get(group) ?? 0;
527
- rect.stackPos = pos;
528
- groupStackPos.set(group, pos + 1);
529
- }
530
- }
531
- }
480
+ assignAnimationIndices(marks, resolvedAnimation);
532
481
 
533
482
  return {
534
483
  area: chartArea,
@@ -554,26 +503,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
554
503
  },
555
504
  animation: resolvedAnimation,
556
505
  watermark,
506
+ measureText: options.measureText,
557
507
  };
558
508
  }
559
509
 
560
- /** Extract the primary quantitative value from a mark for value-based stagger ordering. */
561
- function getMarkPrimaryValue(mark: Mark): number {
562
- switch (mark.type) {
563
- case 'rect':
564
- return mark.height; // bar height is the primary value encoding
565
- case 'point':
566
- return mark.cy; // y position for scatter
567
- case 'arc':
568
- return mark.endAngle - mark.startAngle; // arc angle extent
569
- case 'line':
570
- case 'area':
571
- return 0; // series marks don't have individual values
572
- default:
573
- return 0;
574
- }
575
- }
576
-
577
510
  // ---------------------------------------------------------------------------
578
511
  // Layer compilation
579
512
  // ---------------------------------------------------------------------------
@@ -69,8 +69,8 @@ function normalizeChrome(chrome: Chrome | undefined): NormalizedChrome {
69
69
 
70
70
  /** Sample values from a data column and infer the field type. */
71
71
  function inferFieldType(data: DataRow[], field: string): FieldType {
72
- // Sample up to 10 rows
73
- const sampleSize = Math.min(10, data.length);
72
+ // Sample up to 50 rows for more reliable inference on mixed/messy data
73
+ const sampleSize = Math.min(50, data.length);
74
74
  let numericCount = 0;
75
75
  let dateCount = 0;
76
76
  let totalNonNull = 0;
@@ -38,8 +38,8 @@ import type {
38
38
 
39
39
  /** Base tick counts by axis label density. */
40
40
  const TICK_COUNTS: Record<AxisLabelDensity, number> = {
41
- full: 8,
42
- reduced: 5,
41
+ full: 10,
42
+ reduced: 7,
43
43
  minimal: 3,
44
44
  };
45
45
 
@@ -399,8 +399,7 @@ export function computeAxes(
399
399
 
400
400
  // Auto-rotate labels when band scale labels would overlap.
401
401
  // Uses max label width (not average) since one long label is enough to overlap.
402
- // Prefer labelAngle over deprecated tickAngle
403
- let tickAngle = axisConfig?.labelAngle ?? axisConfig?.tickAngle;
402
+ let tickAngle = axisConfig?.labelAngle;
404
403
  if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
405
404
  const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
406
405
  let maxLabelWidth = 0;
@@ -414,8 +413,7 @@ export function computeAxes(
414
413
  }
415
414
  }
416
415
 
417
- // Prefer title over deprecated label
418
- const axisTitle = axisConfig?.title ?? axisConfig?.label;
416
+ const axisTitle = axisConfig?.title;
419
417
 
420
418
  result.x = {
421
419
  ticks,
@@ -454,21 +452,20 @@ export function computeAxes(
454
452
  allTicks = continuousTicks(scales.y, yDensity);
455
453
  }
456
454
 
457
- // Gridlines use the full tick set (label thinning shouldn't remove gridlines).
458
- const gridlines: Gridline[] = allTicks.map((t) => ({
459
- position: t.position,
460
- major: true,
461
- }));
462
-
463
455
  // Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
464
456
  const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
465
457
  const ticks = shouldThin
466
458
  ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
467
459
  : allTicks;
468
460
 
469
- // Prefer title over deprecated label, labelAngle over deprecated tickAngle
470
- const axisTitle = axisConfig?.title ?? axisConfig?.label;
471
- const tickAngle = axisConfig?.labelAngle ?? axisConfig?.tickAngle;
461
+ // Gridlines match the tick set so every gridline has a label.
462
+ const gridlines: Gridline[] = ticks.map((t) => ({
463
+ position: t.position,
464
+ major: true,
465
+ }));
466
+
467
+ const axisTitle = axisConfig?.title;
468
+ const tickAngle = axisConfig?.labelAngle;
472
469
 
473
470
  result.y = {
474
471
  ticks,
@@ -125,9 +125,9 @@ export function computeDimensions(
125
125
  // Estimate x-axis height below chart area: tick labels sit 14px below,
126
126
  // axis title sits 35px below. These extend past the chart area bottom
127
127
  // and source/footer chrome must be positioned below them.
128
- const xAxis = encoding.x?.axis as (Record<string, unknown> & { tickAngle?: number }) | undefined;
129
- const hasXAxisLabel = !!xAxis?.label;
130
- const xTickAngle = xAxis?.tickAngle;
128
+ const xAxis = encoding.x?.axis as (Record<string, unknown> & { labelAngle?: number }) | undefined;
129
+ const hasXAxisLabel = !!xAxis?.title;
130
+ const xTickAngle = xAxis?.labelAngle;
131
131
 
132
132
  let xAxisHeight: number;
133
133
  if (isRadial) {