@opendata-ai/openchart-engine 3.0.0 → 6.1.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 (65) hide show
  1. package/dist/index.d.ts +155 -19
  2. package/dist/index.js +1513 -164
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -3
  6. package/src/__tests__/axes.test.ts +168 -4
  7. package/src/__tests__/compile-chart.test.ts +23 -12
  8. package/src/__tests__/compile-layer.test.ts +386 -0
  9. package/src/__tests__/dimensions.test.ts +6 -3
  10. package/src/__tests__/legend.test.ts +6 -3
  11. package/src/__tests__/scales.test.ts +176 -2
  12. package/src/annotations/__tests__/compute.test.ts +8 -4
  13. package/src/charts/bar/__tests__/compute.test.ts +12 -6
  14. package/src/charts/bar/compute.ts +21 -5
  15. package/src/charts/column/__tests__/compute.test.ts +14 -7
  16. package/src/charts/column/compute.ts +21 -6
  17. package/src/charts/dot/__tests__/compute.test.ts +10 -5
  18. package/src/charts/dot/compute.ts +10 -4
  19. package/src/charts/line/__tests__/compute.test.ts +102 -11
  20. package/src/charts/line/__tests__/curves.test.ts +51 -0
  21. package/src/charts/line/__tests__/labels.test.ts +2 -1
  22. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  23. package/src/charts/line/area.ts +19 -8
  24. package/src/charts/line/compute.ts +64 -25
  25. package/src/charts/line/curves.ts +40 -0
  26. package/src/charts/pie/__tests__/compute.test.ts +10 -5
  27. package/src/charts/pie/compute.ts +2 -1
  28. package/src/charts/rule/index.ts +127 -0
  29. package/src/charts/scatter/__tests__/compute.test.ts +10 -5
  30. package/src/charts/scatter/compute.ts +15 -5
  31. package/src/charts/text/index.ts +92 -0
  32. package/src/charts/tick/index.ts +84 -0
  33. package/src/charts/utils.ts +1 -1
  34. package/src/compile.ts +175 -23
  35. package/src/compiler/__tests__/compile.test.ts +4 -4
  36. package/src/compiler/__tests__/normalize.test.ts +4 -4
  37. package/src/compiler/__tests__/validate.test.ts +25 -26
  38. package/src/compiler/index.ts +1 -1
  39. package/src/compiler/normalize.ts +77 -4
  40. package/src/compiler/types.ts +6 -2
  41. package/src/compiler/validate.ts +167 -35
  42. package/src/graphs/__tests__/compile-graph.test.ts +2 -2
  43. package/src/graphs/compile-graph.ts +2 -2
  44. package/src/index.ts +17 -1
  45. package/src/layout/axes.ts +122 -20
  46. package/src/layout/dimensions.ts +15 -9
  47. package/src/layout/scales.ts +320 -31
  48. package/src/legend/compute.ts +9 -6
  49. package/src/tables/__tests__/compile-table.test.ts +1 -1
  50. package/src/tooltips/__tests__/compute.test.ts +10 -5
  51. package/src/tooltips/compute.ts +32 -14
  52. package/src/transforms/__tests__/bin.test.ts +88 -0
  53. package/src/transforms/__tests__/calculate.test.ts +146 -0
  54. package/src/transforms/__tests__/conditional.test.ts +109 -0
  55. package/src/transforms/__tests__/filter.test.ts +59 -0
  56. package/src/transforms/__tests__/index.test.ts +93 -0
  57. package/src/transforms/__tests__/predicates.test.ts +176 -0
  58. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  59. package/src/transforms/bin.ts +87 -0
  60. package/src/transforms/calculate.ts +60 -0
  61. package/src/transforms/conditional.ts +46 -0
  62. package/src/transforms/filter.ts +17 -0
  63. package/src/transforms/index.ts +48 -0
  64. package/src/transforms/predicates.ts +90 -0
  65. package/src/transforms/timeunit.ts +88 -0
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
20
20
 
