@opendata-ai/openchart-engine 6.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
@@ -12,7 +12,7 @@ const validLineData = [
12
12
  ];
13
13
 
14
14
  const validLineSpec = {
15
- type: 'line',
15
+ mark: 'line',
16
16
  data: validLineData,
17
17
  encoding: {
18
18
  x: { field: 'date', type: 'temporal' },
@@ -23,7 +23,7 @@ const validLineSpec = {
23
23
  };
24
24
 
25
25
  const validBarSpec = {
26
- type: 'bar',
26
+ mark: 'bar',
27
27
  data: [
28
28
  { category: 'A', count: 10 },
29
29
  { category: 'B', count: 20 },
@@ -34,8 +34,8 @@ const validBarSpec = {
34
34
  },
35
35
  };
36
36
 
37
- const validPieSpec = {
38
- type: 'pie',
37
+ const validArcSpec = {
38
+ mark: 'arc',
39
39
  data: [
40
40
  { label: 'Apples', amount: 30 },
41
41
  { label: 'Oranges', amount: 50 },
@@ -73,18 +73,17 @@ describe('validateSpec', () => {
73
73
  expect(result.errors[0].code).toBe('INVALID_TYPE');
74
74
  });
75
75
 
76
- it('rejects objects without type with MISSING_FIELD code', () => {
76
+ it('rejects objects without mark or type with MISSING_FIELD code', () => {
77
77
  const result = validateSpec({ data: [] });
78
78
  expect(result.valid).toBe(false);
79
- expect(result.errors[0].message).toContain('"type" field');
80
79
  expect(result.errors[0].code).toBe('MISSING_FIELD');
81
- expect(result.errors[0].suggestion).toContain('line');
80
+ expect(result.errors[0].suggestion).toContain('bar');
82
81
  });
83
82
 
84
- it('rejects invalid type values with INVALID_VALUE code', () => {
85
- const result = validateSpec({ type: 'waterfall' });
83
+ it('rejects invalid mark values with INVALID_VALUE code', () => {
84
+ const result = validateSpec({ mark: 'waterfall' });
86
85
  expect(result.valid).toBe(false);
87
- expect(result.errors[0].message).toContain('"waterfall" is not a valid type');
86
+ expect(result.errors[0].message).toContain('"waterfall" is not a valid mark type');
88
87
  expect(result.errors[0].code).toBe('INVALID_VALUE');
89
88
  expect(result.errors[0].suggestion).toContain('line');
90
89
  });
@@ -103,8 +102,8 @@ describe('validateSpec', () => {
103
102
  expect(result.valid).toBe(true);
104
103
  });
105
104
 
106
- it('accepts a valid pie spec', () => {
107
- const result = validateSpec(validPieSpec);
105
+ it('accepts a valid arc spec', () => {
106
+ const result = validateSpec(validArcSpec);
108
107
  expect(result.valid).toBe(true);
109
108
  });
110
109
 
@@ -125,7 +124,7 @@ describe('validateSpec', () => {
125
124
 
126
125
  it('rejects missing encoding with MISSING_FIELD code and channel suggestion', () => {
127
126
  const result = validateSpec({
128
- type: 'line',
127
+ mark: 'line',
129
128
  data: validLineData,
130
129
  });
131
130
  expect(result.valid).toBe(false);
@@ -137,7 +136,7 @@ describe('validateSpec', () => {
137
136
 
138
137
  it('rejects missing required channel with MISSING_FIELD code', () => {
139
138
  const result = validateSpec({
140
- type: 'line',
139
+ mark: 'line',
141
140
  data: validLineData,
142
141
  encoding: {
143
142
  x: { field: 'date', type: 'temporal' },
@@ -155,7 +154,7 @@ describe('validateSpec', () => {
155
154
 
156
155
  it('rejects field referencing non-existent column with DATA_FIELD_MISSING code', () => {
157
156
  const result = validateSpec({
158
- type: 'line',
157
+ mark: 'line',
159
158
  data: validLineData,
160
159
  encoding: {
161
160
  x: { field: 'nonexistent', type: 'temporal' },
@@ -175,7 +174,7 @@ describe('validateSpec', () => {
175
174
 
176
175
  it('rejects invalid field type with INVALID_VALUE code', () => {
177
176
  const result = validateSpec({
178
- type: 'line',
177
+ mark: 'line',
179
178
  data: validLineData,
180
179
  encoding: {
181
180
  x: { field: 'date', type: 'bogus' },
@@ -192,7 +191,7 @@ describe('validateSpec', () => {
192
191
 
193
192
  it('rejects disallowed type for channel with ENCODING_MISMATCH code', () => {
194
193
  const result = validateSpec({
195
- type: 'line',
194
+ mark: 'line',
196
195
  data: validLineData,
197
196
  encoding: {
198
197
  x: { field: 'date', type: 'quantitative' },
@@ -208,7 +207,7 @@ describe('validateSpec', () => {
208
207
 
209
208
  it('catches temporal field with non-date values with ENCODING_MISMATCH', () => {
210
209
  const result = validateSpec({
211
- type: 'line',
210
+ mark: 'line',
212
211
  data: [
213
212
  { x: 'not-a-date', y: 10 },
214
213
  { x: 'also-not-a-date', y: 20 },
@@ -227,7 +226,7 @@ describe('validateSpec', () => {
227
226
 
228
227
  it('catches quantitative field with non-numeric values with ENCODING_MISMATCH', () => {
229
228
  const result = validateSpec({
230
- type: 'scatter',
229
+ mark: 'point',
231
230
  data: [
232
231
  { x: 'hello', y: 10 },
233
232
  { x: 'world', y: 20 },
@@ -258,7 +257,7 @@ describe('validateSpec', () => {
258
257
 
259
258
  it('rejects missing encoding channel field with MISSING_FIELD code', () => {
260
259
  const result = validateSpec({
261
- type: 'bar',
260
+ mark: 'bar',
262
261
  data: [{ a: 1, b: 2 }],
263
262
  encoding: {
264
263
  x: { type: 'quantitative' },
@@ -386,10 +385,10 @@ describe('validateSpec', () => {
386
385
  const results = [
387
386
  validateSpec(null),
388
387
  validateSpec({ data: [] }),
389
- validateSpec({ type: 'waterfall' }),
390
- validateSpec({ type: 'line', data: [] }),
388
+ validateSpec({ mark: 'waterfall' }),
389
+ validateSpec({ mark: 'line', data: [] }),
391
390
  validateSpec({
392
- type: 'line',
391
+ mark: 'line',
393
392
  data: [{ x: 1 }],
394
393
  encoding: { x: { field: 'missing', type: 'quantitative' } },
395
394
  }),
@@ -407,8 +406,8 @@ describe('validateSpec', () => {
407
406
  const results = [
408
407
  validateSpec(null),
409
408
  validateSpec({ data: [] }),
410
- validateSpec({ type: 'waterfall' }),
411
- validateSpec({ type: 'line', data: [] }),
409
+ validateSpec({ mark: 'waterfall' }),
410
+ validateSpec({ mark: 'line', data: [] }),
412
411
  ];
413
412
 
414
413
  for (const result of results) {
@@ -422,7 +421,7 @@ describe('validateSpec', () => {
422
421
 
423
422
  it('DATA_FIELD_MISSING suggestion lists available fields', () => {
424
423
  const result = validateSpec({
425
- type: 'bar',
424
+ mark: 'bar',
426
425
  data: [{ alpha: 1, beta: 2, gamma: 3 }],
427
426
  encoding: {
428
427
  x: { field: 'nonexistent', type: 'quantitative' },
@@ -31,7 +31,7 @@ export function compile(spec: unknown): CompileResult {
31
31
  return { spec: normalized, warnings };
32
32
  }
33
33
 
34
- export { normalizeSpec } from './normalize';
34
+ export { flattenLayers, normalizeSpec } from './normalize';
35
35
  export type {
36
36
  CompileResult,
37
37
  NormalizedChartSpec,
@@ -17,10 +17,18 @@ import type {
17
17
  Encoding,
18
18
  FieldType,
19
19
  GraphSpec,
20
+ LayerSpec,
20
21
  TableSpec,
21
22
  VizSpec,
22
23
  } from '@opendata-ai/openchart-core';
23
- import { isChartSpec, isGraphSpec, isTableSpec } from '@opendata-ai/openchart-core';
24
+ import {
25
+ isChartSpec,
26
+ isGraphSpec,
27
+ isLayerSpec,
28
+ isTableSpec,
29
+ resolveMarkDef,
30
+ resolveMarkType,
31
+ } from '@opendata-ai/openchart-core';
24
32
 
25
33
  import type {
26
34
  NormalizedChartSpec,
@@ -116,9 +124,12 @@ function inferEncodingTypes(encoding: Encoding, data: DataRow[], warnings: strin
116
124
  const spec = result[channel];
117
125
  if (!spec) continue;
118
126
 
127
+ // Skip conditional value definitions - they don't have field/type at the top level
128
+ if ('condition' in spec) continue;
129
+
119
130
  if (!spec.type) {
120
131
  const inferred = inferFieldType(data, spec.field);
121
- result[channel] = { ...spec, type: inferred };
132
+ (result as Record<string, unknown>)[channel] = { ...spec, type: inferred };
122
133
  warnings.push(
123
134
  `Inferred encoding.${channel}.type as "${inferred}" from data values for field "${spec.field}"`,
124
135
  );
@@ -180,9 +191,12 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation
180
191
 
181
192
  function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChartSpec {
182
193
  const encoding = inferEncodingTypes(spec.encoding, spec.data, warnings);
194
+ const markType = resolveMarkType(spec.mark);
195
+ const markDef = resolveMarkDef(spec.mark);
183
196
 
184
197
  return {
185
- type: spec.type,
198
+ markType,
199
+ markDef,
186
200
  data: spec.data,
187
201
  encoding,
188
202
  chrome: normalizeChrome(spec.chrome),
@@ -258,6 +272,16 @@ function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGra
258
272
  * @returns A NormalizedSpec with all optionals filled.
259
273
  */
260
274
  export function normalizeSpec(spec: VizSpec, warnings: string[] = []): NormalizedSpec {
275
+ if (isLayerSpec(spec)) {
276
+ // For LayerSpec, we flatten and normalize the first leaf to get a valid NormalizedChartSpec.
277
+ // The actual layer compilation happens in compileLayer, not here.
278
+ // This path exists so the generic compile() pipeline doesn't reject layer specs.
279
+ const leaves = flattenLayers(spec);
280
+ if (leaves.length === 0) {
281
+ throw new Error('LayerSpec has no leaf chart specs after flattening');
282
+ }
283
+ return normalizeChartSpec(leaves[0], warnings);
284
+ }
261
285
  if (isChartSpec(spec)) {
262
286
  return normalizeChartSpec(spec, warnings);
263
287
  }
@@ -268,5 +292,54 @@ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): Normalize
268
292
  return normalizeGraphSpec(spec, warnings);
269
293
  }
270
294
  // Should never happen after validation
271
- throw new Error(`Unknown spec type: ${(spec as Record<string, unknown>).type}`);
295
+ throw new Error(
296
+ `Unknown spec shape. Expected mark (chart), layer, type: 'table', or type: 'graph'.`,
297
+ );
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Layer flattening (used by compileLayer in compile.ts)
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /**
305
+ * Recursively flatten a LayerSpec into leaf ChartSpecs.
306
+ * Merges parent data, encoding, and transforms down to children.
307
+ */
308
+ export function flattenLayers(
309
+ spec: LayerSpec,
310
+ parentData?: DataRow[],
311
+ parentEncoding?: Encoding,
312
+ parentTransforms?: import('@opendata-ai/openchart-core').Transform[],
313
+ ): ChartSpec[] {
314
+ const resolvedData = spec.data ?? parentData;
315
+ const resolvedEncoding: Encoding | undefined =
316
+ parentEncoding && spec.encoding
317
+ ? { ...parentEncoding, ...spec.encoding }
318
+ : (spec.encoding ?? parentEncoding);
319
+ const resolvedTransforms = [...(parentTransforms ?? []), ...(spec.transform ?? [])];
320
+
321
+ const leaves: ChartSpec[] = [];
322
+
323
+ for (const child of spec.layer) {
324
+ if (isLayerSpec(child)) {
325
+ // Nested layer: recurse with merged context
326
+ leaves.push(...flattenLayers(child, resolvedData, resolvedEncoding, resolvedTransforms));
327
+ } else {
328
+ // Leaf ChartSpec: merge inherited properties
329
+ const mergedData = child.data ?? resolvedData ?? [];
330
+ const mergedEncoding = resolvedEncoding
331
+ ? { ...resolvedEncoding, ...child.encoding }
332
+ : child.encoding;
333
+ const mergedTransforms = [...resolvedTransforms, ...(child.transform ?? [])];
334
+
335
+ leaves.push({
336
+ ...child,
337
+ data: mergedData,
338
+ encoding: mergedEncoding,
339
+ transform: mergedTransforms.length > 0 ? mergedTransforms : undefined,
340
+ });
341
+ }
342
+ }
343
+
344
+ return leaves;
272
345
  }
@@ -10,7 +10,6 @@ import type {
10
10
  AggregateOp,
11
11
  Annotation,
12
12
  AxisConfig,
13
- ChartType,
14
13
  ChromeText,
15
14
  ColumnConfig,
16
15
  DarkMode,
@@ -22,6 +21,8 @@ import type {
22
21
  GraphSpec,
23
22
  LabelConfig,
24
23
  LegendConfig,
24
+ MarkDef,
25
+ MarkType,
25
26
  NodeOverride,
26
27
  ScaleConfig,
27
28
  ThemeConfig,
@@ -59,7 +60,10 @@ export interface NormalizedEncodingChannel {
59
60
 
60
61
  /** A ChartSpec with all optional fields filled with sensible defaults. */
61
62
  export interface NormalizedChartSpec {
62
- type: ChartType;
63
+ /** Resolved mark type string (extracted from spec.mark). */
64
+ markType: MarkType;
65
+ /** Resolved mark definition with defaults filled in. */
66
+ markDef: MarkDef;
63
67
  data: DataRow[];
64
68
  encoding: Encoding;
65
69
  chrome: NormalizedChrome;
@@ -10,10 +10,10 @@
10
10
  */
11
11
 
12
12
  import {
13
- CHART_ENCODING_RULES,
14
- CHART_TYPES,
15
- type ChartType,
16
13
  type FieldType,
14
+ MARK_ENCODING_RULES,
15
+ MARK_TYPES,
16
+ type MarkType,
17
17
  type VizSpec,
18
18
  } from '@opendata-ai/openchart-core';
19
19
 
@@ -53,7 +53,8 @@ function isNumeric(value: unknown): boolean {
53
53
  // ---------------------------------------------------------------------------
54
54
 
55
55
  function validateChartSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
56
- const chartType = spec.type as ChartType;
56
+ const markType =
57
+ typeof spec.mark === 'string' ? spec.mark : (spec.mark as Record<string, unknown>)?.type;
57
58
 
58
59
  // Check data
59
60
  if (!Array.isArray(spec.data)) {
@@ -91,12 +92,12 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
91
92
 
92
93
  // Check encoding exists
93
94
  if (!spec.encoding || typeof spec.encoding !== 'object') {
94
- const rules = CHART_ENCODING_RULES[chartType];
95
+ const rules = MARK_ENCODING_RULES[markType as MarkType];
95
96
  const requiredChannels = Object.entries(rules)
96
97
  .filter(([, rule]) => rule.required)
97
98
  .map(([ch]) => ch);
98
99
  errors.push({
99
- message: `Spec error: ${chartType} chart requires an "encoding" object`,
100
+ message: `Spec error: ${markType} chart requires an "encoding" object`,
100
101
  path: 'encoding',
101
102
  code: 'MISSING_FIELD',
102
103
  suggestion: `Add an encoding object with required channels: ${requiredChannels.join(', ')}. Example: encoding: { ${requiredChannels.map((ch) => `${ch}: { field: "...", type: "..." }`).join(', ')} }`,
@@ -104,7 +105,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
104
105
  return;
105
106
  }
106
107
 
107
- const rules = CHART_ENCODING_RULES[chartType];
108
+ const rules = MARK_ENCODING_RULES[markType as MarkType];
108
109
  const encoding = spec.encoding as Record<string, unknown>;
109
110
  const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
110
111
  const availableColumns = [...dataColumns].join(', ');
@@ -114,7 +115,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
114
115
  if (rule.required && !encoding[channel]) {
115
116
  const allowedTypes = rule.allowedTypes.join(' or ');
116
117
  errors.push({
117
- message: `Spec error: ${chartType} chart requires encoding.${channel} but none was provided`,
118
+ message: `Spec error: ${markType} chart requires encoding.${channel} but none was provided`,
118
119
  path: `encoding.${channel}`,
119
120
  code: 'MISSING_FIELD',
120
121
  suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}) and type (${allowedTypes}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${rule.allowedTypes[0]}" }`,
@@ -122,6 +123,19 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
122
123
  }
123
124
  }
124
125
 
126
+ // Collect fields that transforms will create, so we don't reject them
127
+ const transformFields = new Set<string>();
128
+ if (Array.isArray(spec.transform)) {
129
+ for (const t of spec.transform as Record<string, unknown>[]) {
130
+ if (typeof t.as === 'string') transformFields.add(t.as);
131
+ if (Array.isArray(t.as)) {
132
+ for (const f of t.as) {
133
+ if (typeof f === 'string') transformFields.add(f);
134
+ }
135
+ }
136
+ }
137
+ }
138
+
125
139
  // Validate provided channels
126
140
  for (const [channel, channelSpec] of Object.entries(encoding)) {
127
141
  if (!channelSpec || typeof channelSpec !== 'object') continue;
@@ -129,6 +143,9 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
129
143
  const channelObj = channelSpec as Record<string, unknown>;
130
144
  const channelRule = rules[channel as keyof typeof rules];
131
145
 
146
+ // Skip ConditionalValueDef channels (they have 'condition' instead of 'field')
147
+ if ('condition' in channelObj) continue;
148
+
132
149
  // Check field exists
133
150
  if (!channelObj.field || typeof channelObj.field !== 'string') {
134
151
  errors.push({
@@ -140,8 +157,8 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
140
157
  continue;
141
158
  }
142
159
 
143
- // Check field references a column in data
144
- if (!dataColumns.has(channelObj.field)) {
160
+ // Check field references a column in data (or will be created by a transform)
161
+ if (!dataColumns.has(channelObj.field) && !transformFields.has(channelObj.field)) {
145
162
  errors.push({
146
163
  message: `Spec error: encoding.${channel}.field "${channelObj.field}" does not exist in data. Available columns: ${availableColumns}`,
147
164
  path: `encoding.${channel}.field`,
@@ -164,7 +181,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
164
181
  if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
165
182
  if (!channelRule.allowedTypes.includes(channelObj.type as FieldType)) {
166
183
  errors.push({
167
- message: `Spec error: encoding.${channel} for ${chartType} chart does not accept type "${channelObj.type}". Allowed types: ${channelRule.allowedTypes.join(', ')}`,
184
+ message: `Spec error: encoding.${channel} for ${markType} chart does not accept type "${channelObj.type}". Allowed types: ${channelRule.allowedTypes.join(', ')}`,
168
185
  path: `encoding.${channel}.type`,
169
186
  code: 'ENCODING_MISMATCH',
170
187
  suggestion: `Change encoding.${channel}.type to one of: ${channelRule.allowedTypes.join(', ')}`,
@@ -493,6 +510,99 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
493
510
  }
494
511
  }
495
512
 
513
+ // ---------------------------------------------------------------------------
514
+ // Layer validation
515
+ // ---------------------------------------------------------------------------
516
+
517
+ function validateLayerSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
518
+ const layer = spec.layer as unknown[];
519
+
520
+ if (layer.length === 0) {
521
+ errors.push({
522
+ message: 'Spec error: "layer" must be a non-empty array',
523
+ path: 'layer',
524
+ code: 'EMPTY_DATA',
525
+ suggestion: 'Add at least one layer with a mark and encoding',
526
+ });
527
+ return;
528
+ }
529
+
530
+ for (let i = 0; i < layer.length; i++) {
531
+ const child = layer[i];
532
+ if (!child || typeof child !== 'object' || Array.isArray(child)) {
533
+ errors.push({
534
+ message: `Spec error: layer[${i}] must be an object`,
535
+ path: `layer[${i}]`,
536
+ code: 'INVALID_TYPE',
537
+ suggestion:
538
+ 'Each layer must be a chart spec (with mark) or a nested layer spec (with layer)',
539
+ });
540
+ continue;
541
+ }
542
+
543
+ const childObj = child as Record<string, unknown>;
544
+ const isNestedLayer = 'layer' in childObj && Array.isArray(childObj.layer);
545
+ const isChildChart = 'mark' in childObj;
546
+
547
+ if (!isNestedLayer && !isChildChart) {
548
+ errors.push({
549
+ message: `Spec error: layer[${i}] must have a "mark" field or a "layer" array`,
550
+ path: `layer[${i}]`,
551
+ code: 'MISSING_FIELD',
552
+ suggestion:
553
+ 'Each layer must be a chart spec (with mark + encoding) or a nested layer spec (with layer array)',
554
+ });
555
+ continue;
556
+ }
557
+
558
+ if (isNestedLayer) {
559
+ validateLayerSpec(childObj, errors);
560
+ } else if (isChildChart) {
561
+ // Validate mark type
562
+ const mark = childObj.mark;
563
+ let markValue: string | undefined;
564
+ if (typeof mark === 'string') {
565
+ markValue = mark;
566
+ } else if (mark && typeof mark === 'object' && !Array.isArray(mark)) {
567
+ markValue = (mark as Record<string, unknown>).type as string | undefined;
568
+ }
569
+
570
+ if (!markValue || !MARK_TYPES.has(markValue)) {
571
+ errors.push({
572
+ message: `Spec error: layer[${i}].mark "${markValue ?? String(mark)}" is not a valid mark type`,
573
+ path: `layer[${i}].mark`,
574
+ code: 'INVALID_VALUE',
575
+ suggestion: `Change mark to one of: ${[...MARK_TYPES].join(', ')}`,
576
+ });
577
+ continue;
578
+ }
579
+
580
+ // Child layers can inherit data and encoding from parent, so only validate
581
+ // if the child has its own data (or the parent provides shared data).
582
+ const hasOwnData = Array.isArray(childObj.data) && (childObj.data as unknown[]).length > 0;
583
+ const parentHasData = Array.isArray(spec.data) && (spec.data as unknown[]).length > 0;
584
+
585
+ if (hasOwnData || parentHasData) {
586
+ // Build a merged spec for validation purposes
587
+ const mergedForValidation = { ...childObj };
588
+ if (!hasOwnData && parentHasData) {
589
+ mergedForValidation.data = spec.data;
590
+ }
591
+ // Merge encoding: parent fields are inherited unless child overrides
592
+ if (spec.encoding && typeof spec.encoding === 'object') {
593
+ mergedForValidation.encoding = {
594
+ ...(spec.encoding as Record<string, unknown>),
595
+ ...((childObj.encoding as Record<string, unknown>) ?? {}),
596
+ };
597
+ }
598
+ if (mergedForValidation.data && mergedForValidation.encoding) {
599
+ validateChartSpec(mergedForValidation, errors);
600
+ }
601
+ }
602
+ }
603
+ }
604
+ }
605
+
496
606
  // ---------------------------------------------------------------------------
497
607
  // Public API
498
608
  // ---------------------------------------------------------------------------
@@ -516,7 +626,7 @@ export function validateSpec(spec: unknown): ValidationResult {
516
626
  message: 'Spec error: spec must be a non-null object',
517
627
  code: 'INVALID_TYPE',
518
628
  suggestion:
519
- 'Pass a spec object with at least a "type" field, e.g. { type: "line", data: [...], encoding: {...} }',
629
+ 'Pass a spec object with at least a "mark" field for charts, e.g. { mark: "line", data: [...], encoding: {...} }',
520
630
  },
521
631
  ],
522
632
  normalized: null,
@@ -525,43 +635,65 @@ export function validateSpec(spec: unknown): ValidationResult {
525
635
 
526
636
  const obj = spec as Record<string, unknown>;
527
637
 
528
- // Type check
529
- if (!obj.type || typeof obj.type !== 'string') {
530
- return {
531
- valid: false,
532
- errors: [
533
- {
534
- message: 'Spec error: spec must have a "type" field',
535
- path: 'type',
536
- code: 'MISSING_FIELD',
537
- suggestion: `Add a type field. Valid types: ${[...CHART_TYPES].join(', ')}, table, graph`,
538
- },
539
- ],
540
- normalized: null,
541
- };
542
- }
543
-
544
- const isChart = CHART_TYPES.has(obj.type);
638
+ // Determine spec type via structural discrimination:
639
+ // - Layer specs have a 'layer' array
640
+ // - Chart specs have a 'mark' field (string or object with type property)
641
+ // - Table specs have type: 'table'
642
+ // - Graph specs have type: 'graph'
643
+ const hasLayer = 'layer' in obj && Array.isArray(obj.layer);
644
+ const hasMark = 'mark' in obj;
545
645
  const isTable = obj.type === 'table';
546
646
  const isGraph = obj.type === 'graph';
647
+ const isLayer = hasLayer && !isTable && !isGraph;
648
+ const isChart = hasMark && !hasLayer && !isTable && !isGraph;
547
649
 
548
- if (!isChart && !isTable && !isGraph) {
650
+ if (!isChart && !isTable && !isGraph && !isLayer) {
549
651
  return {
550
652
  valid: false,
551
653
  errors: [
552
654
  {
553
- message: `Spec error: "${obj.type}" is not a valid type. Valid types: ${[...CHART_TYPES].join(', ')}, table, graph`,
554
- path: 'type',
555
- code: 'INVALID_VALUE',
556
- suggestion: `Change type to one of: ${[...CHART_TYPES].join(', ')}, table, graph`,
655
+ message:
656
+ 'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs',
657
+ path: 'mark',
658
+ code: 'MISSING_FIELD',
659
+ suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field for tables/graphs (type: "table" or type: "graph"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
557
660
  },
558
661
  ],
559
662
  normalized: null,
560
663
  };
561
664
  }
562
665
 
563
- // Type-specific validation
666
+ // For layer specs, validate each child layer recursively
667
+ if (isLayer) {
668
+ validateLayerSpec(obj, errors);
669
+ }
670
+
671
+ // For chart specs, validate the mark field
564
672
  if (isChart) {
673
+ const mark = obj.mark;
674
+ let markValue: string | undefined;
675
+
676
+ if (typeof mark === 'string') {
677
+ markValue = mark;
678
+ } else if (mark && typeof mark === 'object' && !Array.isArray(mark)) {
679
+ markValue = (mark as Record<string, unknown>).type as string | undefined;
680
+ }
681
+
682
+ if (!markValue || !MARK_TYPES.has(markValue)) {
683
+ return {
684
+ valid: false,
685
+ errors: [
686
+ {
687
+ message: `Spec error: "${markValue ?? String(mark)}" is not a valid mark type. Valid mark types: ${[...MARK_TYPES].join(', ')}`,
688
+ path: 'mark',
689
+ code: 'INVALID_VALUE',
690
+ suggestion: `Change mark to one of: ${[...MARK_TYPES].join(', ')}`,
691
+ },
692
+ ],
693
+ normalized: null,
694
+ };
695
+ }
696
+
565
697
  validateChartSpec(obj, errors);
566
698
  } else if (isTable) {
567
699
  validateTableSpec(obj, errors);
@@ -295,7 +295,7 @@ describe('compileGraph', () => {
295
295
  describe('error handling', () => {
296
296
  it('throws for non-graph specs', () => {
297
297
  const chartSpec = {
298
- type: 'scatter' as const,
298
+ mark: 'point' as const,
299
299
  data: [{ x: 1, y: 2 }],
300
300
  encoding: {
301
301
  x: { field: 'x', type: 'quantitative' as const },
@@ -304,7 +304,7 @@ describe('compileGraph', () => {
304
304
  };
305
305
 
306
306
  expect(() => compileGraph(chartSpec, compileOptions)).toThrow(
307
- /compileGraph received a scatter spec/,
307
+ /compileGraph received a non-graph spec/,
308
308
  );
309
309
  });
310
310
 
@@ -181,9 +181,9 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
181
181
  // 1. Validate + normalize
182
182
  const { spec: normalized } = compileSpec(spec);
183
183
 
184
- if (normalized.type !== 'graph') {
184
+ if (!('type' in normalized) || normalized.type !== 'graph') {
185
185
  throw new Error(
186
- `compileGraph received a ${normalized.type} spec. Use compileChart or compileTable instead.`,
186
+ 'compileGraph received a non-graph spec. Use compileChart or compileTable instead.',
187
187
  );
188
188
  }
189
189