@opendata-ai/openchart-engine 6.25.3 → 6.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.25.3",
3
+ "version": "6.26.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "6.25.3",
51
+ "@opendata-ai/openchart-core": "6.26.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileChart } from '../compile';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Test data
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const albumData = [
9
+ { album: 'Abbey Road', artist: 'The Beatles', sales: 31 },
10
+ { album: 'Thriller', artist: 'Michael Jackson', sales: 66 },
11
+ { album: 'Back in Black', artist: 'AC/DC', sales: 50 },
12
+ { album: 'The Dark Side of the Moon', artist: 'Pink Floyd', sales: 45 },
13
+ { album: 'Rumours', artist: 'Fleetwood Mac', sales: 40 },
14
+ ];
15
+
16
+ function makeBarSpec(axisConfig?: Record<string, unknown>) {
17
+ return {
18
+ mark: 'bar' as const,
19
+ data: albumData,
20
+ encoding: {
21
+ x: { field: 'sales', type: 'quantitative' as const },
22
+ y: {
23
+ field: 'album',
24
+ type: 'nominal' as const,
25
+ axis: axisConfig,
26
+ },
27
+ },
28
+ };
29
+ }
30
+
31
+ const compileOpts = { width: 600, height: 400 };
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Tests
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe('compound axis labels (labelField)', () => {
38
+ it('populates subtitle on ticks when labelField is set', () => {
39
+ const spec = makeBarSpec({ labelField: 'artist' });
40
+ const layout = compileChart(spec, compileOpts);
41
+
42
+ const yTicks = layout.axes.y?.ticks ?? [];
43
+ expect(yTicks.length).toBeGreaterThan(0);
44
+
45
+ // Every tick should have a subtitle matching the artist for that album
46
+ for (const tick of yTicks) {
47
+ const row = albumData.find((r) => r.album === tick.label);
48
+ expect(row).toBeDefined();
49
+ expect(tick.subtitle).toBe(row!.artist);
50
+ }
51
+ });
52
+
53
+ it('does not add subtitle when labelField is omitted', () => {
54
+ const spec = makeBarSpec();
55
+ const layout = compileChart(spec, compileOpts);
56
+
57
+ const yTicks = layout.axes.y?.ticks ?? [];
58
+ expect(yTicks.length).toBeGreaterThan(0);
59
+
60
+ for (const tick of yTicks) {
61
+ expect(tick.subtitle).toBeUndefined();
62
+ }
63
+ });
64
+
65
+ it('handles missing labelField value gracefully', () => {
66
+ const dataWithMissing = [
67
+ { album: 'Abbey Road', artist: 'The Beatles', sales: 31 },
68
+ { album: 'Unknown Album', sales: 20 }, // no artist field
69
+ ];
70
+ const spec = {
71
+ mark: 'bar' as const,
72
+ data: dataWithMissing,
73
+ encoding: {
74
+ x: { field: 'sales', type: 'quantitative' as const },
75
+ y: {
76
+ field: 'album',
77
+ type: 'nominal' as const,
78
+ axis: { labelField: 'artist' },
79
+ },
80
+ },
81
+ };
82
+
83
+ const layout = compileChart(spec, compileOpts);
84
+ const yTicks = layout.axes.y?.ticks ?? [];
85
+
86
+ // Abbey Road should have a subtitle
87
+ const abbeyRoad = yTicks.find((t) => t.label === 'Abbey Road');
88
+ expect(abbeyRoad?.subtitle).toBe('The Beatles');
89
+
90
+ // Unknown Album has no artist field, so subtitle should be undefined
91
+ const unknown = yTicks.find((t) => t.label === 'Unknown Album');
92
+ expect(unknown?.subtitle).toBeUndefined();
93
+ });
94
+
95
+ it('maps subtitle correctly across multiple ticks', () => {
96
+ const spec = makeBarSpec({ labelField: 'artist' });
97
+ const layout = compileChart(spec, compileOpts);
98
+
99
+ const yTicks = layout.axes.y?.ticks ?? [];
100
+ expect(yTicks.length).toBe(5);
101
+
102
+ // Verify specific mappings
103
+ const thrillerTick = yTicks.find((t) => t.label === 'Thriller');
104
+ expect(thrillerTick?.subtitle).toBe('Michael Jackson');
105
+
106
+ const rumoursTick = yTicks.find((t) => t.label === 'Rumours');
107
+ expect(rumoursTick?.subtitle).toBe('Fleetwood Mac');
108
+ });
109
+
110
+ it('preserves subtitle mapping with sort: descending', () => {
111
+ const spec = {
112
+ mark: 'bar' as const,
113
+ data: albumData,
114
+ encoding: {
115
+ x: { field: 'sales', type: 'quantitative' as const },
116
+ y: {
117
+ field: 'album',
118
+ type: 'nominal' as const,
119
+ sort: 'descending' as const,
120
+ axis: { labelField: 'artist' },
121
+ },
122
+ },
123
+ };
124
+
125
+ const layout = compileChart(spec, compileOpts);
126
+ const yTicks = layout.axes.y?.ticks ?? [];
127
+
128
+ // Regardless of sort order, each tick should still map to the right artist
129
+ for (const tick of yTicks) {
130
+ const row = albumData.find((r) => r.album === tick.label);
131
+ expect(row).toBeDefined();
132
+ expect(tick.subtitle).toBe(row!.artist);
133
+ }
134
+ });
135
+
136
+ it('reserves wider dimension with labelField than without', () => {
137
+ const specWith = makeBarSpec({ labelField: 'artist' });
138
+ const specWithout = makeBarSpec();
139
+
140
+ const layoutWith = compileChart(specWith, compileOpts);
141
+ const layoutWithout = compileChart(specWithout, compileOpts);
142
+
143
+ // Chart area x (left edge) should be larger with labelField because more
144
+ // left margin is reserved for the wider compound labels
145
+ expect(layoutWith.area.x).toBeGreaterThanOrEqual(layoutWithout.area.x);
146
+ });
147
+ });
package/src/compile.ts CHANGED
@@ -77,6 +77,7 @@ import { computeLegend } from './legend/compute';
77
77
  import { legendGap } from './legend/wrap';
