@opendata-ai/openchart-engine 6.11.0 → 6.13.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 (45) hide show
  1. package/dist/index.d.ts +7 -0
  2. package/dist/index.js +944 -629
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +3 -0
  6. package/src/__tests__/axes.test.ts +12 -30
  7. package/src/__tests__/compile-chart.test.ts +4 -4
  8. package/src/__tests__/dimensions.test.ts +2 -2
  9. package/src/__tests__/encoding-sugar.test.ts +389 -0
  10. package/src/annotations/collisions.ts +268 -0
  11. package/src/annotations/compute.ts +9 -912
  12. package/src/annotations/constants.ts +32 -0
  13. package/src/annotations/geometry.ts +167 -0
  14. package/src/annotations/position.ts +95 -0
  15. package/src/annotations/resolve-range.ts +98 -0
  16. package/src/annotations/resolve-refline.ts +148 -0
  17. package/src/annotations/resolve-text.ts +134 -0
  18. package/src/charts/__tests__/post-process.test.ts +258 -0
  19. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  20. package/src/charts/bar/compute.ts +27 -6
  21. package/src/charts/bar/labels.ts +7 -1
  22. package/src/charts/column/__tests__/compute.test.ts +99 -0
  23. package/src/charts/column/compute.ts +27 -6
  24. package/src/charts/line/area.ts +19 -2
  25. package/src/charts/post-process.ts +215 -0
  26. package/src/compile.ts +113 -169
  27. package/src/compiler/__tests__/normalize.test.ts +110 -0
  28. package/src/compiler/normalize.ts +22 -3
  29. package/src/compiler/types.ts +4 -0
  30. package/src/graphs/compile-graph.ts +8 -0
  31. package/src/graphs/types.ts +2 -0
  32. package/src/layout/axes.ts +10 -13
  33. package/src/layout/dimensions.ts +6 -3
  34. package/src/layout/scales.ts +106 -29
  35. package/src/legend/compute.ts +3 -1
  36. package/src/sankey/compile-sankey.ts +12 -2
  37. package/src/sankey/types.ts +1 -0
  38. package/src/tables/compile-table.ts +5 -0
  39. package/src/tooltips/__tests__/compute.test.ts +188 -0
  40. package/src/tooltips/compute.ts +25 -11
  41. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  42. package/src/transforms/__tests__/fold.test.ts +79 -0
  43. package/src/transforms/aggregate.ts +130 -0
  44. package/src/transforms/fold.ts +49 -0
  45. package/src/transforms/index.ts +8 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Mark post-processing: obstacle computation, renderer dispatch, and
