@opendata-ai/openchart-engine 1.2.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 +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- package/src/tooltips/compute.ts +231 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime spec validation.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript catches compile-time errors for specs written in code.
|
|
5
|
+
* This module catches runtime errors for specs coming from JSON, APIs,
|
|
6
|
+
* or Claude-generated output where the TypeScript compiler can't help.
|
|
7
|
+
*
|
|
8
|
+
* Every error includes a machine-readable code and an actionable suggestion
|
|
9
|
+
* so consumers (and LLMs) can fix issues programmatically.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
CHART_ENCODING_RULES,
|
|
14
|
+
CHART_TYPES,
|
|
15
|
+
type ChartType,
|
|
16
|
+
type FieldType,
|
|
17
|
+
type VizSpec,
|
|
18
|
+
} from '@opendata-ai/openchart-core';
|
|
19
|
+
|
|
20
|
+
import type { ValidationError, ValidationResult } from './types';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const VALID_FIELD_TYPES = new Set<string>(['quantitative', 'temporal', 'nominal', 'ordinal']);
|
|
27
|
+
|
|
28
|
+
const VALID_DARK_MODES = new Set<string>(['auto', 'force', 'off']);
|
|
29
|
+
|
|
30
|
+
/** Check if a value looks like a parseable date. */
|
|
31
|
+
function isParseableDate(value: unknown): boolean {
|
|
32
|
+
if (value instanceof Date) return !Number.isNaN(value.getTime());
|
|
33
|
+
if (typeof value === 'string') {
|
|
34
|
+
const d = new Date(value);
|
|
35
|
+
return !Number.isNaN(d.getTime());
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === 'number') return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Check if a value is numeric. */
|
|
42
|
+
function isNumeric(value: unknown): boolean {
|
|
43
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
44
|
+
if (typeof value === 'string') {
|
|
45
|
+
const n = Number(value);
|
|
46
|
+
return !Number.isNaN(n) && Number.isFinite(n);
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Chart validation
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function validateChartSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
56
|
+
const chartType = spec.type as ChartType;
|
|
57
|
+
|
|
58
|
+
// Check data
|
|
59
|
+
if (!Array.isArray(spec.data)) {
|
|
60
|
+
errors.push({
|
|
61
|
+
message: 'Spec error: "data" must be an array',
|
|
62
|
+
path: 'data',
|
|
63
|
+
code: 'INVALID_TYPE',
|
|
64
|
+
suggestion: 'Provide data as an array of objects, e.g. data: [{ x: 1, y: 2 }]',
|
|
65
|
+
});
|
|
66
|
+
return; // Can't validate further without data
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (spec.data.length === 0) {
|
|
70
|
+
errors.push({
|
|
71
|
+
message: 'Spec error: "data" must be a non-empty array',
|
|
72
|
+
path: 'data',
|
|
73
|
+
code: 'EMPTY_DATA',
|
|
74
|
+
suggestion: 'Add at least one data row, e.g. data: [{ x: 1, y: 2 }]',
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate data entries are objects
|
|
80
|
+
const firstRow = spec.data[0] as unknown;
|
|
81
|
+
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
82
|
+
errors.push({
|
|
83
|
+
message: 'Spec error: each item in "data" must be a plain object',
|
|
84
|
+
path: 'data[0]',
|
|
85
|
+
code: 'INVALID_TYPE',
|
|
86
|
+
suggestion:
|
|
87
|
+
'Each data item should be an object with key-value pairs, e.g. { name: "Alice", value: 10 }',
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check encoding exists
|
|
93
|
+
if (!spec.encoding || typeof spec.encoding !== 'object') {
|
|
94
|
+
const rules = CHART_ENCODING_RULES[chartType];
|
|
95
|
+
const requiredChannels = Object.entries(rules)
|
|
96
|
+
.filter(([, rule]) => rule.required)
|
|
97
|
+
.map(([ch]) => ch);
|
|
98
|
+
errors.push({
|
|
99
|
+
message: `Spec error: ${chartType} chart requires an "encoding" object`,
|
|
100
|
+
path: 'encoding',
|
|
101
|
+
code: 'MISSING_FIELD',
|
|
102
|
+
suggestion: `Add an encoding object with required channels: ${requiredChannels.join(', ')}. Example: encoding: { ${requiredChannels.map((ch) => `${ch}: { field: "...", type: "..." }`).join(', ')} }`,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rules = CHART_ENCODING_RULES[chartType];
|
|
108
|
+
const encoding = spec.encoding as Record<string, unknown>;
|
|
109
|
+
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
110
|
+
const availableColumns = [...dataColumns].join(', ');
|
|
111
|
+
|
|
112
|
+
// Validate required channels
|
|
113
|
+
for (const [channel, rule] of Object.entries(rules)) {
|
|
114
|
+
if (rule.required && !encoding[channel]) {
|
|
115
|
+
const allowedTypes = rule.allowedTypes.join(' or ');
|
|
116
|
+
errors.push({
|
|
117
|
+
message: `Spec error: ${chartType} chart requires encoding.${channel} but none was provided`,
|
|
118
|
+
path: `encoding.${channel}`,
|
|
119
|
+
code: 'MISSING_FIELD',
|
|
120
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}) and type (${allowedTypes}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${rule.allowedTypes[0]}" }`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Validate provided channels
|
|
126
|
+
for (const [channel, channelSpec] of Object.entries(encoding)) {
|
|
127
|
+
if (!channelSpec || typeof channelSpec !== 'object') continue;
|
|
128
|
+
|
|
129
|
+
const channelObj = channelSpec as Record<string, unknown>;
|
|
130
|
+
const channelRule = rules[channel as keyof typeof rules];
|
|
131
|
+
|
|
132
|
+
// Check field exists
|
|
133
|
+
if (!channelObj.field || typeof channelObj.field !== 'string') {
|
|
134
|
+
errors.push({
|
|
135
|
+
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
136
|
+
path: `encoding.${channel}.field`,
|
|
137
|
+
code: 'MISSING_FIELD',
|
|
138
|
+
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
139
|
+
});
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check field references a column in data
|
|
144
|
+
if (!dataColumns.has(channelObj.field)) {
|
|
145
|
+
errors.push({
|
|
146
|
+
message: `Spec error: encoding.${channel}.field "${channelObj.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
147
|
+
path: `encoding.${channel}.field`,
|
|
148
|
+
code: 'DATA_FIELD_MISSING',
|
|
149
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check field type is valid
|
|
154
|
+
if (channelObj.type && !VALID_FIELD_TYPES.has(channelObj.type as string)) {
|
|
155
|
+
errors.push({
|
|
156
|
+
message: `Spec error: encoding.${channel}.type "${channelObj.type}" is not valid. Must be one of: ${[...VALID_FIELD_TYPES].join(', ')}`,
|
|
157
|
+
path: `encoding.${channel}.type`,
|
|
158
|
+
code: 'INVALID_VALUE',
|
|
159
|
+
suggestion: `Use one of: ${[...VALID_FIELD_TYPES].join(', ')}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check field type is allowed for this channel
|
|
164
|
+
if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
|
|
165
|
+
if (!channelRule.allowedTypes.includes(channelObj.type as FieldType)) {
|
|
166
|
+
errors.push({
|
|
167
|
+
message: `Spec error: encoding.${channel} for ${chartType} chart does not accept type "${channelObj.type}". Allowed types: ${channelRule.allowedTypes.join(', ')}`,
|
|
168
|
+
path: `encoding.${channel}.type`,
|
|
169
|
+
code: 'ENCODING_MISMATCH',
|
|
170
|
+
suggestion: `Change encoding.${channel}.type to one of: ${channelRule.allowedTypes.join(', ')}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check field values match declared type
|
|
176
|
+
if (channelObj.type && channelObj.field && dataColumns.has(channelObj.field as string)) {
|
|
177
|
+
const data = spec.data as Record<string, unknown>[];
|
|
178
|
+
const fieldName = channelObj.field as string;
|
|
179
|
+
const fieldType = channelObj.type as string;
|
|
180
|
+
// Sample up to 5 values for type checking
|
|
181
|
+
const sampleSize = Math.min(5, data.length);
|
|
182
|
+
|
|
183
|
+
if (fieldType === 'temporal') {
|
|
184
|
+
let nonDateCount = 0;
|
|
185
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
186
|
+
const val = data[i][fieldName];
|
|
187
|
+
if (val != null && !isParseableDate(val)) {
|
|
188
|
+
nonDateCount++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (nonDateCount > 0) {
|
|
192
|
+
errors.push({
|
|
193
|
+
message: `Spec error: encoding.${channel}.field "${fieldName}" is declared as temporal but contains non-date values`,
|
|
194
|
+
path: `encoding.${channel}`,
|
|
195
|
+
code: 'ENCODING_MISMATCH',
|
|
196
|
+
suggestion: `Either change the type to "nominal" or ensure "${fieldName}" values are parseable dates (ISO 8601 strings like "2024-01-15" or Date objects)`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (fieldType === 'quantitative') {
|
|
202
|
+
let nonNumericCount = 0;
|
|
203
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
204
|
+
const val = data[i][fieldName];
|
|
205
|
+
if (val != null && !isNumeric(val)) {
|
|
206
|
+
nonNumericCount++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (nonNumericCount > 0) {
|
|
210
|
+
errors.push({
|
|
211
|
+
message: `Spec error: encoding.${channel}.field "${fieldName}" is declared as quantitative but contains non-numeric values`,
|
|
212
|
+
path: `encoding.${channel}`,
|
|
213
|
+
code: 'ENCODING_MISMATCH',
|
|
214
|
+
suggestion: `Either change the type to "nominal" or ensure "${fieldName}" values are numbers`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Validate darkMode if provided
|
|
222
|
+
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
223
|
+
errors.push({
|
|
224
|
+
message: `Spec error: darkMode must be "auto", "force", or "off"`,
|
|
225
|
+
path: 'darkMode',
|
|
226
|
+
code: 'INVALID_VALUE',
|
|
227
|
+
suggestion:
|
|
228
|
+
'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Table validation
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function validateTableSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
238
|
+
if (!Array.isArray(spec.data)) {
|
|
239
|
+
errors.push({
|
|
240
|
+
message: 'Spec error: "data" must be an array',
|
|
241
|
+
path: 'data',
|
|
242
|
+
code: 'INVALID_TYPE',
|
|
243
|
+
suggestion: 'Provide data as an array of objects, e.g. data: [{ name: "Alice", age: 30 }]',
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (spec.data.length === 0) {
|
|
249
|
+
errors.push({
|
|
250
|
+
message: 'Spec error: "data" must be a non-empty array',
|
|
251
|
+
path: 'data',
|
|
252
|
+
code: 'EMPTY_DATA',
|
|
253
|
+
suggestion: 'Add at least one data row to the data array',
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!Array.isArray(spec.columns)) {
|
|
259
|
+
errors.push({
|
|
260
|
+
message: 'Spec error: table spec requires a "columns" array',
|
|
261
|
+
path: 'columns',
|
|
262
|
+
code: 'MISSING_FIELD',
|
|
263
|
+
suggestion:
|
|
264
|
+
'Add a columns array defining which data fields to display, e.g. columns: [{ key: "name" }, { key: "age" }]',
|
|
265
|
+
});
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const data = spec.data as Record<string, unknown>[];
|
|
270
|
+
const firstRow = data[0];
|
|
271
|
+
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
272
|
+
errors.push({
|
|
273
|
+
message: 'Spec error: each item in "data" must be a plain object',
|
|
274
|
+
path: 'data[0]',
|
|
275
|
+
code: 'INVALID_TYPE',
|
|
276
|
+
suggestion:
|
|
277
|
+
'Each data item should be an object with key-value pairs, e.g. { name: "Alice", age: 30 }',
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
283
|
+
const availableColumns = [...dataColumns].join(', ');
|
|
284
|
+
const columns = spec.columns as Record<string, unknown>[];
|
|
285
|
+
|
|
286
|
+
for (let i = 0; i < columns.length; i++) {
|
|
287
|
+
const col = columns[i];
|
|
288
|
+
if (!col || typeof col !== 'object') {
|
|
289
|
+
errors.push({
|
|
290
|
+
message: `Spec error: columns[${i}] must be an object`,
|
|
291
|
+
path: `columns[${i}]`,
|
|
292
|
+
code: 'INVALID_TYPE',
|
|
293
|
+
suggestion: 'Each column entry should be an object, e.g. { key: "fieldName" }',
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Check key exists
|
|
299
|
+
if (!col.key || typeof col.key !== 'string') {
|
|
300
|
+
errors.push({
|
|
301
|
+
message: `Spec error: columns[${i}] must have a "key" string`,
|
|
302
|
+
path: `columns[${i}].key`,
|
|
303
|
+
code: 'MISSING_FIELD',
|
|
304
|
+
suggestion: `Add a key referencing a data field. Available columns: ${availableColumns}`,
|
|
305
|
+
});
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check key references a field in data
|
|
310
|
+
if (!dataColumns.has(col.key as string)) {
|
|
311
|
+
errors.push({
|
|
312
|
+
message: `Spec error: columns[${i}].key "${col.key}" does not exist in data. Available columns: ${availableColumns}`,
|
|
313
|
+
path: `columns[${i}].key`,
|
|
314
|
+
code: 'DATA_FIELD_MISSING',
|
|
315
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Check at most one visual enhancement
|
|
320
|
+
const visuals = ['heatmap', 'bar', 'sparkline', 'image', 'flag', 'categoryColors'].filter(
|
|
321
|
+
(v) => col[v] != null && col[v] !== false,
|
|
322
|
+
);
|
|
323
|
+
if (visuals.length > 1) {
|
|
324
|
+
errors.push({
|
|
325
|
+
message: `Spec error: columns[${i}] has multiple visual features (${visuals.join(', ')}). Only one is allowed per column.`,
|
|
326
|
+
path: `columns[${i}]`,
|
|
327
|
+
code: 'INVALID_VALUE',
|
|
328
|
+
suggestion: `Keep only one visual feature per column. Remove all but one of: ${visuals.join(', ')}`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Graph validation
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
339
|
+
// Validate nodes array exists and is non-empty
|
|
340
|
+
if (!Array.isArray(spec.nodes)) {
|
|
341
|
+
errors.push({
|
|
342
|
+
message: 'Spec error: graph spec requires a "nodes" array',
|
|
343
|
+
path: 'nodes',
|
|
344
|
+
code: 'MISSING_FIELD',
|
|
345
|
+
suggestion:
|
|
346
|
+
'Add a nodes array with objects that have an "id" field, e.g. nodes: [{ id: "a" }, { id: "b" }]',
|
|
347
|
+
});
|
|
348
|
+
return; // Can't validate further without nodes
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (spec.nodes.length === 0) {
|
|
352
|
+
errors.push({
|
|
353
|
+
message: 'Spec error: "nodes" must be a non-empty array',
|
|
354
|
+
path: 'nodes',
|
|
355
|
+
code: 'EMPTY_DATA',
|
|
356
|
+
suggestion: 'Add at least one node, e.g. nodes: [{ id: "a" }]',
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Validate each node has a string id
|
|
362
|
+
const nodeIds = new Set<string>();
|
|
363
|
+
const nodes = spec.nodes as Record<string, unknown>[];
|
|
364
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
365
|
+
const node = nodes[i];
|
|
366
|
+
if (!node || typeof node !== 'object') {
|
|
367
|
+
errors.push({
|
|
368
|
+
message: `Spec error: nodes[${i}] must be an object`,
|
|
369
|
+
path: `nodes[${i}]`,
|
|
370
|
+
code: 'INVALID_TYPE',
|
|
371
|
+
suggestion: 'Each node must be an object with at least an "id" field, e.g. { id: "a" }',
|
|
372
|
+
});
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (typeof node.id !== 'string' || node.id === '') {
|
|
376
|
+
errors.push({
|
|
377
|
+
message: `Spec error: nodes[${i}] must have a non-empty string "id" field`,
|
|
378
|
+
path: `nodes[${i}].id`,
|
|
379
|
+
code: 'MISSING_FIELD',
|
|
380
|
+
suggestion: 'Add a string id to the node, e.g. { id: "node1" }',
|
|
381
|
+
});
|
|
382
|
+
} else {
|
|
383
|
+
nodeIds.add(node.id);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Validate edges array exists
|
|
388
|
+
if (!Array.isArray(spec.edges)) {
|
|
389
|
+
errors.push({
|
|
390
|
+
message: 'Spec error: graph spec requires an "edges" array',
|
|
391
|
+
path: 'edges',
|
|
392
|
+
code: 'MISSING_FIELD',
|
|
393
|
+
suggestion: 'Add an edges array (can be empty), e.g. edges: [{ source: "a", target: "b" }]',
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Validate each edge has string source and target that reference existing nodes
|
|
399
|
+
const edges = spec.edges as Record<string, unknown>[];
|
|
400
|
+
for (let i = 0; i < edges.length; i++) {
|
|
401
|
+
const edge = edges[i];
|
|
402
|
+
if (!edge || typeof edge !== 'object') {
|
|
403
|
+
errors.push({
|
|
404
|
+
message: `Spec error: edges[${i}] must be an object`,
|
|
405
|
+
path: `edges[${i}]`,
|
|
406
|
+
code: 'INVALID_TYPE',
|
|
407
|
+
suggestion:
|
|
408
|
+
'Each edge must be an object with "source" and "target" fields, e.g. { source: "a", target: "b" }',
|
|
409
|
+
});
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (typeof edge.source !== 'string' || edge.source === '') {
|
|
414
|
+
errors.push({
|
|
415
|
+
message: `Spec error: edges[${i}] must have a non-empty string "source" field`,
|
|
416
|
+
path: `edges[${i}].source`,
|
|
417
|
+
code: 'MISSING_FIELD',
|
|
418
|
+
suggestion: 'Add a source node id, e.g. { source: "a", target: "b" }',
|
|
419
|
+
});
|
|
420
|
+
} else if (nodeIds.size > 0 && !nodeIds.has(edge.source)) {
|
|
421
|
+
errors.push({
|
|
422
|
+
message: `Spec error: edges[${i}].source "${edge.source}" does not reference an existing node id`,
|
|
423
|
+
path: `edges[${i}].source`,
|
|
424
|
+
code: 'DATA_FIELD_MISSING',
|
|
425
|
+
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (typeof edge.target !== 'string' || edge.target === '') {
|
|
430
|
+
errors.push({
|
|
431
|
+
message: `Spec error: edges[${i}] must have a non-empty string "target" field`,
|
|
432
|
+
path: `edges[${i}].target`,
|
|
433
|
+
code: 'MISSING_FIELD',
|
|
434
|
+
suggestion: 'Add a target node id, e.g. { source: "a", target: "b" }',
|
|
435
|
+
});
|
|
436
|
+
} else if (nodeIds.size > 0 && !nodeIds.has(edge.target)) {
|
|
437
|
+
errors.push({
|
|
438
|
+
message: `Spec error: edges[${i}].target "${edge.target}" does not reference an existing node id`,
|
|
439
|
+
path: `edges[${i}].target`,
|
|
440
|
+
code: 'DATA_FIELD_MISSING',
|
|
441
|
+
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(', ')}${nodeIds.size > 5 ? '...' : ''}`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Validate encoding fields exist on at least the first node/edge
|
|
447
|
+
if (spec.encoding && typeof spec.encoding === 'object') {
|
|
448
|
+
const encoding = spec.encoding as Record<string, unknown>;
|
|
449
|
+
const firstNode = nodes[0] as Record<string, unknown>;
|
|
450
|
+
const firstEdge = edges.length > 0 ? (edges[0] as Record<string, unknown>) : null;
|
|
451
|
+
const nodeFields = firstNode ? new Set(Object.keys(firstNode)) : new Set<string>();
|
|
452
|
+
const edgeFields = firstEdge ? new Set(Object.keys(firstEdge)) : new Set<string>();
|
|
453
|
+
|
|
454
|
+
const nodeChannels = ['nodeColor', 'nodeSize', 'nodeLabel'] as const;
|
|
455
|
+
for (const channel of nodeChannels) {
|
|
456
|
+
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
457
|
+
if (ch?.field && typeof ch.field === 'string' && !nodeFields.has(ch.field)) {
|
|
458
|
+
errors.push({
|
|
459
|
+
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on nodes. Available fields: ${[...nodeFields].join(', ')}`,
|
|
460
|
+
path: `encoding.${channel}.field`,
|
|
461
|
+
code: 'DATA_FIELD_MISSING',
|
|
462
|
+
suggestion: `Use one of the node fields: ${[...nodeFields].join(', ')}`,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const edgeChannels = ['edgeColor', 'edgeWidth'] as const;
|
|
468
|
+
for (const channel of edgeChannels) {
|
|
469
|
+
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
470
|
+
if (ch?.field && typeof ch.field === 'string' && firstEdge && !edgeFields.has(ch.field)) {
|
|
471
|
+
errors.push({
|
|
472
|
+
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on edges. Available fields: ${[...edgeFields].join(', ')}`,
|
|
473
|
+
path: `encoding.${channel}.field`,
|
|
474
|
+
code: 'DATA_FIELD_MISSING',
|
|
475
|
+
suggestion: `Use one of the edge fields: ${[...edgeFields].join(', ')}`,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Validate layout type if specified
|
|
482
|
+
if (spec.layout && typeof spec.layout === 'object') {
|
|
483
|
+
const layout = spec.layout as Record<string, unknown>;
|
|
484
|
+
if (layout.type && layout.type !== 'force') {
|
|
485
|
+
errors.push({
|
|
486
|
+
message: `Spec error: layout.type "${layout.type}" is not supported. Only "force" is currently supported`,
|
|
487
|
+
path: 'layout.type',
|
|
488
|
+
code: 'INVALID_VALUE',
|
|
489
|
+
suggestion:
|
|
490
|
+
'Use layout.type: "force" or omit the layout field to use the default force layout',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// Public API
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Validate a spec at runtime.
|
|
502
|
+
*
|
|
503
|
+
* Checks structure, required fields, encoding rules, data shape, and
|
|
504
|
+
* field type compatibility. Returns structured errors with machine-readable
|
|
505
|
+
* codes and actionable suggestions for each problem found.
|
|
506
|
+
*/
|
|
507
|
+
export function validateSpec(spec: unknown): ValidationResult {
|
|
508
|
+
const errors: ValidationError[] = [];
|
|
509
|
+
|
|
510
|
+
// Basic shape check
|
|
511
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
512
|
+
return {
|
|
513
|
+
valid: false,
|
|
514
|
+
errors: [
|
|
515
|
+
{
|
|
516
|
+
message: 'Spec error: spec must be a non-null object',
|
|
517
|
+
code: 'INVALID_TYPE',
|
|
518
|
+
suggestion:
|
|
519
|
+
'Pass a spec object with at least a "type" field, e.g. { type: "line", data: [...], encoding: {...} }',
|
|
520
|
+
},
|
|
521
|
+
],
|
|
522
|
+
normalized: null,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const obj = spec as Record<string, unknown>;
|
|
527
|
+
|
|
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);
|
|
545
|
+
const isTable = obj.type === 'table';
|
|
546
|
+
const isGraph = obj.type === 'graph';
|
|
547
|
+
|
|
548
|
+
if (!isChart && !isTable && !isGraph) {
|
|
549
|
+
return {
|
|
550
|
+
valid: false,
|
|
551
|
+
errors: [
|
|
552
|
+
{
|
|
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`,
|
|
557
|
+
},
|
|
558
|
+
],
|
|
559
|
+
normalized: null,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Type-specific validation
|
|
564
|
+
if (isChart) {
|
|
565
|
+
validateChartSpec(obj, errors);
|
|
566
|
+
} else if (isTable) {
|
|
567
|
+
validateTableSpec(obj, errors);
|
|
568
|
+
} else if (isGraph) {
|
|
569
|
+
validateGraphSpec(obj, errors);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (errors.length > 0) {
|
|
573
|
+
return { valid: false, errors, normalized: null };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
valid: true,
|
|
578
|
+
errors: [],
|
|
579
|
+
normalized: spec as VizSpec,
|
|
580
|
+
};
|
|
581
|
+
}
|