@opendata-ai/openchart-engine 6.25.4 → 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 +862 -66
- 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 +47 -13
- package/src/compiler/normalize.ts +57 -1
- 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
|
|
|
@@ -310,7 +311,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
310
311
|
// the reserved margin. This way computeLegend positions the legend outside
|
|
311
312
|
// the data area (in the margin) instead of overlapping data marks.
|
|
312
313
|
const legendArea: Rect = { ...chartArea };
|
|
313
|
-
if (legendLayout.entries.length > 0) {
|
|
314
|
+
if ('entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
314
315
|
const gap = legendGap(options.width);
|
|
315
316
|
switch (legendLayout.position) {
|
|
316
317
|
case 'top':
|
|
@@ -367,7 +368,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
367
368
|
// Compute axes (skip for radial charts)
|
|
368
369
|
const axes = isRadial
|
|
369
370
|
? { x: undefined, y: undefined }
|
|
370
|
-
: 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
|
+
});
|
|
371
375
|
|
|
372
376
|
// INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
|
|
373
377
|
// axes.y.gridlines off the same object. Do not introduce a copy-on-write.
|
|
@@ -504,7 +508,8 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
504
508
|
|
|
505
509
|
const allMarks: Mark[] = [];
|
|
506
510
|
const seenLabels = new Set<string>();
|
|
507
|
-
const
|
|
511
|
+
const pLegend = primaryLayout.legend;
|
|
512
|
+
const mergedLegendEntries = 'entries' in pLegend ? [...pLegend.entries] : [];
|
|
508
513
|
for (const entry of mergedLegendEntries) {
|
|
509
514
|
seenLabels.add(entry.label);
|
|
510
515
|
}
|
|
@@ -522,10 +527,13 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
522
527
|
|
|
523
528
|
allMarks.push(...leafLayout.marks);
|
|
524
529
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
+
}
|
|
529
537
|
}
|
|
530
538
|
}
|
|
531
539
|
}
|
|
@@ -535,8 +543,8 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
|
|
|
535
543
|
marks: allMarks,
|
|
536
544
|
legend: {
|
|
537
545
|
...primaryLayout.legend,
|
|
538
|
-
entries: mergedLegendEntries,
|
|
539
|
-
},
|
|
546
|
+
...('entries' in pLegend ? { entries: mergedLegendEntries } : {}),
|
|
547
|
+
} as typeof primaryLayout.legend,
|
|
540
548
|
};
|
|
541
549
|
}
|
|
542
550
|
|
|
@@ -812,9 +820,12 @@ function compileLayerIndependent(
|
|
|
812
820
|
|
|
813
821
|
// Merge legend entries with deduplication
|
|
814
822
|
const seenLabels = new Set<string>();
|
|
815
|
-
const
|
|
823
|
+
const l0Legend = layout0.legend;
|
|
824
|
+
const l1Legend = layout1.legend;
|
|
825
|
+
const mergedLegendEntries = 'entries' in l0Legend ? [...l0Legend.entries] : [];
|
|
816
826
|
for (const entry of mergedLegendEntries) seenLabels.add(entry.label);
|
|
817
|
-
|
|
827
|
+
const l1Entries = 'entries' in l1Legend ? l1Legend.entries : [];
|
|
828
|
+
for (const entry of l1Entries) {
|
|
818
829
|
if (!seenLabels.has(entry.label)) {
|
|
819
830
|
seenLabels.add(entry.label);
|
|
820
831
|
mergedLegendEntries.push(entry);
|
|
@@ -856,8 +867,8 @@ function compileLayerIndependent(
|
|
|
856
867
|
marks,
|
|
857
868
|
legend: {
|
|
858
869
|
...layout0.legend,
|
|
859
|
-
entries: mergedLegendEntries,
|
|
860
|
-
},
|
|
870
|
+
...('entries' in l0Legend ? { entries: mergedLegendEntries } : {}),
|
|
871
|
+
} as typeof layout0.legend,
|
|
861
872
|
tooltipDescriptors: mergedTooltips,
|
|
862
873
|
};
|
|
863
874
|
}
|
|
@@ -1111,3 +1122,26 @@ export function compileSankey(
|
|
|
1111
1122
|
): import('@opendata-ai/openchart-core').SankeyLayout {
|
|
1112
1123
|
return compileSankeyImpl(spec, options);
|
|
1113
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
|
+
}
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
LayerSpec,
|
|
22
22
|
SankeySpec,
|
|
23
23
|
TableSpec,
|
|
24
|
+
TileMapSpec,
|
|
24
25
|
VizSpec,
|
|
25
26
|
} from '@opendata-ai/openchart-core';
|
|
26
27
|
import {
|
|
@@ -29,10 +30,13 @@ import {
|
|
|
29
30
|
isLayerSpec,
|
|
30
31
|
isSankeySpec,
|
|
31
32
|
isTableSpec,
|
|
33
|
+
isTileMapSpec,
|
|
32
34
|
resolveMarkDef,
|
|
33
35
|
resolveMarkType,
|
|
34
36
|
} from '@opendata-ai/openchart-core';
|
|
35
37
|
import type { NormalizedSankeySpec } from '../sankey/types';
|
|
38
|
+
import { STATE_CODE_SET } from '../tilemap/layout';
|
|
39
|
+
import type { NormalizedTileMapSpec } from '../tilemap/types';
|
|
36
40
|
import type {
|
|
37
41
|
NormalizedChartSpec,
|
|
38
42
|
NormalizedChrome,
|
|
@@ -303,6 +307,55 @@ function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGra
|
|
|
303
307
|
};
|
|
304
308
|
}
|
|
305
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
|
+
|
|
306
359
|
// ---------------------------------------------------------------------------
|
|
307
360
|
// Public API
|
|
308
361
|
// ---------------------------------------------------------------------------
|
|
@@ -337,9 +390,12 @@ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): Normalize
|
|
|
337
390
|
if (isSankeySpec(spec)) {
|
|
338
391
|
return normalizeSankeySpec(spec, warnings);
|
|
339
392
|
}
|
|
393
|
+
if (isTileMapSpec(spec)) {
|
|
394
|
+
return normalizeTileMapSpec(spec, warnings);
|
|
395
|
+
}
|
|
340
396
|
// Should never happen after validation
|
|
341
397
|
throw new Error(
|
|
342
|
-
`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'.`,
|
|
343
399
|
);
|
|
344
400
|
}
|
|
345
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';
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* not from the chart area. Density thinning lives in ./thinning.ts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
AxisLabelDensity,
|
|
10
|
+
AxisTick,
|
|
11
|
+
DataRow,
|
|
12
|
+
MeasureTextFn,
|
|
13
|
+
} from '@opendata-ai/openchart-core';
|
|
9
14
|
import {
|
|
10
15
|
abbreviateNumber,
|
|
11
16
|
buildD3Formatter,
|
|
@@ -221,6 +226,7 @@ export function categoricalTicks(
|
|
|
221
226
|
fontSize?: number,
|
|
222
227
|
fontWeight?: number,
|
|
223
228
|
measureText?: MeasureTextFn,
|
|
229
|
+
subtitleContext?: { data: DataRow[]; fieldName: string; labelField: string },
|
|
224
230
|
): AxisTick[] {
|
|
225
231
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
226
232
|
const domain: string[] = scale.domain();
|
|
@@ -275,6 +281,23 @@ export function categoricalTicks(
|
|
|
275
281
|
}
|
|
276
282
|
// vertical band scale (horizontal bar y-axis): always show all labels
|
|
277
283
|
|
|
284
|
+
let subtitleMap: Map<string, string> | undefined;
|
|
285
|
+
if (subtitleContext) {
|
|
286
|
+
const { data, fieldName, labelField } = subtitleContext;
|
|
287
|
+
if (data.length > 0) {
|
|
288
|
+
subtitleMap = new Map();
|
|
289
|
+
for (const row of data) {
|
|
290
|
+
const key = String(row[fieldName] ?? '');
|
|
291
|
+
if (!subtitleMap.has(key)) {
|
|
292
|
+
const val = row[labelField];
|
|
293
|
+
if (val != null) {
|
|
294
|
+
subtitleMap.set(key, String(val));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
278
301
|
const ticks = selectedValues.map((value: string) => {
|
|
279
302
|
// Band scales: use the center of the band
|
|
280
303
|
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
@@ -282,11 +305,20 @@ export function categoricalTicks(
|
|
|
282
305
|
? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
|
|
283
306
|
: ((scale(value) as number | undefined) ?? 0);
|
|
284
307
|
|
|
285
|
-
|
|
308
|
+
const tick: AxisTick = {
|
|
286
309
|
value,
|
|
287
310
|
position: pos,
|
|
288
311
|
label: value,
|
|
289
312
|
};
|
|
313
|
+
|
|
314
|
+
if (subtitleMap) {
|
|
315
|
+
const subtitle = subtitleMap.get(value);
|
|
316
|
+
if (subtitle !== undefined) {
|
|
317
|
+
tick.subtitle = subtitle;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return tick;
|
|
290
322
|
});
|
|
291
323
|
|
|
292
324
|
return ticks;
|