@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/dist/index.d.ts +46 -4
- package/dist/index.js +888 -80
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compound-labels.test.ts +147 -0
- package/src/compile.ts +64 -21
- package/src/compiler/normalize.ts +74 -7
- package/src/compiler/types.ts +3 -1
- package/src/compiler/validate.ts +124 -5
- package/src/index.ts +16 -1
- package/src/layout/axes/ticks.ts +34 -2
- package/src/layout/axes.ts +27 -1
- package/src/layout/dimensions.ts +21 -3
- package/src/sankey/compile-sankey.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +322 -0
- package/src/tilemap/compile-tilemap.ts +383 -0
- package/src/tilemap/layout.ts +172 -0
- package/src/tilemap/types.ts +32 -0
- package/src/transforms/__tests__/filter-relative.test.ts +202 -0
- package/src/transforms/__tests__/window.test.ts +286 -0
- package/src/transforms/filter.ts +108 -3
- package/src/transforms/index.ts +5 -1
- package/src/transforms/predicates.ts +39 -9
- package/src/transforms/window.ts +185 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
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
|
-
|
|
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: '
|
|
398
|
+
`Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', type: 'sankey', or type: 'tilemap'.`,
|
|
332
399
|
);
|
|
333
400
|
}
|
|
334
401
|
|
package/src/compiler/types.ts
CHANGED
|
@@ -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
|
package/src/compiler/validate.ts
CHANGED
|
@@ -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
|
|
784
|
-
const
|
|
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: "
|
|
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 {
|
|
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';
|