78
78
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
79
79
  import { compileTableLayout } from './tables/compile-table';
80
+ import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
80
81
  import { computeTooltipDescriptors } from './tooltips/compute';
81
82
  import { runTransforms } from './transforms';
82
83
 
@@ -237,14 +238,23 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
237
238
  },
238
239
  };
239
240
  }
240
- if (bp.labels) {
241
- chartSpec = {
242
- ...chartSpec,
243
- labels: {
244
- ...chartSpec.labels,
245
- ...(bp.labels as NormalizedChartSpec['labels']),
246
- },
247
- };
241
+ if (bp.labels !== undefined) {
242
+ if (typeof bp.labels === 'boolean') {
243
+ chartSpec = {
244
+ ...chartSpec,
245
+ labels: bp.labels
246
+ ? { density: 'auto', format: '', prefix: '' }
247
+ : { density: 'none', format: '', prefix: '' },
248
+ };
249
+ } else {
250
+ chartSpec = {
251
+ ...chartSpec,
252
+ labels: {
253
+ ...chartSpec.labels,
254
+ ...(bp.labels as NormalizedChartSpec['labels']),
255
+ },
256
+ };
257
+ }
248
258
  }
249
259
  if (bp.legend) {
250
260
  chartSpec = {
@@ -301,7 +311,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
301
311
  // the reserved margin. This way computeLegend positions the legend outside
302
312
  // the data area (in the margin) instead of overlapping data marks.
303
313
  const legendArea: Rect = { ...chartArea };
304
- if (legendLayout.entries.length > 0) {
314
+ if ('entries' in legendLayout && legendLayout.entries.length > 0) {
305
315
  const gap = legendGap(options.width);
306
316
  switch (legendLayout.position) {
307
317
  case 'top':
@@ -358,7 +368,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
358
368
  // Compute axes (skip for radial charts)
359
369
  const axes = isRadial
360
370
  ? { x: undefined, y: undefined }
361
- : computeAxes(scales, chartArea, strategy, theme, options.measureText);
371
+ : computeAxes(scales, chartArea, strategy, theme, options.measureText, {
372
+ data: renderSpec.data,
373
+ encoding: renderSpec.encoding as Encoding,
374
+ });
362
375
 
363
376
  // INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
364
377
  // axes.y.gridlines off the same object. Do not introduce a copy-on-write.
@@ -495,7 +508,8 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
495
508
 
496
509
  const allMarks: Mark[] = [];
497
510
  const seenLabels = new Set<string>();
498
- const mergedLegendEntries = [...primaryLayout.legend.entries];
511
+ const pLegend = primaryLayout.legend;
512
+ const mergedLegendEntries = 'entries' in pLegend ? [...pLegend.entries] : [];
499
513
  for (const entry of mergedLegendEntries) {
500
514
  seenLabels.add(entry.label);
501
515
  }
@@ -513,10 +527,13 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
513
527
 
514
528
  allMarks.push(...leafLayout.marks);
515
529
 
516
- for (const entry of leafLayout.legend.entries) {
517
- if (!seenLabels.has(entry.label)) {
518
- seenLabels.add(entry.label);
519
- mergedLegendEntries.push(entry);
530
+ const leafLeg = leafLayout.legend;
531
+ if ('entries' in leafLeg) {
532
+ for (const entry of leafLeg.entries) {
533
+ if (!seenLabels.has(entry.label)) {
534
+ seenLabels.add(entry.label);
535
+ mergedLegendEntries.push(entry);
536
+ }
520
537
  }
521
538
  }
522
539
  }
@@ -526,8 +543,8 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
526
543
  marks: allMarks,
527
544
  legend: {
528
545
  ...primaryLayout.legend,
529
- entries: mergedLegendEntries,
530
- },
546
+ ...('entries' in pLegend ? { entries: mergedLegendEntries } : {}),
547
+ } as typeof primaryLayout.legend,
531
548
  };
532
549
  }
533
550
 
@@ -803,9 +820,12 @@ function compileLayerIndependent(
803
820
 
804
821
  // Merge legend entries with deduplication
805
822
  const seenLabels = new Set<string>();
806
- const mergedLegendEntries = [...layout0.legend.entries];
823
+ const l0Legend = layout0.legend;
824
+ const l1Legend = layout1.legend;
825
+ const mergedLegendEntries = 'entries' in l0Legend ? [...l0Legend.entries] : [];
807
826
  for (const entry of mergedLegendEntries) seenLabels.add(entry.label);
808
- for (const entry of layout1.legend.entries) {
827
+ const l1Entries = 'entries' in l1Legend ? l1Legend.entries : [];
828
+ for (const entry of l1Entries) {
809
829
  if (!seenLabels.has(entry.label)) {
810
830
  seenLabels.add(entry.label);
811
831
  mergedLegendEntries.push(entry);
@@ -847,8 +867,8 @@ function compileLayerIndependent(
847
867
  marks,
848
868
  legend: {
849
869
  ...layout0.legend,
850
- entries: mergedLegendEntries,
851
- },
870
+ ...('entries' in l0Legend ? { entries: mergedLegendEntries } : {}),
871
+ } as typeof layout0.legend,
852
872
  tooltipDescriptors: mergedTooltips,
853
873
  };
854
874
  }
@@ -1102,3 +1122,26 @@ export function compileSankey(
1102
1122
  ): import('@opendata-ai/openchart-core').SankeyLayout {
1103
1123
  return compileSankeyImpl(spec, options);
1104
1124
  }
1125
+
1126
+ // ---------------------------------------------------------------------------
1127
+ // TileMap compilation
1128
+ // ---------------------------------------------------------------------------
1129
+
1130
+ /**
1131
+ * Compile a tilemap spec into a TileMapLayout.
1132
+ *
1133
+ * Takes a raw tilemap spec, validates, normalizes, resolves theme and chrome,
1134
+ * computes tile positions, builds tile marks with colors and labels, and
1135
+ * returns a TileMapLayout ready for rendering.
1136
+ *
1137
+ * @param spec - Raw tilemap spec (validated and normalized internally).
1138
+ * @param options - Compile options (width, height, theme, darkMode).
1139
+ * @returns TileMapLayout with computed positions and visual properties.
1140
+ * @throws Error if spec is invalid or not a tilemap type.
1141
+ */
1142
+ export function compileTileMap(
1143
+ spec: unknown,
1144
+ options: CompileOptions,
1145
+ ): import('@opendata-ai/openchart-core').TileMapLayout {
1146
+ return compileTileMapImpl(spec, options);
1147
+ }
@@ -17,9 +17,11 @@ import type {
17
17
  Encoding,
18
18
  FieldType,
19
19
  GraphSpec,
20
+ LabelSpec,
20
21
  LayerSpec,
21
22
  SankeySpec,
22
23
  TableSpec,
24
+ TileMapSpec,
23
25
  VizSpec,
24
26
  } from '@opendata-ai/openchart-core';
25
27
  import {
@@ -28,10 +30,13 @@ import {
28
30
  isLayerSpec,
29
31
  isSankeySpec,
30
32
  isTableSpec,
33
+ isTileMapSpec,
31
34
  resolveMarkDef,
32
35
  resolveMarkType,
33
36
  } from '@opendata-ai/openchart-core';
34
37
  import type { NormalizedSankeySpec } from '../sankey/types';
38
+ import { STATE_CODE_SET } from '../tilemap/layout';
39
+ import type { NormalizedTileMapSpec } from '../tilemap/types';
35
40
  import type {
36
41
  NormalizedChartSpec,
37
42
  NormalizedChrome,
@@ -189,6 +194,21 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation
189
194
  });
190
195
  }
191
196
 
197
+ // ---------------------------------------------------------------------------
198
+ // Label normalization
199
+ // ---------------------------------------------------------------------------
200
+
201
+ function normalizeLabels(labels?: LabelSpec): NormalizedChartSpec['labels'] {
202
+ if (labels === false) return { density: 'none', format: '', prefix: '' };
203
+ if (labels === true || labels === undefined) return { density: 'auto', format: '', prefix: '' };
204
+ return {
205
+ density: labels.density ?? 'auto',
206
+ format: labels.format ?? '',
207
+ prefix: labels.prefix ?? '',
208
+ offsets: labels.offsets,
209
+ };
210
+ }
211
+
192
212
  // ---------------------------------------------------------------------------
193
213
  // Spec-level normalization
194
214
  // ---------------------------------------------------------------------------
@@ -205,12 +225,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
205
225
  encoding,
206
226
  chrome: normalizeChrome(spec.chrome),
207
227
  annotations: normalizeAnnotations(spec.annotations),
208
- labels: {
209
- density: spec.labels?.density ?? 'auto',
210
- format: spec.labels?.format ?? '',
211
- prefix: spec.labels?.prefix ?? '',
212
- offsets: spec.labels?.offsets,
213
- },
228
+ labels: normalizeLabels(spec.labels),
214
229
  legend: spec.legend,
215
230
  responsive: spec.responsive ?? true,
216
231
  theme: spec.theme ?? {},
@@ -292,6 +307,55 @@ function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGra
292
307
  };
293
308
  }
294
309
 
310
+ function normalizeTileMapSpec(spec: TileMapSpec, warnings: string[]): NormalizedTileMapSpec {
311
+ // Convert record data to array if needed
312
+ let data: Record<string, unknown>[] = Array.isArray(spec.data) ? spec.data : [];
313
+
314
+ if (!Array.isArray(spec.data)) {
315
+ // Convert record map to array of rows
316
+ data = Object.entries(spec.data).map(([state, value]) => ({ state, value }));
317
+ }
318
+
319
+ // Auto-generate encoding if not provided
320
+ let encoding = spec.encoding;
321
+ if (!encoding) {
322
+ encoding = {
323
+ state: { field: 'state', type: 'nominal' },
324
+ value: { field: 'value', type: 'quantitative' },
325
+ };
326
+ }
327
+
328
+ // Count matched states and warn if low match ratio
329
+ let matchedCount = 0;
330
+ for (const row of data) {
331
+ const stateCode = String(row[encoding.state.field]);
332
+ if (STATE_CODE_SET.has(stateCode)) {
333
+ matchedCount++;
334
+ }
335
+ }
336
+
337
+ const matchRatio = data.length > 0 ? matchedCount / data.length : 0;
338
+ if (matchRatio < 0.5 && data.length > 0) {
339
+ warnings.push(
340
+ `TileMap data: only ${matchedCount} of ${data.length} rows have valid US state codes (expected ≥50%)`,
341
+ );
342
+ }
343
+
344
+ return {
345
+ type: 'tilemap',
346
+ data,
347
+ encoding,
348
+ palette: spec.palette ?? 'blue',
349
+ chrome: normalizeChrome(spec.chrome),
350
+ legend: spec.legend,
351
+ theme: spec.theme ?? {},
352
+ darkMode: spec.darkMode ?? 'off',
353
+ watermark: spec.watermark ?? true,
354
+ animation: spec.animation,
355
+ valueFormat: spec.valueFormat,
356
+ };
357
+ }
358
+
295
359
  // ---------------------------------------------------------------------------
296
360
  // Public API
297
361
  // ---------------------------------------------------------------------------
@@ -326,9 +390,12 @@ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): Normalize
326
390
  if (isSankeySpec(spec)) {
327
391
  return normalizeSankeySpec(spec, warnings);
328
392
  }
393
+ if (isTileMapSpec(spec)) {
394
+ return normalizeTileMapSpec(spec, warnings);
395
+ }
329
396
  // Should never happen after validation
330
397
  throw new Error(
331
- `Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', or type: 'sankey'.`,
398
+ `Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', type: 'sankey', or type: 'tilemap'.`,
332
399
  );
333
400
  }
334
401
 
@@ -29,6 +29,7 @@ import type {
29
29
  ThemeConfig,
30
30
  } from '@opendata-ai/openchart-core';
31
31
  import type { NormalizedSankeySpec } from '../sankey/types';
32
+ import type { NormalizedTileMapSpec } from '../tilemap/types';
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // NormalizedChrome: all fields are ChromeText objects (not plain strings)
@@ -124,7 +125,8 @@ export type NormalizedSpec =
124
125
  | NormalizedChartSpec
125
126
  | NormalizedTableSpec
126
127
  | NormalizedGraphSpec
127
- | NormalizedSankeySpec;
128
+ | NormalizedSankeySpec
129
+ | NormalizedTileMapSpec;
128
130
 
129
131
  // ---------------------------------------------------------------------------
130
132
  // Validation types
@@ -645,6 +645,120 @@ function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationErr
645
645
  }