3
+ * animation index assignment. These are mark-type-aware operations that
4
+ * run after the chart renderer has produced marks.
5
+ */
6
+
7
+ import type {
8
+ Encoding,
9
+ Mark,
10
+ MarkDef,
11
+ PointMark,
12
+ Rect,
13
+ RectMark,
14
+ ResolvedAnimation,
15
+ } from '@opendata-ai/openchart-core';
16
+ import type { ResolvedScales } from '../layout/scales';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Mark obstacles for annotation collision avoidance
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Compute bounding rects from marks to use as obstacles for annotation nudging.
24
+ *
25
+ * For band-scale charts (bar, dot): groups marks by band row and returns
26
+ * a single obstacle per row spanning the full band height and x-range.
27
+ *
28
+ * For other charts (column, scatter): returns individual mark bounds so
29
+ * annotations avoid overlapping any visible data mark.
30
+ */
31
+ export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
32
+ // Band-scale y-axis: group marks by row for efficient obstacle computation
33
+ if (scales.y?.type === 'band') {
34
+ return computeBandRowObstacles(marks, scales);
35
+ }
36
+
37
+ // All other charts: use individual rect/point mark bounds as obstacles
38
+ const obstacles: Rect[] = [];
39
+ for (const mark of marks) {
40
+ if (mark.type === 'rect') {
41
+ const rm = mark as RectMark;
42
+ obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
43
+ } else if (mark.type === 'point') {
44
+ const pm = mark as PointMark;
45
+ obstacles.push({
46
+ x: pm.cx - pm.r,
47
+ y: pm.cy - pm.r,
48
+ width: pm.r * 2,
49
+ height: pm.r * 2,
50
+ });
51
+ }
52
+ }
53
+ return obstacles;
54
+ }
55
+
56
+ /** Group band-scale marks by row, returning one obstacle per band. */
57
+ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
58
+ const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
59
+
60
+ for (const mark of marks) {
61
+ let cy: number;
62
+ let left: number;
63
+ let right: number;
64
+
65
+ if (mark.type === 'point') {
66
+ const pm = mark as PointMark;
67
+ cy = pm.cy;
68
+ left = pm.cx - pm.r;
69
+ right = pm.cx + pm.r;
70
+ } else if (mark.type === 'rect') {
71
+ const rm = mark as RectMark;
72
+ cy = rm.y + rm.height / 2;
73
+ left = rm.x;
74
+ right = rm.x + rm.width;
75
+ } else {
76
+ continue;
77
+ }
78
+
79
+ // Round cy to group marks on the same band
80
+ const key = Math.round(cy);
81
+ const existing = rows.get(key);
82
+ if (existing) {
83
+ existing.minX = Math.min(existing.minX, left);
84
+ existing.maxX = Math.max(existing.maxX, right);
85
+ } else {
86
+ rows.set(key, { minX: left, maxX: right, bandY: cy });
87
+ }
88
+ }
89
+
90
+ // Get bandwidth from the band scale
91
+ const bandScale = scales.y!.scale as { bandwidth?: () => number };
92
+ const bandwidth = bandScale.bandwidth?.() ?? 0;
93
+ if (bandwidth === 0) return [];
94
+
95
+ const obstacles: Rect[] = [];
96
+ for (const { minX, maxX, bandY } of rows.values()) {
97
+ obstacles.push({
98
+ x: minX,
99
+ y: bandY - bandwidth / 2,
100
+ width: maxX - minX,
101
+ height: bandwidth,
102
+ });
103
+ }
104
+
105
+ return obstacles;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Renderer key dispatch
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Resolve the renderer key from mark type, encoding, and mark definition.
114
+ *
115
+ * - 'bar' -> 'bar' (horizontal) or 'bar:vertical' based on encoding axis types
116
+ * - 'arc' -> 'arc' (pie) or 'arc:donut' based on innerRadius
117
+ * - All other mark types pass through unchanged
118
+ */
119
+ export function resolveRendererKey(
120
+ markType: string,
121
+ encoding: Partial<Encoding>,
122
+ markDef: Partial<MarkDef>,
123
+ ): string {
124
+ if (markType === 'bar') {
125
+ const xType = encoding.x?.type;
126
+ const yType = encoding.y?.type;
127
+ const isVertical =
128
+ (xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
129
+ yType === 'quantitative';
130
+ if (isVertical) {
131
+ return 'bar:vertical';
132
+ }
133
+ } else if (markType === 'arc') {
134
+ const innerRadius = markDef.innerRadius;
135
+ if (innerRadius && innerRadius > 0) {
136
+ return 'arc:donut';
137
+ }
138
+ }
139
+ return markType;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Animation index assignment
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** Extract the primary quantitative value from a mark for value-based stagger ordering. */
147
+ function getMarkPrimaryValue(mark: Mark): number {
148
+ switch (mark.type) {
149
+ case 'rect':
150
+ return mark.height; // bar height is the primary value encoding
151
+ case 'point':
152
+ return mark.cy; // y position for scatter
153
+ case 'arc':
154
+ return mark.endAngle - mark.startAngle; // arc angle extent
155
+ case 'line':
156
+ case 'area':
157
+ return 0; // series marks don't have individual values
158
+ default:
159
+ return 0;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Assign animation indices to marks for stagger ordering.
165
+ *
166
+ * Two phases:
167
+ * 1. Value-based stagger: sorts marks by primary value, assigns sequential indices.
168
+ * Skips stacked rects since they get group-based indices in phase 2.
169
+ * 2. Stack-based stagger: groups marks by stackGroup, assigns the same index to
170
+ * all segments in a group, and computes stackPos (segment position: 0, 1, 2...).
171
+ * This intentionally overwrites any value-based indices for stacked marks.
172
+ */
173
+ export function assignAnimationIndices(
174
+ marks: Mark[],
175
+ animation: ResolvedAnimation | undefined,
176
+ ): void {
177
+ if (!animation?.enabled) return;
178
+
179
+ // Phase 1: Value-based stagger ordering. Skip stacked rects
180
+ // since they get group-based indices below (avoids wasted work that gets overwritten).
181
+ if (animation.staggerOrder === 'value') {
182
+ const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
183
+ indexed.sort((a, b) => {
184
+ const av = getMarkPrimaryValue(a.mark);
185
+ const bv = getMarkPrimaryValue(b.mark);
186
+ return av - bv;
187
+ });
188
+ for (let i = 0; i < indexed.length; i++) {
189
+ const m = indexed[i].mark;
190
+ if (m.type === 'rect' && (m as RectMark).stackGroup) continue;
191
+ m.animationIndex = i;
192
+ }
193
+ }
194
+
195
+ // Phase 2: For stacked bars/columns, assign the same animationIndex to all segments
196
+ // sharing a stackGroup so they animate as one contiguous bar per category.
197
+ // Also compute stackPos (segment position within each group: 0, 1, 2...)
198
+ // so the renderer can chain segment animations sequentially.
199
+ const groupIndexMap = new Map<string, number>();
200
+ const groupStackPos = new Map<string, number>();
201
+ let nextGroupIndex = 0;
202
+ for (const mark of marks) {
203
+ if (mark.type === 'rect' && (mark as RectMark).stackGroup) {
204
+ const rect = mark as RectMark;
205
+ const group = rect.stackGroup!;
206
+ if (!groupIndexMap.has(group)) {
207
+ groupIndexMap.set(group, nextGroupIndex++);
208
+ }
209
+ rect.animationIndex = groupIndexMap.get(group)!;
210
+ const pos = groupStackPos.get(group) ?? 0;
211
+ rect.stackPos = pos;
212
+ groupStackPos.set(group, pos + 1);
213
+ }
214
+ }
215
+ }
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
151
 
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
-
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.');
@@ -222,10 +222,15 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
222
222
 
223
223
  let chartSpec = normalized as NormalizedChartSpec;
224
224
 
225
+ // Resolve watermark: explicit spec value wins, then options fallback, then default true.
226
+ const rawWatermark = (expandedSpec as Record<string, unknown>).watermark;
227
+ const watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
228
+
225
229
  // Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
226
- // Transforms are defined on the original spec, not the normalized spec, since
227
- // NormalizedChartSpec doesn't carry the transform field.
228
- 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
229
234
  | import('@opendata-ai/openchart-core').Transform[]
230
235
  | undefined;
231
236
  if (rawTransforms && rawTransforms.length > 0) {
@@ -237,8 +242,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
237
242
  const heightClass = getHeightClass(options.height);
238
243
  const strategy = getLayoutStrategy(breakpoint, heightClass);
239
244
 
240
- // Apply breakpoint-conditional overrides from the original spec
241
- const rawSpec = spec as Record<string, unknown>;
245
+ // Apply breakpoint-conditional overrides from the expanded spec
246
+ const rawSpec = expandedSpec as Record<string, unknown>;
242
247
  const overrides = rawSpec.overrides as
243
248
  | Partial<
244
249
  Record<
@@ -306,10 +311,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
306
311
  width: options.width,
307
312
  height: options.height,
308
313
  };
309
- const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
314
+ const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea, watermark);
310
315
 
311
316
  // Compute dimensions (accounts for chrome + legend + responsive strategy)
312
- const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
317
+ const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy, watermark);
313
318
  const chartArea = dims.chartArea;
314
319
 
315
320
  // Recompute legend bounds relative to actual chart area.
@@ -332,7 +337,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
332
337
  break;
333
338
  }
334
339
  }
