@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.
- package/dist/index.d.ts +155 -19
- package/dist/index.js +1513 -164
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -3
- package/src/__tests__/axes.test.ts +168 -4
- package/src/__tests__/compile-chart.test.ts +23 -12
- package/src/__tests__/compile-layer.test.ts +386 -0
- package/src/__tests__/dimensions.test.ts +6 -3
- package/src/__tests__/legend.test.ts +6 -3
- package/src/__tests__/scales.test.ts +176 -2
- package/src/annotations/__tests__/compute.test.ts +8 -4
- package/src/charts/bar/__tests__/compute.test.ts +12 -6
- package/src/charts/bar/compute.ts +21 -5
- package/src/charts/column/__tests__/compute.test.ts +14 -7
- package/src/charts/column/compute.ts +21 -6
- package/src/charts/dot/__tests__/compute.test.ts +10 -5
- package/src/charts/dot/compute.ts +10 -4
- package/src/charts/line/__tests__/compute.test.ts +102 -11
- package/src/charts/line/__tests__/curves.test.ts +51 -0
- package/src/charts/line/__tests__/labels.test.ts +2 -1
- package/src/charts/line/__tests__/mark-options.test.ts +175 -0
- package/src/charts/line/area.ts +19 -8
- package/src/charts/line/compute.ts +64 -25
- package/src/charts/line/curves.ts +40 -0
- package/src/charts/pie/__tests__/compute.test.ts +10 -5
- package/src/charts/pie/compute.ts +2 -1
- package/src/charts/rule/index.ts +127 -0
- package/src/charts/scatter/__tests__/compute.test.ts +10 -5
- package/src/charts/scatter/compute.ts +15 -5
- package/src/charts/text/index.ts +92 -0
- package/src/charts/tick/index.ts +84 -0
- package/src/charts/utils.ts +1 -1
- package/src/compile.ts +175 -23
- package/src/compiler/__tests__/compile.test.ts +4 -4
- package/src/compiler/__tests__/normalize.test.ts +4 -4
- package/src/compiler/__tests__/validate.test.ts +25 -26
- package/src/compiler/index.ts +1 -1
- package/src/compiler/normalize.ts +77 -4
- package/src/compiler/types.ts +6 -2
- package/src/compiler/validate.ts +167 -35
- package/src/graphs/__tests__/compile-graph.test.ts +2 -2
- package/src/graphs/compile-graph.ts +2 -2
- package/src/index.ts +17 -1
- package/src/layout/axes.ts +122 -20
- package/src/layout/dimensions.ts +15 -9
- package/src/layout/scales.ts +320 -31
- package/src/legend/compute.ts +9 -6
- package/src/tables/__tests__/compile-table.test.ts +1 -1
- package/src/tooltips/__tests__/compute.test.ts +10 -5
- package/src/tooltips/compute.ts +32 -14
- package/src/transforms/__tests__/bin.test.ts +88 -0
- package/src/transforms/__tests__/calculate.test.ts +146 -0
- package/src/transforms/__tests__/conditional.test.ts +109 -0
- package/src/transforms/__tests__/filter.test.ts +59 -0
- package/src/transforms/__tests__/index.test.ts +93 -0
- package/src/transforms/__tests__/predicates.test.ts +176 -0
- package/src/transforms/__tests__/timeunit.test.ts +129 -0
- package/src/transforms/bin.ts +87 -0
- package/src/transforms/calculate.ts +60 -0
- package/src/transforms/conditional.ts +46 -0
- package/src/transforms/filter.ts +17 -0
- package/src/transforms/index.ts +48 -0
- package/src/transforms/predicates.ts +90 -0
- package/src/transforms/timeunit.ts +88 -0
|
@@ -12,7 +12,7 @@ const validLineData = [
|
|
|
12
12
|
];
|
|
13
13
|
|
|
14
14
|
const validLineSpec = {
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
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('
|
|
80
|
+
expect(result.errors[0].suggestion).toContain('bar');
|
|
82
81
|
});
|
|
83
82
|
|
|
84
|
-
it('rejects invalid
|
|
85
|
-
const result = validateSpec({
|
|
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
|
|
107
|
-
const result = validateSpec(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
390
|
-
validateSpec({
|
|
388
|
+
validateSpec({ mark: 'waterfall' }),
|
|
389
|
+
validateSpec({ mark: 'line', data: [] }),
|
|
391
390
|
validateSpec({
|
|
392
|
-
|
|
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({
|
|
411
|
-
validateSpec({
|
|
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
|
-
|
|
424
|
+
mark: 'bar',
|
|
426
425
|
data: [{ alpha: 1, beta: 2, gamma: 3 }],
|
|
427
426
|
encoding: {
|
|
428
427
|
x: { field: 'nonexistent', type: 'quantitative' },
|
package/src/compiler/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/src/compiler/types.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/compiler/validate.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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: ${
|
|
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 =
|
|
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: ${
|
|
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 ${
|
|
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 "
|
|
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
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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:
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
186
|
+
'compileGraph received a non-graph spec. Use compileChart or compileTable instead.',
|
|
187
187
|
);
|
|
188
188
|
}
|
|
189
189
|
|