646
646
  }
647
647
 
648
+ // ---------------------------------------------------------------------------
649
+ // TileMap validation
650
+ // ---------------------------------------------------------------------------
651
+
652
+ function validateTileMapSpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
653
+ // Validate data (can be record or array)
654
+ if (!spec.data || typeof spec.data !== 'object') {
655
+ errors.push({
656
+ message: 'Spec error: tilemap spec requires a "data" field (record or array)',
657
+ path: 'data',
658
+ code: 'INVALID_TYPE',
659
+ suggestion:
660
+ 'Provide data as either a record mapping state codes to values (e.g. { "CA": 12000, "TX": 8500 }) or an array of objects with state and value fields',
661
+ });
662
+ return;
663
+ }
664
+
665
+ // If data is an object (record), validate it has at least one entry
666
+ if (!Array.isArray(spec.data) && Object.keys(spec.data as Record<string, unknown>).length === 0) {
667
+ errors.push({
668
+ message: 'Spec error: "data" must have at least one entry',
669
+ path: 'data',
670
+ code: 'EMPTY_DATA',
671
+ suggestion: 'Add at least one state-value pair, e.g. { "CA": 12000 }',
672
+ });
673
+ return;
674
+ }
675
+
676
+ // If data is an array, validate it's non-empty
677
+ if (Array.isArray(spec.data)) {
678
+ if (spec.data.length === 0) {
679
+ errors.push({
680
+ message: 'Spec error: "data" array must be non-empty',
681
+ path: 'data',
682
+ code: 'EMPTY_DATA',
683
+ suggestion: 'Add at least one data row',
684
+ });
685
+ return;
686
+ }
687
+
688
+ const firstRow = spec.data[0] as unknown;
689
+ if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
690
+ errors.push({
691
+ message: 'Spec error: each item in "data" must be a plain object',
692
+ path: 'data[0]',
693
+ code: 'INVALID_TYPE',
694
+ suggestion: 'Each data item should be an object, e.g. { state: "CA", value: 12000 }',
695
+ });
696
+ return;
697
+ }
698
+
699
+ // If data is array, encoding is required
700
+ if (!spec.encoding || typeof spec.encoding !== 'object') {
701
+ errors.push({
702
+ message:
703
+ 'Spec error: tilemap spec with array data requires an "encoding" object with state and value channels',
704
+ path: 'encoding',
705
+ code: 'MISSING_FIELD',
706
+ suggestion:
707
+ 'Add an encoding object, e.g. encoding: { state: { field: "state", type: "nominal" }, value: { field: "value", type: "quantitative" } }',
708
+ });
709
+ return;
710
+ }
711
+
712
+ const encoding = spec.encoding as Record<string, unknown>;
713
+ const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
714
+ const availableColumns = [...dataColumns].join(', ');
715
+
716
+ // Required channels
717
+ for (const channel of ['state', 'value'] as const) {
718
+ const ch = encoding[channel] as Record<string, unknown> | undefined;
719
+ if (!ch || typeof ch !== 'object') {
720
+ errors.push({
721
+ message: `Spec error: tilemap encoding requires "${channel}" channel`,
722
+ path: `encoding.${channel}`,
723
+ code: 'MISSING_FIELD',
724
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
725
+ });
726
+ continue;
727
+ }
728
+
729
+ if (!ch.field || typeof ch.field !== 'string') {
730
+ errors.push({
731
+ message: `Spec error: encoding.${channel} must have a "field" string`,
732
+ path: `encoding.${channel}.field`,
733
+ code: 'MISSING_FIELD',
734
+ suggestion: `Add a field name from your data columns: ${availableColumns}`,
735
+ });
736
+ continue;
737
+ }
738
+
739
+ if (!dataColumns.has(ch.field as string)) {
740
+ errors.push({
741
+ message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
742
+ path: `encoding.${channel}.field`,
743
+ code: 'DATA_FIELD_MISSING',
744
+ suggestion: `Use one of the available data columns: ${availableColumns}`,
745
+ });
746
+ }
747
+ }
748
+ }
749
+
750
+ // Validate darkMode if provided
751
+ if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
752
+ errors.push({
753
+ message: 'Spec error: darkMode must be "auto", "force", or "off"',
754
+ path: 'darkMode',
755
+ code: 'INVALID_VALUE',
756
+ suggestion:
757
+ 'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
758
+ });
759
+ }
760
+ }
761
+
648
762
  // ---------------------------------------------------------------------------