335
- const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea);
340
+ const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea, watermark);
336
341
 
337
342
  // Apply data filtering after legend (so legend retains all series), but before
338
343
  // scale computation (so hidden/clipped data doesn't affect domains or marks).
@@ -404,26 +409,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
404
409
  }
405
410
 
406
411
  // Get chart renderer and compute marks (using filtered data).
407
- // For 'bar' mark, resolve orientation to pick horizontal vs vertical renderer.
408
- // For 'arc' mark, resolve innerRadius to pick pie vs donut renderer.
409
- let rendererKey = renderSpec.markType as string;
410
- if (rendererKey === 'bar') {
411
- // Infer orientation from encoding: if x is quantitative and y is categorical, horizontal (default)
412
- // If x is categorical and y is quantitative, vertical (old 'column')
413
- const xType = renderSpec.encoding.x?.type;
414
- const yType = renderSpec.encoding.y?.type;
415
- const isVertical =
416
- (xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
417
- yType === 'quantitative';
418
- if (isVertical) {
419
- rendererKey = 'bar:vertical';
420
- }
421
- } else if (rendererKey === 'arc') {
422
- const innerRadius = renderSpec.markDef.innerRadius;
423
- if (innerRadius && innerRadius > 0) {
424
- rendererKey = 'arc:donut';
425
- }
426
- }
412
+ const rendererKey = resolveRendererKey(
413
+ renderSpec.markType,
414
+ renderSpec.encoding,
415
+ renderSpec.markDef,
416
+ );
427
417
  const renderer = getChartRenderer(rendererKey);
428
418
  const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
429
419
 
@@ -444,14 +434,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
444
434
  // Add brand watermark as an obstacle so annotations avoid overlapping it.
445
435
  // The brand is right-aligned on the same baseline as the first bottom chrome element,
446
436
  // offset below the chart area by x-axis extent (tick labels + axis title).
447
- const brandPadding = theme.spacing.padding;
448
- const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
449
- const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
450
- const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
451
- const brandY = firstBottomChrome
452
- ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
453
- : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
454
- obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
437
+ if (watermark) {
438
+ const brandPadding = theme.spacing.padding;
439
+ const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
440
+ const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
441
+ const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
442
+ const brandY = firstBottomChrome
443
+ ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
444
+ : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
445
+ obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
446
+ }
455
447
  const annotations: ResolvedAnnotation[] = computeAnnotations(
456
448
  chartSpec,
457
449
  scales,
@@ -485,44 +477,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
485
477
  );
486
478
 
487
479
  // Assign animationIndex for stagger ordering when animation is enabled
488
- // Assign animationIndex for value-based stagger ordering. Skip stacked rects
489
- // since they get group-based indices below (avoids wasted work that gets overwritten).
490
- if (resolvedAnimation?.enabled && resolvedAnimation.staggerOrder === 'value') {
491
- const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
492
- indexed.sort((a, b) => {
493
- const av = getMarkPrimaryValue(a.mark);
494
- const bv = getMarkPrimaryValue(b.mark);
495
- return av - bv;
496
- });
497
- for (let i = 0; i < indexed.length; i++) {
498
- const m = indexed[i].mark;
499
- if (m.type === 'rect' && (m as RectMark).stackGroup) continue;
500
- m.animationIndex = i;
501
- }
502
- }
503
-
504
- // For stacked bars/columns, assign the same animationIndex to all segments
505
- // sharing a stackGroup so they animate as one contiguous bar per category.
506
- // Also compute stackPos (segment position within each group: 0, 1, 2...)
507
- // so the renderer can chain segment animations sequentially.
508
- if (resolvedAnimation?.enabled) {
509
- const groupIndexMap = new Map<string, number>();
510
- const groupStackPos = new Map<string, number>();
511
- let nextGroupIndex = 0;
512
- for (const mark of marks) {
513
- if (mark.type === 'rect' && (mark as RectMark).stackGroup) {
514
- const rect = mark as RectMark;
515
- const group = rect.stackGroup!;
516
- if (!groupIndexMap.has(group)) {
517
- groupIndexMap.set(group, nextGroupIndex++);
518
- }
519
- rect.animationIndex = groupIndexMap.get(group)!;
520
- const pos = groupStackPos.get(group) ?? 0;
521
- rect.stackPos = pos;
522
- groupStackPos.set(group, pos + 1);
523
- }
524
- }
525
- }
480
+ assignAnimationIndices(marks, resolvedAnimation);
526
481
 
527
482
  return {
528
483
  area: chartArea,
@@ -547,26 +502,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
547
502
  height: options.height,
548
503
  },
549
504
  animation: resolvedAnimation,
505
+ watermark,
550
506
  };
