@opendata-ai/openchart-engine 6.25.4 → 6.27.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 +82 -4
- package/dist/index.js +1027 -76
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +33 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -0
- package/src/__tests__/compile-chart.test.ts +301 -0
- package/src/__tests__/compound-labels.test.ts +147 -0
- package/src/charts/line/area.ts +1 -1
- package/src/charts/line/compute.ts +7 -1
- package/src/compile.ts +222 -17
- package/src/compiler/normalize.ts +83 -1
- package/src/compiler/types.ts +41 -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 +36 -3
- package/src/layout/dimensions.ts +98 -5
- package/src/legend/compute.ts +6 -1
- 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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compileTileMap } from '../../compile';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Shared fixtures
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const basicSpec = {
|
|
9
|
+
type: 'tilemap' as const,
|
|
10
|
+
data: { CA: 5.4, TX: 4.1, NY: 4.5, FL: 3.3, IL: 4.6 } as Record<string, number>,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const fullSpec = {
|
|
14
|
+
type: 'tilemap' as const,
|
|
15
|
+
data: {
|
|
16
|
+
AL: 2.7,
|
|
17
|
+
AK: 6.4,
|
|
18
|
+
AZ: 3.5,
|
|
19
|
+
AR: 3.4,
|
|
20
|
+
CA: 5.4,
|
|
21
|
+
CO: 3.4,
|
|
22
|
+
CT: 4.1,
|
|
23
|
+
DE: 4.4,
|
|
24
|
+
FL: 3.3,
|
|
25
|
+
GA: 3.4,
|
|
26
|
+
HI: 3.2,
|
|
27
|
+
ID: 3.0,
|
|
28
|
+
IL: 4.6,
|
|
29
|
+
IN: 3.3,
|
|
30
|
+
IA: 2.7,
|
|
31
|
+
KS: 3.2,
|
|
32
|
+
KY: 4.4,
|
|
33
|
+
LA: 3.6,
|
|
34
|
+
ME: 3.6,
|
|
35
|
+
MD: 1.8,
|
|
36
|
+
MA: 3.3,
|
|
37
|
+
MI: 4.2,
|
|
38
|
+
MN: 2.8,
|
|
39
|
+
MS: 3.7,
|
|
40
|
+
MO: 3.5,
|
|
41
|
+
MT: 2.9,
|
|
42
|
+
NE: 2.2,
|
|
43
|
+
NV: 5.4,
|
|
44
|
+
NH: 2.4,
|
|
45
|
+
NJ: 4.8,
|
|
46
|
+
NM: 4.1,
|
|
47
|
+
NY: 4.5,
|
|
48
|
+
NC: 3.5,
|
|
49
|
+
ND: 1.9,
|
|
50
|
+
OH: 4.0,
|
|
51
|
+
OK: 3.9,
|
|
52
|
+
OR: 4.2,
|
|
53
|
+
PA: 3.4,
|
|
54
|
+
RI: 3.8,
|
|
55
|
+
SC: 3.3,
|
|
56
|
+
SD: 2.0,
|
|
57
|
+
TN: 3.5,
|
|
58
|
+
TX: 4.1,
|
|
59
|
+
UT: 2.9,
|
|
60
|
+
VT: 2.3,
|
|
61
|
+
VA: 2.9,
|
|
62
|
+
WA: 4.6,
|
|
63
|
+
WV: 4.0,
|
|
64
|
+
WI: 2.9,
|
|
65
|
+
WY: 3.2,
|
|
66
|
+
DC: 5.2,
|
|
67
|
+
} as Record<string, number>,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const defaultOptions = { width: 600, height: 400 };
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Tests
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe('compileTileMap', () => {
|
|
77
|
+
it('always renders all 51 state tiles', () => {
|
|
78
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
79
|
+
expect(result.tiles).toHaveLength(51);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('marks data-bearing states as hasData: true', () => {
|
|
83
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
84
|
+
|
|
85
|
+
const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
|
|
86
|
+
expect(caTile.hasData).toBe(true);
|
|
87
|
+
expect(caTile.value).toBe(5.4);
|
|
88
|
+
|
|
89
|
+
const dataTiles = result.tiles.filter((t) => t.hasData);
|
|
90
|
+
expect(dataTiles).toHaveLength(5);
|
|
91
|
+
const codes = dataTiles.map((t) => t.stateCode).sort();
|
|
92
|
+
expect(codes).toEqual(['CA', 'FL', 'IL', 'NY', 'TX']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('marks missing states as hasData: false with neutral fill', () => {
|
|
96
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
97
|
+
|
|
98
|
+
const akTile = result.tiles.find((t) => t.stateCode === 'AK')!;
|
|
99
|
+
expect(akTile).toBeDefined();
|
|
100
|
+
expect(akTile.hasData).toBe(false);
|
|
101
|
+
expect(akTile.value).toBeNull();
|
|
102
|
+
expect(akTile.formattedValue).toBe('–');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('all tiles have valid position and size (x >= 0, y >= 0, size > 0)', () => {
|
|
106
|
+
const result = compileTileMap(fullSpec, defaultOptions);
|
|
107
|
+
|
|
108
|
+
for (const tile of result.tiles) {
|
|
109
|
+
expect(tile.x).toBeGreaterThanOrEqual(0);
|
|
110
|
+
expect(tile.y).toBeGreaterThanOrEqual(0);
|
|
111
|
+
expect(tile.size).toBeGreaterThan(0);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('data tiles have fill colors from the sequential palette', () => {
|
|
116
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
117
|
+
|
|
118
|
+
const dataTiles = result.tiles.filter((t) => t.hasData);
|
|
119
|
+
for (const tile of dataTiles) {
|
|
120
|
+
expect(tile.fill).toBeTruthy();
|
|
121
|
+
expect(typeof tile.fill).toBe('string');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fills = new Set(dataTiles.map((t) => t.fill));
|
|
125
|
+
expect(fills.size).toBeGreaterThan(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('compiles tabular DataRow[] data with encoding', () => {
|
|
129
|
+
const spec = {
|
|
130
|
+
type: 'tilemap' as const,
|
|
131
|
+
data: [
|
|
132
|
+
{ code: 'CA', rate: 5.4 },
|
|
133
|
+
{ code: 'TX', rate: 4.1 },
|
|
134
|
+
{ code: 'NY', rate: 4.5 },
|
|
135
|
+
],
|
|
136
|
+
encoding: {
|
|
137
|
+
state: { field: 'code', type: 'nominal' as const },
|
|
138
|
+
value: { field: 'rate', type: 'quantitative' as const },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
143
|
+
|
|
144
|
+
expect(result.tiles).toHaveLength(51);
|
|
145
|
+
const dataTiles = result.tiles.filter((t) => t.hasData);
|
|
146
|
+
expect(dataTiles).toHaveLength(3);
|
|
147
|
+
const codes = dataTiles.map((t) => t.stateCode).sort();
|
|
148
|
+
expect(codes).toEqual(['CA', 'NY', 'TX']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('handles null values in record-map data as missing', () => {
|
|
152
|
+
const spec = {
|
|
153
|
+
type: 'tilemap' as const,
|
|
154
|
+
data: { CA: 5.4, TX: null, NY: 4.5 } as Record<string, number | null>,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
158
|
+
|
|
159
|
+
const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
|
|
160
|
+
expect(caTile.hasData).toBe(true);
|
|
161
|
+
|
|
162
|
+
const txTile = result.tiles.find((t) => t.stateCode === 'TX')!;
|
|
163
|
+
expect(txTile.hasData).toBe(false);
|
|
164
|
+
expect(txTile.value).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('gradient legend', () => {
|
|
168
|
+
it('has correct min/max labels', () => {
|
|
169
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
170
|
+
|
|
171
|
+
expect(result.gradientLegend).not.toBeNull();
|
|
172
|
+
expect(result.gradientLegend!.minLabel).toBeTruthy();
|
|
173
|
+
expect(result.gradientLegend!.maxLabel).toBeTruthy();
|
|
174
|
+
expect(Number(result.gradientLegend!.minLabel)).toBeLessThan(
|
|
175
|
+
Number(result.gradientLegend!.maxLabel),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('has colorStops', () => {
|
|
180
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
181
|
+
|
|
182
|
+
expect(result.gradientLegend!.colorStops.length).toBeGreaterThan(0);
|
|
183
|
+
for (const stop of result.gradientLegend!.colorStops) {
|
|
184
|
+
expect(stop.offset).toBeGreaterThanOrEqual(0);
|
|
185
|
+
expect(stop.offset).toBeLessThanOrEqual(1);
|
|
186
|
+
expect(stop.color).toBeTruthy();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('is null when legend.show is false', () => {
|
|
191
|
+
const spec = { ...basicSpec, legend: { show: false } };
|
|
192
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
193
|
+
|
|
194
|
+
expect(result.gradientLegend).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('valueFormat', () => {
|
|
199
|
+
it('applies to tile formattedValue', () => {
|
|
200
|
+
const spec = { ...basicSpec, valueFormat: '.1f' };
|
|
201
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
202
|
+
|
|
203
|
+
const caTile = result.tiles.find((t) => t.stateCode === 'CA');
|
|
204
|
+
expect(caTile).toBeDefined();
|
|
205
|
+
expect(caTile!.formattedValue).toBe('5.4');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('dark mode', () => {
|
|
210
|
+
it('reverses palette direction compared to light mode', () => {
|
|
211
|
+
const lightResult = compileTileMap(basicSpec, defaultOptions);
|
|
212
|
+
const darkResult = compileTileMap(basicSpec, { ...defaultOptions, darkMode: true });
|
|
213
|
+
|
|
214
|
+
const lightFL = lightResult.tiles.find((t) => t.stateCode === 'FL');
|
|
215
|
+
const darkFL = darkResult.tiles.find((t) => t.stateCode === 'FL');
|
|
216
|
+
expect(lightFL!.fill).not.toBe(darkFL!.fill);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('chrome', () => {
|
|
221
|
+
it('resolves title and subtitle', () => {
|
|
222
|
+
const spec = {
|
|
223
|
+
...basicSpec,
|
|
224
|
+
chrome: {
|
|
225
|
+
title: 'Test Title',
|
|
226
|
+
subtitle: 'Test Subtitle',
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
230
|
+
|
|
231
|
+
expect(result.chrome.title).toBeDefined();
|
|
232
|
+
expect(result.chrome.title!.text).toBe('Test Title');
|
|
233
|
+
expect(result.chrome.subtitle).toBeDefined();
|
|
234
|
+
expect(result.chrome.subtitle!.text).toBe('Test Subtitle');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('tooltip descriptors', () => {
|
|
239
|
+
it('contains entries for all tiles', () => {
|
|
240
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
241
|
+
|
|
242
|
+
expect(result.tooltipDescriptors.has('CA')).toBe(true);
|
|
243
|
+
expect(result.tooltipDescriptors.has('TX')).toBe(true);
|
|
244
|
+
expect(result.tooltipDescriptors.size).toBe(51);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('tooltip has title and value field', () => {
|
|
248
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
249
|
+
|
|
250
|
+
const tooltip = result.tooltipDescriptors.get('CA')!;
|
|
251
|
+
expect(tooltip.title).toBe('California');
|
|
252
|
+
expect(tooltip.fields.length).toBeGreaterThan(0);
|
|
253
|
+
expect(tooltip.fields.some((f) => f.label === 'Value')).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('a11y', () => {
|
|
258
|
+
it('generates descriptive alt text', () => {
|
|
259
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
260
|
+
|
|
261
|
+
expect(result.a11y.altText).toContain('Tile map');
|
|
262
|
+
expect(result.a11y.altText).toContain('US states');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('has a data table fallback', () => {
|
|
266
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
267
|
+
|
|
268
|
+
expect(result.a11y.dataTableFallback.length).toBeGreaterThan(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('validation', () => {
|
|
273
|
+
it('throws on non-tilemap spec', () => {
|
|
274
|
+
const chartSpec = {
|
|
275
|
+
mark: 'bar' as const,
|
|
276
|
+
data: [{ x: 1, y: 2 }],
|
|
277
|
+
encoding: {
|
|
278
|
+
x: { field: 'x', type: 'quantitative' as const },
|
|
279
|
+
y: { field: 'y', type: 'quantitative' as const },
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
expect(() => compileTileMap(chartSpec, defaultOptions)).toThrow(/non-tilemap/);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('throws on empty record-map data', () => {
|
|
287
|
+
const spec = {
|
|
288
|
+
type: 'tilemap' as const,
|
|
289
|
+
data: {} as Record<string, number>,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
expect(() => compileTileMap(spec, defaultOptions)).toThrow();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('dimensions', () => {
|
|
297
|
+
it('reflects the compile options', () => {
|
|
298
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
299
|
+
|
|
300
|
+
expect(result.width).toBe(600);
|
|
301
|
+
expect(result.height).toBe(400);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('works with different container sizes', () => {
|
|
305
|
+
const result = compileTileMap(basicSpec, { width: 800, height: 600 });
|
|
306
|
+
|
|
307
|
+
expect(result.width).toBe(800);
|
|
308
|
+
expect(result.height).toBe(600);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('palette', () => {
|
|
313
|
+
it('uses the specified palette (data tiles differ between palettes)', () => {
|
|
314
|
+
const blueResult = compileTileMap(basicSpec, defaultOptions);
|
|
315
|
+
const greenResult = compileTileMap({ ...basicSpec, palette: 'green' }, defaultOptions);
|
|
316
|
+
|
|
317
|
+
const blueCa = blueResult.tiles.find((t) => t.stateCode === 'CA')!;
|
|
318
|
+
const greenCa = greenResult.tiles.find((t) => t.stateCode === 'CA')!;
|
|
319
|
+
expect(blueCa.fill).not.toBe(greenCa.fill);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TileMap compilation pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Takes a raw tilemap spec (unknown shape), validates, normalizes, resolves
|
|
5
|
+
* theme, computes chrome, builds a color scale, computes tile positions and
|
|
6
|
+
* marks, builds legend and tooltips, and returns a TileMapLayout.
|
|
7
|
+
*
|
|
8
|
+
* Pipeline:
|
|
9
|
+
* validate -> normalize -> resolve theme -> dark mode adapt ->
|
|
10
|
+
* compute chrome -> extract data -> build color scale -> compute positions ->
|
|
11
|
+
* build tile marks -> legend -> tooltips -> a11y -> animation ->
|
|
12
|
+
* return TileMapLayout
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
CompileOptions,
|
|
17
|
+
GradientColorStop,
|
|
18
|
+
GradientLegendLayout,
|
|
19
|
+
ResolvedAnimation,
|
|
20
|
+
ResolvedTheme,
|
|
21
|
+
TextStyle,
|
|
22
|
+
TileMapLayout,
|
|
23
|
+
TileMapTileMark,
|
|
24
|
+
TooltipContent,
|
|
25
|
+
TooltipField,
|
|
26
|
+
} from '@opendata-ai/openchart-core';
|
|
27
|
+
import {
|
|
28
|
+
adaptTheme,
|
|
29
|
+
buildD3Formatter,
|
|
30
|
+
computeChrome,
|
|
31
|
+
estimateTextWidth,
|
|
32
|
+
formatNumber,
|
|
33
|
+
resolveTheme,
|
|
34
|
+
SEQUENTIAL_PALETTES,
|
|
35
|
+
} from '@opendata-ai/openchart-core';
|
|
36
|
+
import { scaleLinear } from 'd3-scale';
|
|
37
|
+
|
|
38
|
+
import { resolveAnimation } from '../compiler/animation';
|
|
39
|
+
import { compile as compileSpec } from '../compiler/index';
|
|
40
|
+
import { computeTilePositions, STATE_CODE_SET, STATE_NAMES, US_STATE_TILES } from './layout';
|
|
41
|
+
import type { NormalizedTileMapSpec } from './types';
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Constants
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const TILE_CORNER_RADIUS = 2;
|
|
48
|
+
const TILE_STROKE_WIDTH = 0;
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Public API
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compile a tilemap spec into a TileMapLayout.
|
|
56
|
+
*
|
|
57
|
+
* @param spec - Raw tilemap spec (validated and normalized internally).
|
|
58
|
+
* @param options - Compile options (width, height, theme, darkMode).
|
|
59
|
+
* @returns TileMapLayout with computed positions and visual properties.
|
|
60
|
+
* @throws Error if spec is invalid or not a tilemap type.
|
|
61
|
+
*/
|
|
62
|
+
export function compileTileMap(spec: unknown, options: CompileOptions): TileMapLayout {
|
|
63
|
+
// 1. Validate + normalize via the shared compiler pipeline
|
|
64
|
+
const { spec: normalized } = compileSpec(spec);
|
|
65
|
+
|
|
66
|
+
if (!('type' in normalized) || normalized.type !== 'tilemap') {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'compileTileMap received a non-tilemap spec. Use compileChart, compileTable, compileGraph, or compileSankey instead.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const tilemapSpec = normalized as NormalizedTileMapSpec;
|
|
73
|
+
|
|
74
|
+
// Resolve watermark: explicit spec value wins, then options fallback, then default true.
|
|
75
|
+
const rawWatermark = (spec as Record<string, unknown>).watermark;
|
|
76
|
+
const watermark =
|
|
77
|
+
rawWatermark !== undefined ? tilemapSpec.watermark : (options.watermark ?? true);
|
|
78
|
+
|
|
79
|
+
// 2. Resolve theme
|
|
80
|
+
const mergedThemeConfig = options.theme
|
|
81
|
+
? { ...tilemapSpec.theme, ...options.theme }
|
|
82
|
+
: tilemapSpec.theme;
|
|
83
|
+
const lightTheme: ResolvedTheme = resolveTheme(mergedThemeConfig);
|
|
84
|
+
let theme: ResolvedTheme = lightTheme;
|
|
85
|
+
if (options.darkMode) {
|
|
86
|
+
theme = adaptTheme(theme);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isDarkMode = options.darkMode;
|
|
90
|
+
|
|
91
|
+
// 3. Compute chrome
|
|
92
|
+
const chrome = computeChrome(
|
|
93
|
+
{
|
|
94
|
+
title: tilemapSpec.chrome.title,
|
|
95
|
+
subtitle: tilemapSpec.chrome.subtitle,
|
|
96
|
+
source: tilemapSpec.chrome.source,
|
|
97
|
+
byline: tilemapSpec.chrome.byline,
|
|
98
|
+
footer: tilemapSpec.chrome.footer,
|
|
99
|
+
},
|
|
100
|
+
theme,
|
|
101
|
+
options.width,
|
|
102
|
+
options.measureText,
|
|
103
|
+
'full',
|
|
104
|
+
undefined,
|
|
105
|
+
watermark,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// 4. Compute drawing area (total space minus chrome)
|
|
109
|
+
const padding = theme.spacing.padding;
|
|
110
|
+
const fullArea = {
|
|
111
|
+
x: padding,
|
|
112
|
+
y: padding + chrome.topHeight,
|
|
113
|
+
width: options.width - padding * 2,
|
|
114
|
+
height: options.height - chrome.topHeight - chrome.bottomHeight - padding * 2,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Guard against negative dimensions
|
|
118
|
+
if (fullArea.width <= 0 || fullArea.height <= 0) {
|
|
119
|
+
return emptyLayout(chrome, theme, options, watermark);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 5. Extract encoding fields
|
|
123
|
+
const stateField = tilemapSpec.encoding.state.field;
|
|
124
|
+
const valueField = tilemapSpec.encoding.value.field;
|
|
125
|
+
|
|
126
|
+
// 6. Extract values from data, preserving null/undefined as missing
|
|
127
|
+
const stateValueMap = new Map<string, number>();
|
|
128
|
+
|
|
129
|
+
for (const row of tilemapSpec.data) {
|
|
130
|
+
const stateCode = String(row[stateField]);
|
|
131
|
+
const raw = row[valueField];
|
|
132
|
+
|
|
133
|
+
if (STATE_CODE_SET.has(stateCode) && raw !== null && raw !== undefined) {
|
|
134
|
+
const value = Number(raw);
|
|
135
|
+
if (!Number.isNaN(value)) {
|
|
136
|
+
stateValueMap.set(stateCode, value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 7. Compute value range for color scale
|
|
142
|
+
const values = Array.from(stateValueMap.values());
|
|
143
|
+
const min = values.length > 0 ? Math.min(...values) : 0;
|
|
144
|
+
const max = values.length > 0 ? Math.max(...values) : 100;
|
|
145
|
+
|
|
146
|
+
// 8. Build color scale (fallback to 'blue' if palette name is invalid)
|
|
147
|
+
const paletteStops = [...(SEQUENTIAL_PALETTES[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES.blue)];
|
|
148
|
+
if (isDarkMode) paletteStops.reverse();
|
|
149
|
+
|
|
150
|
+
const domain = paletteStops.map((_, i) => min + (i / (paletteStops.length - 1)) * (max - min));
|
|
151
|
+
const colorScale = scaleLinear<string>().domain(domain).range(paletteStops).clamp(true);
|
|
152
|
+
|
|
153
|
+
// 9. Reserve space for gradient legend at bottom (unless hidden)
|
|
154
|
+
const showLegend = tilemapSpec.legend?.show !== false;
|
|
155
|
+
const legendBarHeight = 12;
|
|
156
|
+
const legendLabelGap = 4;
|
|
157
|
+
const legendTotalHeight = showLegend ? legendBarHeight + legendLabelGap + 14 : 0;
|
|
158
|
+
|
|
159
|
+
// 10. Compute tile positions in the remaining area
|
|
160
|
+
const legendGap = showLegend ? 8 : 0;
|
|
161
|
+
const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap;
|
|
162
|
+
const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 4);
|
|
163
|
+
|
|
164
|
+
// Center tile grid horizontally
|
|
165
|
+
const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
|
|
166
|
+
const tileGridOffsetY = fullArea.y;
|
|
167
|
+
|
|
168
|
+
// Position for legend
|
|
169
|
+
const legendX = tileGridOffsetX;
|
|
170
|
+
const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap;
|
|
171
|
+
const legendWidth = tilePositions.gridWidth;
|
|
172
|
+
|
|
173
|
+
// 11. Build TileMapTileMark[]
|
|
174
|
+
const formatter = buildD3Formatter(tilemapSpec.valueFormat) ?? formatNumber;
|
|
175
|
+
const neutralFillLight = '#e0e0e0';
|
|
176
|
+
const neutralFillDark = '#2a2a3e';
|
|
177
|
+
const neutralStrokeLight = '#d0d0d0';
|
|
178
|
+
const neutralStrokeDark = '#3a3a50';
|
|
179
|
+
|
|
180
|
+
const neutralFill = isDarkMode ? neutralFillDark : neutralFillLight;
|
|
181
|
+
const neutralStroke = isDarkMode ? neutralStrokeDark : neutralStrokeLight;
|
|
182
|
+
|
|
183
|
+
const tiles: TileMapTileMark[] = [];
|
|
184
|
+
|
|
185
|
+
for (const { state: stateCode } of US_STATE_TILES) {
|
|
186
|
+
const pos = tilePositions.positions.get(stateCode);
|
|
187
|
+
if (!pos) continue;
|
|
188
|
+
|
|
189
|
+
const hasData = stateValueMap.has(stateCode);
|
|
190
|
+
const value = hasData ? stateValueMap.get(stateCode)! : null;
|
|
191
|
+
const fill = hasData ? colorScale(value!) : neutralFill;
|
|
192
|
+
const formattedValue = hasData ? formatter(value!) : '–';
|
|
193
|
+
|
|
194
|
+
const labelStyle: TextStyle = {
|
|
195
|
+
fontFamily: theme.fonts.family,
|
|
196
|
+
fontSize: tilePositions.tileSize > 24 ? 14 : 11,
|
|
197
|
+
fontWeight: 700,
|
|
198
|
+
fill: '#ffffff',
|
|
199
|
+
lineHeight: 1.2,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const valueLabelStyle: TextStyle = {
|
|
203
|
+
fontFamily: theme.fonts.family,
|
|
204
|
+
fontSize: tilePositions.tileSize > 24 ? 12 : 10,
|
|
205
|
+
fontWeight: 400,
|
|
206
|
+
fill: '#ffffff',
|
|
207
|
+
lineHeight: 1.2,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Only show value label on larger tiles
|
|
211
|
+
const valueLabel =
|
|
212
|
+
tilePositions.tileSize < 24
|
|
213
|
+
? { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false }
|
|
214
|
+
: {
|
|
215
|
+
text: formattedValue,
|
|
216
|
+
x: tileGridOffsetX + pos.x + tilePositions.tileSize / 2,
|
|
217
|
+
y: tileGridOffsetY + pos.y + tilePositions.tileSize / 2 + 8,
|
|
218
|
+
style: valueLabelStyle,
|
|
219
|
+
visible: true,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const tile: TileMapTileMark = {
|
|
223
|
+
type: 'tile' as const,
|
|
224
|
+
stateCode,
|
|
225
|
+
x: tileGridOffsetX + pos.x,
|
|
226
|
+
y: tileGridOffsetY + pos.y,
|
|
227
|
+
size: tilePositions.tileSize,
|
|
228
|
+
fill,
|
|
229
|
+
stroke: neutralStroke,
|
|
230
|
+
strokeWidth: TILE_STROKE_WIDTH,
|
|
231
|
+
cornerRadius: TILE_CORNER_RADIUS,
|
|
232
|
+
value: value ?? null,
|
|
233
|
+
formattedValue,
|
|
234
|
+
hasData,
|
|
235
|
+
label: {
|
|
236
|
+
text: stateCode,
|
|
237
|
+
x: tileGridOffsetX + pos.x + tilePositions.tileSize / 2,
|
|
238
|
+
y: tileGridOffsetY + pos.y + tilePositions.tileSize / 2 - 4,
|
|
239
|
+
style: labelStyle,
|
|
240
|
+
visible: true,
|
|
241
|
+
},
|
|
242
|
+
valueLabel,
|
|
243
|
+
data: { state: stateCode, value, stateName: STATE_NAMES[stateCode] ?? stateCode },
|
|
244
|
+
aria: {
|
|
245
|
+
role: 'img',
|
|
246
|
+
label: `${STATE_NAMES[stateCode] ?? stateCode}: ${formattedValue}`,
|
|
247
|
+
},
|
|
248
|
+
animationIndex: 0,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
tiles.push(tile);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Assign shuffled animation indices for a scattered pop-in effect.
|
|
255
|
+
// Uses a deterministic Fisher-Yates shuffle so the order looks random
|
|
256
|
+
// but is consistent across renders.
|
|
257
|
+
const indices = Array.from({ length: tiles.length }, (_, i) => i);
|
|
258
|
+
let seed = 42;
|
|
259
|
+
for (let i = indices.length - 1; i > 0; i--) {
|
|
260
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
|
261
|
+
const j = seed % (i + 1);
|
|
262
|
+
[indices[i], indices[j]] = [indices[j], indices[i]];
|
|
263
|
+
}
|
|
264
|
+
for (let i = 0; i < tiles.length; i++) {
|
|
265
|
+
tiles[i].animationIndex = indices[i];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 12. Build gradient legend (null when legend is hidden)
|
|
269
|
+
let gradientLegend: GradientLegendLayout | null = null;
|
|
270
|
+
|
|
271
|
+
if (showLegend) {
|
|
272
|
+
const gradientColorStops: GradientColorStop[] = paletteStops.map((color, i) => ({
|
|
273
|
+
offset: i / (paletteStops.length - 1),
|
|
274
|
+
color,
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
gradientLegend = {
|
|
278
|
+
type: 'gradient',
|
|
279
|
+
position: 'bottom',
|
|
280
|
+
bounds: { x: legendX, y: legendY, width: legendWidth, height: legendBarHeight },
|
|
281
|
+
labelStyle: {
|
|
282
|
+
fontFamily: theme.fonts.family,
|
|
283
|
+
fontSize: 11,
|
|
284
|
+
fontWeight: 400,
|
|
285
|
+
fill: theme.colors.text,
|
|
286
|
+
lineHeight: 1.2,
|
|
287
|
+
},
|
|
288
|
+
colorStops: gradientColorStops,
|
|
289
|
+
minLabel: formatter(min),
|
|
290
|
+
maxLabel: formatter(max),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 13. Build tooltip descriptors
|
|
295
|
+
const tooltipDescriptors = new Map<string, TooltipContent>();
|
|
296
|
+
for (const tile of tiles) {
|
|
297
|
+
const fields: TooltipField[] = [
|
|
298
|
+
{
|
|
299
|
+
label: 'Value',
|
|
300
|
+
value: tile.formattedValue,
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
tooltipDescriptors.set(tile.stateCode, {
|
|
304
|
+
title: STATE_NAMES[tile.stateCode] ?? tile.stateCode,
|
|
305
|
+
fields,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 14. Build a11y metadata
|
|
310
|
+
const a11y = {
|
|
311
|
+
altText: `Tile map of US states showing values from ${formatter(min)} to ${formatter(max)}`,
|
|
312
|
+
dataTableFallback: tiles.map((t) => [t.stateCode, t.formattedValue]),
|
|
313
|
+
role: 'img',
|
|
314
|
+
keyboardNavigable: tiles.length > 0,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// 15. Resolve animation
|
|
318
|
+
const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
area: fullArea,
|
|
322
|
+
chrome,
|
|
323
|
+
tiles,
|
|
324
|
+
gradientLegend,
|
|
325
|
+
tooltipDescriptors,
|
|
326
|
+
a11y,
|
|
327
|
+
theme,
|
|
328
|
+
width: options.width,
|
|
329
|
+
height: options.height,
|
|
330
|
+
animation: resolvedAnimation,
|
|
331
|
+
watermark,
|
|
332
|
+
measureText:
|
|
333
|
+
options.measureText ??
|
|
334
|
+
((text, fontSize) => ({ width: estimateTextWidth(text, fontSize), height: fontSize })),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Empty layout fallback
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
function emptyLayout(
|
|
343
|
+
chrome: ReturnType<typeof computeChrome>,
|
|
344
|
+
theme: ResolvedTheme,
|
|
345
|
+
options: CompileOptions,
|
|
346
|
+
watermark: boolean,
|
|
347
|
+
): TileMapLayout {
|
|
348
|
+
return {
|
|
349
|
+
area: { x: 0, y: 0, width: 0, height: 0 },
|
|
350
|
+
chrome,
|
|
351
|
+
tiles: [],
|
|
352
|
+
gradientLegend: {
|
|
353
|
+
type: 'gradient',
|
|
354
|
+
position: 'bottom',
|
|
355
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
356
|
+
labelStyle: {
|
|
357
|
+
fontFamily: theme.fonts.family,
|
|
358
|
+
fontSize: 11,
|
|
359
|
+
fontWeight: 400,
|
|
360
|
+
fill: theme.colors.text,
|
|
361
|
+
lineHeight: 1.2,
|
|
362
|
+
},
|
|
363
|
+
colorStops: [],
|
|
364
|
+
minLabel: '0',
|
|
365
|
+
maxLabel: '0',
|
|
366
|
+
},
|
|
367
|
+
tooltipDescriptors: new Map(),
|
|
368
|
+
a11y: {
|
|
369
|
+
altText: 'Empty tile map',
|
|
370
|
+
dataTableFallback: [],
|
|
371
|
+
role: 'img',
|
|
372
|
+
keyboardNavigable: false,
|
|
373
|
+
},
|
|
374
|
+
theme,
|
|
375
|
+
width: options.width,
|
|
376
|
+
height: options.height,
|
|
377
|
+
watermark,
|
|
378
|
+
animation: undefined,
|
|
379
|
+
measureText:
|
|
380
|
+
options.measureText ??
|
|
381
|
+
((text, fontSize) => ({ width: estimateTextWidth(text, fontSize), height: fontSize })),
|
|
382
|
+
};
|
|
383
|
+
}
|