649
763
  // Layer validation
650
764
  // ---------------------------------------------------------------------------
@@ -775,24 +889,27 @@ export function validateSpec(spec: unknown): ValidationResult {
775
889
  // - Chart specs have a 'mark' field (string or object with type property)
776
890
  // - Table specs have type: 'table'
777
891
  // - Graph specs have type: 'graph'
892
+ // - Sankey specs have type: 'sankey'
893
+ // - TileMap specs have type: 'tilemap'
778
894
  const hasLayer = 'layer' in obj && Array.isArray(obj.layer);
779
895
  const hasMark = 'mark' in obj;
780
896
  const isTable = obj.type === 'table';
781
897
  const isGraph = obj.type === 'graph';
782
898
  const isSankey = obj.type === 'sankey';
783
- const isLayer = hasLayer && !isTable && !isGraph && !isSankey;
784
- const isChart = hasMark && !hasLayer && !isTable && !isGraph && !isSankey;
899
+ const isTileMap = obj.type === 'tilemap';
900
+ const isLayer = hasLayer && !isTable && !isGraph && !isSankey && !isTileMap;
901
+ const isChart = hasMark && !hasLayer && !isTable && !isGraph && !isSankey && !isTileMap;
785
902
 
786
- if (!isChart && !isTable && !isGraph && !isSankey && !isLayer) {
903
+ if (!isChart && !isTable && !isGraph && !isSankey && !isTileMap && !isLayer) {
787
904
  return {
788
905
  valid: false,
789
906
  errors: [
790
907
  {
791
908
  message:
792
- 'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs/sankey',
909
+ 'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs/sankey/tilemap',
793
910
  path: 'mark',
794
911
  code: 'MISSING_FIELD',
795
- suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field for tables/graphs/sankey (type: "table", type: "graph", or type: "sankey"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
912
+ suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field for tables/graphs/sankey/tilemap (type: "table", type: "graph", type: "sankey", or type: "tilemap"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
796
913
  },
797
914
  ],
798
915
  normalized: null,
@@ -837,6 +954,8 @@ export function validateSpec(spec: unknown): ValidationResult {
837
954
  validateGraphSpec(obj, errors);
838
955
  } else if (isSankey) {
839
956
  validateSankeySpec(obj, errors);
957
+ } else if (isTileMap) {
958
+ validateTileMapSpec(obj, errors);
840
959
  }
841
960
 
842
961
  if (errors.length > 0) {
package/src/index.ts CHANGED
@@ -12,7 +12,14 @@
12
12
  // Main compile API
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
- export { compileChart, compileGraph, compileLayer, compileSankey, compileTable } from './compile';
15
+ export {
16
+ compileChart,
17
+ compileGraph,
18
+ compileLayer,
19
+ compileSankey,
20
+ compileTable,
21
+ compileTileMap,
22
+ } from './compile';
16
23
 
17
24
  // ---------------------------------------------------------------------------
18
25
  // Animation resolution
@@ -37,6 +44,12 @@ export type {
37
44
 
38
45
  export type { NormalizedSankeySpec } from './sankey/types';
39
46
 
47
+ // ---------------------------------------------------------------------------
48
+ // TileMap compilation types
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export type { NormalizedTileMapSpec } from './tilemap/types';
52
+
40
53
  // ---------------------------------------------------------------------------
41
54
  // Compiler pipeline (spec validation, normalization, generic compile)
42
55
  // ---------------------------------------------------------------------------
@@ -100,5 +113,7 @@ export type {
100
113
  SankeySpec,
101
114
  TableLayout,
102
115
  TableSpec,
116
+ TileMapLayout,
117
+ TileMapSpec,
103
118
  VizSpec,
104
119
  } from '@opendata-ai/openchart-core';