@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.
Files changed (85) hide show
  1. package/dist/index.d.ts +366 -0
  2. package/dist/index.js +4227 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +62 -0
  5. package/src/__test-fixtures__/specs.ts +124 -0
  6. package/src/__tests__/axes.test.ts +114 -0
  7. package/src/__tests__/compile-chart.test.ts +337 -0
  8. package/src/__tests__/dimensions.test.ts +151 -0
  9. package/src/__tests__/legend.test.ts +113 -0
  10. package/src/__tests__/scales.test.ts +109 -0
  11. package/src/annotations/__tests__/compute.test.ts +454 -0
  12. package/src/annotations/compute.ts +603 -0
  13. package/src/charts/__tests__/registry.test.ts +110 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +294 -0
  15. package/src/charts/bar/__tests__/labels.test.ts +75 -0
  16. package/src/charts/bar/compute.ts +205 -0
  17. package/src/charts/bar/index.ts +33 -0
  18. package/src/charts/bar/labels.ts +132 -0
  19. package/src/charts/column/__tests__/compute.test.ts +277 -0
  20. package/src/charts/column/compute.ts +282 -0
  21. package/src/charts/column/index.ts +33 -0
  22. package/src/charts/column/labels.ts +108 -0
  23. package/src/charts/dot/__tests__/compute.test.ts +344 -0
  24. package/src/charts/dot/compute.ts +257 -0
  25. package/src/charts/dot/index.ts +46 -0
  26. package/src/charts/dot/labels.ts +97 -0
  27. package/src/charts/line/__tests__/compute.test.ts +437 -0
  28. package/src/charts/line/__tests__/labels.test.ts +93 -0
  29. package/src/charts/line/area.ts +288 -0
  30. package/src/charts/line/compute.ts +177 -0
  31. package/src/charts/line/index.ts +68 -0
  32. package/src/charts/line/labels.ts +144 -0
  33. package/src/charts/pie/__tests__/compute.test.ts +276 -0
  34. package/src/charts/pie/compute.ts +234 -0
  35. package/src/charts/pie/index.ts +49 -0
  36. package/src/charts/pie/labels.ts +142 -0
  37. package/src/charts/registry.ts +64 -0
  38. package/src/charts/scatter/__tests__/compute.test.ts +304 -0
  39. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  40. package/src/charts/scatter/compute.ts +124 -0
  41. package/src/charts/scatter/index.ts +41 -0
  42. package/src/charts/scatter/trendline.ts +100 -0
  43. package/src/charts/utils.ts +120 -0
  44. package/src/compile.ts +368 -0
  45. package/src/compiler/__tests__/compile.test.ts +87 -0
  46. package/src/compiler/__tests__/normalize.test.ts +210 -0
  47. package/src/compiler/__tests__/validate.test.ts +440 -0
  48. package/src/compiler/index.ts +47 -0
  49. package/src/compiler/normalize.ts +269 -0
  50. package/src/compiler/types.ts +148 -0
  51. package/src/compiler/validate.ts +581 -0
  52. package/src/graphs/__tests__/community.test.ts +228 -0
  53. package/src/graphs/__tests__/compile-graph.test.ts +315 -0
  54. package/src/graphs/__tests__/encoding.test.ts +314 -0
  55. package/src/graphs/community.ts +92 -0
  56. package/src/graphs/compile-graph.ts +291 -0
  57. package/src/graphs/encoding.ts +302 -0
  58. package/src/graphs/types.ts +98 -0
  59. package/src/index.ts +74 -0
  60. package/src/layout/axes.ts +194 -0
  61. package/src/layout/dimensions.ts +199 -0
  62. package/src/layout/gridlines.ts +84 -0
  63. package/src/layout/scales.ts +426 -0
  64. package/src/legend/compute.ts +186 -0
  65. package/src/tables/__tests__/bar-column.test.ts +147 -0
  66. package/src/tables/__tests__/category-colors.test.ts +153 -0
  67. package/src/tables/__tests__/compile-table.test.ts +208 -0
  68. package/src/tables/__tests__/format-cells.test.ts +126 -0
  69. package/src/tables/__tests__/heatmap.test.ts +124 -0
  70. package/src/tables/__tests__/pagination.test.ts +78 -0
  71. package/src/tables/__tests__/search.test.ts +94 -0
  72. package/src/tables/__tests__/sort.test.ts +107 -0
  73. package/src/tables/__tests__/sparkline.test.ts +122 -0
  74. package/src/tables/bar-column.ts +94 -0
  75. package/src/tables/category-colors.ts +67 -0
  76. package/src/tables/compile-table.ts +420 -0
  77. package/src/tables/format-cells.ts +110 -0
  78. package/src/tables/heatmap.ts +121 -0
  79. package/src/tables/pagination.ts +46 -0
  80. package/src/tables/search.ts +66 -0
  81. package/src/tables/sort.ts +69 -0
  82. package/src/tables/sparkline.ts +113 -0
  83. package/src/tables/utils.ts +16 -0
  84. package/src/tooltips/__tests__/compute.test.ts +328 -0
  85. 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
+ }