21
21
  function makeBasicScatterSpec(): NormalizedChartSpec {
22
22
  return {
23
- type: 'scatter',
23
+ markType: 'point',
24
+ markDef: { type: 'point' },
24
25
  data: [
25
26
  { x: 10, y: 20 },
26
27
  { x: 30, y: 50 },
@@ -43,7 +44,8 @@ function makeBasicScatterSpec(): NormalizedChartSpec {
43
44
 
44
45
  function makeBubbleSpec(): NormalizedChartSpec {
45
46
  return {
46
- type: 'scatter',
47
+ markType: 'point',
48
+ markDef: { type: 'point' },
47
49
  data: [
48
50
  { gdp: 10, life: 60, population: 1000 },
49
51
  { gdp: 30, life: 70, population: 5000 },
@@ -66,7 +68,8 @@ function makeBubbleSpec(): NormalizedChartSpec {
66
68
 
67
69
  function makeColoredScatterSpec(): NormalizedChartSpec {
68
70
  return {
69
- type: 'scatter',
71
+ markType: 'point',
72
+ markDef: { type: 'point' },
70
73
  data: [
71
74
  { x: 10, y: 20, group: 'A' },
72
75
  { x: 30, y: 50, group: 'A' },
@@ -202,7 +205,8 @@ describe('computeScatterMarks', () => {
202
205
  describe('edge cases', () => {
203
206
  it('returns empty array when no x encoding', () => {
204
207
  const spec: NormalizedChartSpec = {
205
- type: 'scatter',
208
+ markType: 'point',
209
+ markDef: { type: 'point' },
206
210
  data: [{ y: 10 }],
207
211
  encoding: {
208
212
  y: { field: 'y', type: 'quantitative' },
@@ -221,7 +225,8 @@ describe('computeScatterMarks', () => {
221
225
 
222
226
  it('skips rows with non-finite values', () => {
223
227
  const spec: NormalizedChartSpec = {
224
- type: 'scatter',
228
+ markType: 'point',
229
+ markDef: { type: 'point' },
225
230
  data: [
226
231
  { x: 10, y: 20 },
227
232
  { x: NaN, y: 30 },
@@ -20,7 +20,7 @@ import { scaleSqrt } from 'd3-scale';
20
20
 
21
21
  import type { NormalizedChartSpec } from '../../compiler/types';
22
22
  import type { ResolvedScales } from '../../layout/scales';
23
- import { getColor } from '../utils';
23
+ import { getColor, getSequentialColor } from '../utils';
24
24
 
25
25
  // ---------------------------------------------------------------------------
26
26
  // Constants
@@ -58,8 +58,10 @@ export function computeScatterMarks(
58
58
  const xScale = scales.x.scale as ScaleLinear<number, number>;
59
59
  const yScale = scales.y.scale as ScaleLinear<number, number>;
60
60
 
61
- const colorField = encoding.color?.field;
62
- const sizeField = encoding.size?.field;
61
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
62
+ const isSequentialColor = colorEnc?.type === 'quantitative';
63
+ const colorField = colorEnc?.field;
64
+ const sizeField = encoding.size && 'field' in encoding.size ? encoding.size.field : undefined;
63
65
 
64
66
  // Build a size scale for bubble variant
65
67
  let sizeScale: ((v: number) => number) | undefined;
@@ -85,8 +87,16 @@ export function computeScatterMarks(
85
87
  const cx = xScale(xVal);
86
88
  const cy = yScale(yVal);
87
89
 
88
- const category = colorField ? String(row[colorField] ?? '') : undefined;
89
- const color = getColor(scales, category ?? '__default__');
90
+ const category = colorField && !isSequentialColor ? String(row[colorField] ?? '') : undefined;
91
+ let color: string;
92
+ if (isSequentialColor && colorField) {
93
+ const val = Number(row[colorField]);
94
+ color = Number.isFinite(val)
95
+ ? getSequentialColor(scales, val)
96
+ : getColor(scales, '__default__');
97
+ } else {
98
+ color = getColor(scales, category ?? '__default__');
99
+ }
90
100
 
91
101
  let radius = DEFAULT_POINT_RADIUS;
92
102
  if (sizeScale && sizeField) {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Text mark renderer.
3
+ *
4
+ * Computes TextMarkLayout marks from a normalized chart spec.
5
+ * Positions text at data coordinates using x/y encoding channels,
6
+ * with content from the text encoding channel.
7
+ */
8
+
9
+ import type { Encoding, Mark, MarkAria, TextMarkLayout } from '@opendata-ai/openchart-core';
10
+
11
+ import type { NormalizedChartSpec } from '../../compiler/types';
12
+ import type { ResolvedScales } from '../../layout/scales';
13
+ import type { ChartRenderer } from '../registry';
14
+ import { getColor, scaleValue } from '../utils';
15
+
16
+ /**
17
+ * Compute text marks from spec data and resolved scales.
18
+ */
19
+ export function computeTextMarks(
20
+ spec: NormalizedChartSpec,
21
+ scales: ResolvedScales,
22
+ ): TextMarkLayout[] {
23
+ const encoding = spec.encoding as Encoding;
24
+ const xChannel = encoding.x;
25
+ const yChannel = encoding.y;
26
+ const textChannel = encoding.text;
27
+
28
+ if (!textChannel || !('field' in textChannel)) return [];
29
+
30
+ const marks: TextMarkLayout[] = [];
31
+ const colorEncoding = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
32
+ const colorField = colorEncoding?.field;
33
+ const sizeEncoding = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
34
+
35
+ for (const row of spec.data) {
36
+ // Resolve x position (center of chart if no x encoding)
37
+ let x = 0;
38
+ if (xChannel && scales.x) {
39
+ const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
40
+ if (xVal == null) continue;
41
+ x = xVal;
42
+ }
43
+
44
+ // Resolve y position (center of chart if no y encoding)
45
+ let y = 0;
46
+ if (yChannel && scales.y) {
47
+ const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
48
+ if (yVal == null) continue;
49
+ y = yVal;
50
+ }
51
+
52
+ const text = String(row[textChannel.field] ?? '');
53
+ if (!text) continue;
54
+
55
+ const color = colorField
56
+ ? getColor(scales, String(row[colorField] ?? '__default__'))
57
+ : getColor(scales, '__default__');
58
+
59
+ const fontSize = sizeEncoding
60
+ ? Math.max(8, Math.min(48, Number(row[sizeEncoding.field]) || 12))
61
+ : 12;
62
+
63
+ const aria: MarkAria = {
64
+ label: text,
65
+ };
66
+
67
+ marks.push({
68
+ type: 'textMark',
69
+ x,
70
+ y,
71
+ text,
72
+ fill: color,
73
+ fontSize,
74
+ textAnchor: 'middle',
75
+ angle:
76
+ encoding.angle && 'field' in encoding.angle
77
+ ? Number(row[encoding.angle.field]) || 0
78
+ : undefined,
79
+ data: row as Record<string, unknown>,
80
+ aria,
81
+ });
82
+ }
83
+
84
+ return marks;
85
+ }
86
+
87
+ /**
88
+ * Text chart renderer.
89
+ */
90
+ export const textRenderer: ChartRenderer = (spec, scales, _chartArea, _strategy, _theme) => {
91
+ return computeTextMarks(spec, scales) as Mark[];
92
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tick mark renderer.
3
+ *
4
+ * Computes TickMarkLayout marks from a normalized chart spec.
5
+ * Ticks are short line segments used for strip/rug plots.
6
+ * Each data point produces a small tick perpendicular to the data axis.
7
+ */
8
+
9
+ import type { Encoding, Mark, MarkAria, Rect, TickMarkLayout } from '@opendata-ai/openchart-core';
10
+
11
+ import type { NormalizedChartSpec } from '../../compiler/types';
12
+ import type { ResolvedScales } from '../../layout/scales';
13
+ import type { ChartRenderer } from '../registry';
14
+ import { getColor, scaleValue } from '../utils';
15
+
16
+ /** Default tick length in pixels. */
17
+ const DEFAULT_TICK_LENGTH = 18;
18
+
19
+ /**
20
+ * Compute tick marks from spec data and resolved scales.
21
+ *
22
+ * Orientation is inferred from the encoding:
23
+ * - If x is quantitative and y is categorical (or vice versa), ticks are
24
+ * perpendicular to the quantitative axis.
25
+ * - Default: vertical ticks (short vertical lines at each x position).
26
+ */
27
+ export function computeTickMarks(
28
+ spec: NormalizedChartSpec,
29
+ scales: ResolvedScales,
30
+ _chartArea: Rect,
31
+ ): TickMarkLayout[] {
32
+ const encoding = spec.encoding as Encoding;
33
+ const xChannel = encoding.x;
34
+ const yChannel = encoding.y;
35
+
36
+ if (!xChannel || !yChannel || !scales.x || !scales.y) return [];
37
+
38
+ const colorEncoding = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
39
+ const colorField = colorEncoding?.field;
40
+ const marks: TickMarkLayout[] = [];
41
+
42
+ // Determine orientation: ticks are perpendicular to the quantitative axis
43
+ const isHorizontal = xChannel.type === 'quantitative' && yChannel.type !== 'quantitative';
44
+ const orient: 'horizontal' | 'vertical' = isHorizontal ? 'horizontal' : 'vertical';
45
+
46
+ for (const row of spec.data) {
47
+ const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
48
+ const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
49
+ if (xVal == null || yVal == null) continue;
50
+
51
+ const color = colorField
52
+ ? getColor(scales, String(row[colorField] ?? '__default__'))
53
+ : getColor(scales, '__default__');
54
+
55
+ const aria: MarkAria = {
56
+ label: `${row[xChannel.field]}, ${row[yChannel.field]}`,
57
+ };
58
+
59
+ marks.push({
60
+ type: 'tick',
61
+ x: xVal,
62
+ y: yVal,
63
+ length: DEFAULT_TICK_LENGTH,
64
+ orient,
65
+ stroke: color,
66
+ strokeWidth: 1,
67
+ opacity:
68
+ encoding.opacity && 'field' in encoding.opacity
69
+ ? Math.max(0, Math.min(1, Number(row[encoding.opacity.field]) || 1))
70
+ : undefined,
71
+ data: row as Record<string, unknown>,
72
+ aria,
73
+ });
74
+ }
75
+
76
+ return marks;
77
+ }
78
+
79
+ /**
80
+ * Tick chart renderer.
81
+ */
82
+ export const tickRenderer: ChartRenderer = (spec, scales, chartArea, _strategy, _theme) => {
83
+ return computeTickMarks(spec, scales, chartArea) as Mark[];
84
+ };
@@ -32,7 +32,7 @@ export const DEFAULT_COLOR = '#1b7fa3';
32
32
  export function scaleValue(scale: D3Scale, scaleType: string, value: unknown): number | null {
33
33
  if (value == null) return null;
34
34
 
35
- if (scaleType === 'time') {
35
+ if (scaleType === 'time' || scaleType === 'utc') {
36
36
  const date = value instanceof Date ? value : new Date(String(value));
37
37
  if (Number.isNaN(date.getTime())) return null;
38
38
  return (scale as ScaleTime<number, number>)(date);
package/src/compile.ts CHANGED
@@ -13,8 +13,10 @@
13
13
 
14
14
  import type {
15
15
  ChartLayout,
16
+ ChartSpec,
16
17
  CompileOptions,
17
18
  CompileTableOptions,
19
+ LayerSpec,
18
20
  Mark,
19
21
  PointMark,
20
22
  Rect,
@@ -41,21 +43,39 @@ import { dotRenderer } from './charts/dot';
41
43
  import { areaRenderer, lineRenderer } from './charts/line';
42
44
  import { donutRenderer, pieRenderer } from './charts/pie';
43
45
  import { type ChartRenderer, getChartRenderer, registerChartRenderer } from './charts/registry';
46
+ import { ruleRenderer } from './charts/rule';
44
47
  import { scatterRenderer } from './charts/scatter';
45
- import { compile as compileSpec } from './compiler/index';
46
-
47
- // Register all built-in chart renderers. Explicit imports ensure bundlers
48
- // cannot tree-shake the registrations away (bare side-effect imports are
49
- // treated as dead code by esbuild).
48
+ import { textRenderer } from './charts/text';
49
+ import { tickRenderer } from './charts/tick';
50
+ import { compile as compileSpec, flattenLayers } from './compiler/index';
51
+
52
+ // Register all built-in chart renderers under the new Vega-Lite mark type names.
53
+ // Explicit imports ensure bundlers cannot tree-shake the registrations away.
54
+ //
55
+ // Mark type mapping from old chart types:
56
+ // - 'bar' -> barRenderer (horizontal bars, old 'bar')
57
+ // - 'bar:vertical' is handled by columnRenderer (old 'column')
58
+ // - 'arc' -> pieRenderer (old 'pie'); donutRenderer is also registered
59
+ // - 'point' -> scatterRenderer (old 'scatter')
60
+ // - 'circle' -> dotRenderer (old 'dot')
61
+ // - 'line' and 'area' unchanged
62
+ // - 'text', 'rule', 'tick' are new Vega-Lite mark types
63
+ //
64
+ // For 'bar', orientation is resolved at compile time to dispatch to the right renderer.
65
+ // We register both barRenderer and columnRenderer; the compile function picks based on orientation.
50
66
  const builtinRenderers: Record<string, ChartRenderer> = {
51
67
  line: lineRenderer,
52
68
  area: areaRenderer,
53
- bar: barRenderer,
54
- column: columnRenderer,
55
- scatter: scatterRenderer,
56
- pie: pieRenderer,
57
- donut: donutRenderer,
58
- dot: dotRenderer,
69
+ bar: barRenderer, // horizontal bars
70
+ 'bar:vertical': columnRenderer, // vertical bars (old 'column')
71
+ point: scatterRenderer, // old 'scatter'
72
+ arc: pieRenderer, // old 'pie' (donut handled via innerRadius)
73
+ 'arc:donut': donutRenderer, // old 'donut'
74
+ circle: dotRenderer, // old 'dot'
75
+ text: textRenderer,
76
+ rule: ruleRenderer,
77
+ tick: tickRenderer,
78
+ rect: columnRenderer, // rect uses column renderer (RectMark output) as baseline for heatmaps
59
79
  };
60
80
  for (const [type, renderer] of Object.entries(builtinRenderers)) {
61
81
  registerChartRenderer(type, renderer);
@@ -71,6 +91,7 @@ import { computeScales, type ResolvedScales } from './layout/scales';
71
91
  import { computeLegend } from './legend/compute';
72
92
  import { compileTableLayout } from './tables/compile-table';
73
93
  import { computeTooltipDescriptors } from './tooltips/compute';
94
+ import { runTransforms } from './transforms';
74
95
 
75
96
  // ---------------------------------------------------------------------------
76
97
  // Mark obstacles for annotation collision avoidance
@@ -182,15 +203,25 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
182
203
  // Validate + normalize
183
204
  const { spec: normalized } = compileSpec(spec);
184
205
 
185
- if (normalized.type === 'table') {
206
+ if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
186
207
  throw new Error('compileChart received a table spec. Use compileTable instead.');
187
208
  }
188
- if (normalized.type === 'graph') {
209
+ if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'graph') {
189
210
  throw new Error('compileChart received a graph spec. Use compileGraph instead.');
190
211
  }
191
212
 
192
213
  let chartSpec = normalized as NormalizedChartSpec;
193
214
 
215
+ // Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
216
+ // Transforms are defined on the original spec, not the normalized spec, since
217
+ // NormalizedChartSpec doesn't carry the transform field.
218
+ const rawTransforms = (spec as Record<string, unknown>).transform as
219
+ | import('@opendata-ai/openchart-core').Transform[]
220
+ | undefined;
221
+ if (rawTransforms && rawTransforms.length > 0) {
222
+ chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
223
+ }
224
+
194
225
  // Responsive strategy
195
226
  const breakpoint = getBreakpoint(options.width);
196
227
  const heightClass = getHeightClass(options.height);
@@ -292,7 +323,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
292
323
  let renderData = chartSpec.data;
293
324
 
294
325
  // Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
295
- if (chartSpec.hiddenSeries.length > 0 && chartSpec.encoding.color) {
326
+ if (
327
+ chartSpec.hiddenSeries.length > 0 &&
328
+ chartSpec.encoding.color &&
329
+ 'field' in chartSpec.encoding.color
330
+ ) {
296
331
  const colorField = chartSpec.encoding.color.field;
297
332
  const hiddenSet = new Set(chartSpec.hiddenSeries);
298
333
  renderData = renderData.filter((row) => !hiddenSet.has(String(row[colorField])));
@@ -338,8 +373,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
338
373
  // Set default color for single-series charts (no color encoding)
339
374
  scales.defaultColor = theme.colors.categorical[0];
340
375
 
341
- // Pie/donut charts don't use axes or gridlines
342
- const isRadial = chartSpec.type === 'pie' || chartSpec.type === 'donut';
376
+ // Arc charts (pie/donut) don't use axes or gridlines
377
+ const isRadial = chartSpec.markType === 'arc';
343
378
 
344
379
  // Compute axes (skip for radial charts)
345
380
  const axes = isRadial
@@ -351,8 +386,28 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
351
386
  computeGridlines(axes, chartArea);
352
387
  }
353
388
 
354
- // Get chart renderer and compute marks (using filtered data)
355
- const renderer = getChartRenderer(renderSpec.type);
389
+ // Get chart renderer and compute marks (using filtered data).
390
+ // For 'bar' mark, resolve orientation to pick horizontal vs vertical renderer.
391
+ // For 'arc' mark, resolve innerRadius to pick pie vs donut renderer.
392
+ let rendererKey = renderSpec.markType as string;
393
+ if (rendererKey === 'bar') {
394
+ // Infer orientation from encoding: if x is quantitative and y is categorical, horizontal (default)
395
+ // If x is categorical and y is quantitative, vertical (old 'column')
396
+ const xType = renderSpec.encoding.x?.type;
397
+ const yType = renderSpec.encoding.y?.type;
398
+ const isVertical =
399
+ (xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
400
+ yType === 'quantitative';
401
+ if (isVertical) {
402
+ rendererKey = 'bar:vertical';
403
+ }
404
+ } else if (rendererKey === 'arc') {
405
+ const innerRadius = renderSpec.markDef.innerRadius;
406
+ if (innerRadius && innerRadius > 0) {
407
+ rendererKey = 'arc:donut';
408
+ }
409
+ }
410
+ const renderer = getChartRenderer(rendererKey);
356
411
  const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
357
412
 
358
413
  // Compute annotations from spec, passing legend + mark + brand bounds as obstacles
@@ -364,7 +419,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
364
419
 
365
420
  // Add visible data label bounds as obstacles so annotations avoid overlapping them
366
421
  for (const mark of marks) {
367
- if (mark.type !== 'area' && mark.label?.visible) {
422
+ if ('label' in mark && mark.label?.visible) {
368
423
  obstacles.push(computeLabelBounds(mark.label));
369
424
  }
370
425
  }
@@ -395,7 +450,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
395
450
  // Compute accessibility
396
451
  const altText = generateAltText(
397
452
  {
398
- type: chartSpec.type,
453
+ mark: chartSpec.markType,
399
454
  data: chartSpec.data,
400
455
  encoding: chartSpec.encoding,
401
456
  chrome: chartSpec.chrome,
@@ -404,7 +459,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
404
459
  );
405
460
  const dataTableFallback = generateDataTable(
406
461
  {
407
- type: chartSpec.type,
462
+ mark: chartSpec.markType,
408
463
  data: chartSpec.data,
409
464
  encoding: chartSpec.encoding,
410
465
  },
@@ -436,6 +491,101 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
436
491
  };
437
492
  }
438
493
 
494
+ // ---------------------------------------------------------------------------
495
+ // Layer compilation
496
+ // ---------------------------------------------------------------------------
497
+
498
+ /**
499
+ * Compile a LayerSpec into a single ChartLayout.
500
+ *
501
+ * Flattens nested layers, merges inherited data/encoding/transforms,
502
+ * compiles each leaf layer independently, unions scale domains (shared
503
+ * by default), and concatenates marks in layer order.
504
+ *
505
+ * @param spec - A LayerSpec with child layers.
506
+ * @param options - Compile options (width, height, theme, darkMode).
507
+ * @returns A single ChartLayout with combined marks from all layers.
508
+ */
509
+ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
510
+ // Flatten nested layers into leaf ChartSpecs with merged data/encoding/transforms
511
+ const leaves = flattenLayers(spec);
512
+
513
+ if (leaves.length === 0) {
514
+ throw new Error('LayerSpec has no leaf chart specs after flattening');
515
+ }
516
+
517
+ // If there's only one layer, just compile it directly
518
+ if (leaves.length === 1) {
519
+ const singleSpec = buildPrimarySpec(leaves, spec);
520
+ return compileChart(singleSpec, options);
521
+ }
522
+
523
+ // Build primary spec with unioned data for shared scale computation.
524
+ // The primary layout provides chrome, axes, dimensions, legend, and a11y.
525
+ const primarySpec = buildPrimarySpec(leaves, spec);
526
+ const primaryLayout = compileChart(primarySpec, options);
527
+
528
+ // Compile each leaf layer independently but with the full unioned data
529
+ // so they all share the same scale domains.
530
+ const allMarks: Mark[] = [];
531
+ const seenLabels = new Set<string>();
532
+ const mergedLegendEntries = [...primaryLayout.legend.entries];
533
+ for (const entry of mergedLegendEntries) {
534
+ seenLabels.add(entry.label);
535
+ }
536
+
537
+ for (const leaf of leaves) {
538
+ // Compile each leaf with its own data so marks correspond to its rows only.
539
+ // Scale domains may differ slightly between layers, but this prevents
540
+ // duplicate marks from feeding unioned data into every renderer.
541
+ const leafLayout = compileChart(leaf as unknown, options);
542
+
543
+ allMarks.push(...leafLayout.marks);
544
+
545
+ // Deduplicate legend entries across layers
546
+ for (const entry of leafLayout.legend.entries) {
547
+ if (!seenLabels.has(entry.label)) {
548
+ seenLabels.add(entry.label);
549
+ mergedLegendEntries.push(entry);
550
+ }
551
+ }
552
+ }
553
+
554
+ return {
555
+ ...primaryLayout,
556
+ marks: allMarks,
557
+ legend: {
558
+ ...primaryLayout.legend,
559
+ entries: mergedLegendEntries,
560
+ },
561
+ };
562
+ }
563
+
564
+ /**
565
+ * Build the primary ChartSpec from all leaves for shared compilation.
566
+ * Unions all data rows across layers so scales see the full domain.
567
+ * Uses the first leaf's mark/encoding as the base, with layer-level chrome.
568
+ */
569
+ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec {
570
+ // Union all data across layers for domain computation
571
+ const allData = leaves.flatMap((leaf) => leaf.data);
572
+
573
+ const primary = {
574
+ ...leaves[0],
575
+ data: allData,
576
+ // Layer-level chrome overrides leaf chrome
577
+ chrome: layerSpec.chrome ?? leaves[0].chrome,
578
+ labels: layerSpec.labels ?? leaves[0].labels,
579
+ legend: layerSpec.legend ?? leaves[0].legend,
580
+ responsive: layerSpec.responsive ?? leaves[0].responsive,
581
+ theme: layerSpec.theme ?? leaves[0].theme,
582
+ darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
583
+ hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
584
+ };
585
+
586
+ return primary;
587
+ }
588
+
439
589
  // ---------------------------------------------------------------------------
440
590
  // Table compilation
441
591
  // ---------------------------------------------------------------------------
@@ -454,8 +604,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
454
604
  export function compileTable(spec: unknown, options: CompileTableOptions): TableLayout {
455
605
  const { spec: normalized } = compileSpec(spec);
456
606
 
457
- if (normalized.type !== 'table') {
458
- throw new Error(`compileTable received a ${normalized.type} spec. Use compileChart instead.`);
607
+ const normType =
608
+ 'type' in normalized ? (normalized as unknown as Record<string, unknown>).type : undefined;
609
+ if (normType !== 'table') {
610
+ throw new Error(`compileTable received a non-table spec. Use compileChart instead.`);
459
611
  }
460
612
 
461
613
  const tableSpec = normalized as NormalizedTableSpec;
@@ -4,7 +4,7 @@ import type { NormalizedChartSpec } from '../types';
4
4
 
5
5
  describe('compile (validate + normalize pipeline)', () => {
6
6
  const validSpec = {
7
- type: 'line',
7
+ mark: 'line',
8
8
  data: [
9
9
  { date: '2020-01-01', value: 10 },
10
10
  { date: '2021-01-01', value: 20 },
@@ -19,7 +19,7 @@ describe('compile (validate + normalize pipeline)', () => {
19
19
  it('returns a normalized spec for valid input', () => {
20
20
  const result = compile(validSpec);
21
21
  expect(result.spec).toBeDefined();
22
- expect(result.spec.type).toBe('line');
22
+ expect((result.spec as NormalizedChartSpec).markType).toBe('line');
23
23
  expect(result.warnings).toBeInstanceOf(Array);
24
24
  });
25
25
 
@@ -42,7 +42,7 @@ describe('compile (validate + normalize pipeline)', () => {
42
42
  expect(() => compile({})).toThrow('Invalid spec');
43
43
  expect(() =>
44
44
  compile({
45
- type: 'line',
45
+ mark: 'line',
46
46
  data: [],
47
47
  encoding: {},
48
48
  }),
@@ -51,7 +51,7 @@ describe('compile (validate + normalize pipeline)', () => {
51
51
 
52
52
  it('produces warnings for inferred types', () => {
53
53
  const spec = {
54
- type: 'scatter',
54
+ mark: 'point',
55
55
  data: [
56
56
  { x: 10, y: 20 },
57
57
  { x: 30, y: 40 },
@@ -15,7 +15,7 @@ import type { NormalizedChartSpec, NormalizedGraphSpec, NormalizedTableSpec } fr
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
17
  const lineSpec: ChartSpec = {
18
- type: 'line',
18
+ mark: 'line',
19
19
  data: [
20
20
  { date: '2020-01-01', value: 10, country: 'US' },
21
21
  { date: '2021-01-01', value: 20, country: 'UK' },
@@ -76,7 +76,7 @@ describe('normalizeSpec', () => {
76
76
  it('infers encoding types from data when not specified', () => {
77
77
  const warnings: string[] = [];
78
78
  const spec: ChartSpec = {
79
- type: 'scatter',
79
+ mark: 'point',
80
80
  data: [
81
81
  { x: 10, y: 20 },
82
82
  { x: 30, y: 40 },
@@ -97,7 +97,7 @@ describe('normalizeSpec', () => {
97
97
  it('infers temporal type from date strings', () => {
98
98
  const warnings: string[] = [];
99
99
  const spec: ChartSpec = {
100
- type: 'line',
100
+ mark: 'line',
101
101
  data: [
102
102
  { date: '2020-01-01', value: 10 },
103
103
  { date: '2021-06-15', value: 20 },
@@ -116,7 +116,7 @@ describe('normalizeSpec', () => {
116
116
  it('warns on type mismatch (temporal declared as nominal)', () => {
117
117
  const warnings: string[] = [];
118
118
  const spec: ChartSpec = {
119
- type: 'line',
119
+ mark: 'line',
120
120
  data: [
121
121
  { date: '2020-01-01', value: 10 },
122
122
  { date: '2021-06-15', value: 20 },