551
507
  }
552
508
 
553
- /** Extract the primary quantitative value from a mark for value-based stagger ordering. */
554
- function getMarkPrimaryValue(mark: Mark): number {
555
- switch (mark.type) {
556
- case 'rect':
557
- return mark.height; // bar height is the primary value encoding
558
- case 'point':
559
- return mark.cy; // y position for scatter
560
- case 'arc':
561
- return mark.endAngle - mark.startAngle; // arc angle extent
562
- case 'line':
563
- case 'area':
564
- return 0; // series marks don't have individual values
565
- default:
566
- return 0;
567
- }
568
- }
569
-
570
509
  // ---------------------------------------------------------------------------
571
510
  // Layer compilation
572
511
  // ---------------------------------------------------------------------------
@@ -656,6 +595,7 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
656
595
  responsive: layerSpec.responsive ?? leaves[0].responsive,
657
596
  theme: layerSpec.theme ?? leaves[0].theme,
658
597
  darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
598
+ watermark: layerSpec.watermark ?? leaves[0].watermark,
659
599
  hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
660
600
  };
661
601
 
@@ -697,7 +637,11 @@ export function compileTable(spec: unknown, options: CompileTableOptions): Table
697
637
  theme = adaptTheme(theme);
698
638
  }
699
639
 
700
- return compileTableLayout(tableSpec, options, theme);
640
+ // Resolve watermark: spec-level wins, then options, then default true
641
+ const rawWatermark = (spec as Record<string, unknown>).watermark;
642
+ const watermark = rawWatermark !== undefined ? tableSpec.watermark : (options.watermark ?? true);
643
+
644
+ return compileTableLayout({ ...tableSpec, watermark }, options, theme);
701
645
  }
702
646
 
703
647
  // ---------------------------------------------------------------------------