@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.
@@ -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
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * US state tile grid layout computation.
3
+ *
4
+ * Defines the fixed grid positions for US state tiles (12 columns x 8 rows)
5
+ * and computes pixel positions from a given available area.
6
+ */
7
+
8
+ export const US_STATE_TILES: Array<{ state: string; col: number; row: number }> = [
9
+ // Row 0
10
+ { state: 'ME', col: 10, row: 0 },
11
+
12
+ // Row 1
13
+ { state: 'VT', col: 9, row: 1 },
14
+ { state: 'NH', col: 10, row: 1 },
15
+
16
+ // Row 2
17
+ { state: 'WA', col: 0, row: 2 },
18
+ { state: 'ID', col: 1, row: 2 },
19
+ { state: 'MT', col: 2, row: 2 },
20
+ { state: 'ND', col: 3, row: 2 },
21
+ { state: 'MN', col: 4, row: 2 },
22
+ { state: 'WI', col: 5, row: 2 },
23
+ { state: 'MI', col: 6, row: 2 },
24
+ { state: 'NY', col: 8, row: 2 },
25
+ { state: 'MA', col: 9, row: 2 },
26
+
27
+ // Row 3
28
+ { state: 'OR', col: 0, row: 3 },
29
+ { state: 'NV', col: 1, row: 3 },
30
+ { state: 'WY', col: 2, row: 3 },
31
+ { state: 'SD', col: 3, row: 3 },
32
+ { state: 'IA', col: 4, row: 3 },
33
+ { state: 'IL', col: 5, row: 3 },
34
+ { state: 'IN', col: 6, row: 3 },
35
+ { state: 'OH', col: 7, row: 3 },
36
+ { state: 'PA', col: 8, row: 3 },
37
+ { state: 'NJ', col: 9, row: 3 },
38
+ { state: 'CT', col: 10, row: 3 },
39
+
40
+ // Row 4
41
+ { state: 'CA', col: 0, row: 4 },
42
+ { state: 'UT', col: 1, row: 4 },
43
+ { state: 'CO', col: 2, row: 4 },
44
+ { state: 'NE', col: 3, row: 4 },
45
+ { state: 'MO', col: 4, row: 4 },
46
+ { state: 'KY', col: 5, row: 4 },
47
+ { state: 'WV', col: 6, row: 4 },
48
+ { state: 'VA', col: 7, row: 4 },
49
+ { state: 'MD', col: 8, row: 4 },
50
+ { state: 'DE', col: 9, row: 4 },
51
+ { state: 'RI', col: 10, row: 4 },
52
+
53
+ // Row 5
54
+ { state: 'AZ', col: 1, row: 5 },
55
+ { state: 'NM', col: 2, row: 5 },
56
+ { state: 'KS', col: 3, row: 5 },
57
+ { state: 'AR', col: 4, row: 5 },
58
+ { state: 'TN', col: 5, row: 5 },
59
+ { state: 'NC', col: 6, row: 5 },
60
+ { state: 'SC', col: 7, row: 5 },
61
+ { state: 'DC', col: 8, row: 5 },
62
+
63
+ // Row 6
64
+ { state: 'AK', col: 0, row: 6 },
65
+ { state: 'OK', col: 3, row: 6 },
66
+ { state: 'LA', col: 4, row: 6 },
67
+ { state: 'MS', col: 5, row: 6 },
68
+ { state: 'AL', col: 6, row: 6 },
69
+ { state: 'GA', col: 7, row: 6 },
70
+
71
+ // Row 7
72
+ { state: 'HI', col: 1, row: 7 },
73
+ { state: 'TX', col: 3, row: 7 },
74
+ { state: 'FL', col: 7, row: 7 },
75
+ ];
76
+
77
+ export const STATE_CODE_SET = new Set(US_STATE_TILES.map((t) => t.state));
78
+
79
+ export const STATE_NAMES: Record<string, string> = {
80
+ AL: 'Alabama',
81
+ AK: 'Alaska',
82
+ AZ: 'Arizona',
83
+ AR: 'Arkansas',
84
+ CA: 'California',
85
+ CO: 'Colorado',
86
+ CT: 'Connecticut',
87
+ DE: 'Delaware',
88
+ DC: 'District of Columbia',
89
+ FL: 'Florida',
90
+ GA: 'Georgia',
91
+ HI: 'Hawaii',
92
+ ID: 'Idaho',
93
+ IL: 'Illinois',
94
+ IN: 'Indiana',
95
+ IA: 'Iowa',
96
+ KS: 'Kansas',
97
+ KY: 'Kentucky',
98
+ LA: 'Louisiana',
99
+ ME: 'Maine',
100
+ MD: 'Maryland',
101
+ MA: 'Massachusetts',
102
+ MI: 'Michigan',
103
+ MN: 'Minnesota',
104
+ MS: 'Mississippi',
105
+ MO: 'Missouri',
106
+ MT: 'Montana',
107
+ NE: 'Nebraska',
108
+ NV: 'Nevada',
109
+ NH: 'New Hampshire',
110
+ NJ: 'New Jersey',
111
+ NM: 'New Mexico',
112
+ NY: 'New York',
113
+ NC: 'North Carolina',
114
+ ND: 'North Dakota',
115
+ OH: 'Ohio',
116
+ OK: 'Oklahoma',
117
+ OR: 'Oregon',
118
+ PA: 'Pennsylvania',
119
+ RI: 'Rhode Island',
120
+ SC: 'South Carolina',
121
+ SD: 'South Dakota',
122
+ TN: 'Tennessee',
123
+ TX: 'Texas',
124
+ UT: 'Utah',
125
+ VT: 'Vermont',
126
+ VA: 'Virginia',
127
+ WA: 'Washington',
128
+ WV: 'West Virginia',
129
+ WI: 'Wisconsin',
130
+ WY: 'Wyoming',
131
+ };
132
+
133
+ const GRID_COLS = 12;
134
+ const GRID_ROWS = 8;
135
+
136
+ export interface TilePositions {
137
+ tileSize: number;
138
+ gap: number;
139
+ positions: Map<string, { x: number; y: number }>;
140
+ gridWidth: number;
141
+ gridHeight: number;
142
+ }
143
+
144
+ /**
145
+ * Compute pixel positions for all US state tiles.
146
+ *
147
+ * Calculates the largest tile size that fits the available area,
148
+ * preserving the grid's aspect ratio and returning a map of
149
+ * state codes to pixel coordinates.
150
+ */
151
+ export function computeTilePositions(
152
+ availableWidth: number,
153
+ availableHeight: number,
154
+ gap = 4,
155
+ ): TilePositions {
156
+ const maxTileW = (availableWidth - gap * (GRID_COLS - 1)) / GRID_COLS;
157
+ const maxTileH = (availableHeight - gap * (GRID_ROWS - 1)) / GRID_ROWS;
158
+ const tileSize = Math.max(1, Math.floor(Math.min(maxTileW, maxTileH)));
159
+
160
+ const gridWidth = tileSize * GRID_COLS + gap * (GRID_COLS - 1);
161
+ const gridHeight = tileSize * GRID_ROWS + gap * (GRID_ROWS - 1);
162
+
163
+ const positions = new Map<string, { x: number; y: number }>();
164
+ for (const { state, col, row } of US_STATE_TILES) {
165
+ positions.set(state, {
166
+ x: col * (tileSize + gap),
167
+ y: row * (tileSize + gap),
168
+ });
169
+ }
170
+
171
+ return { tileSize, gap, positions, gridWidth, gridHeight };
172
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Internal normalized tilemap spec type used by the compilation pipeline.
3
+ *
4
+ * This mirrors NormalizedSankeySpec: all optional fields have been filled
5
+ * with sensible defaults. It's an engine implementation detail, not a public contract.
6
+ */
7
+
8
+ import type {
9
+ AnimationSpec,
10
+ DarkMode,
11
+ LegendConfig,
12
+ ThemeConfig,
13
+ TileMapEncoding,
14
+ TileMapPalette,
15
+ } from '@opendata-ai/openchart-core';
16
+
17
+ import type { NormalizedChrome } from '../compiler/types';
18
+
19
+ /** A TileMapSpec with all optional fields filled with defaults. */
20
+ export interface NormalizedTileMapSpec {
21
+ type: 'tilemap';
22
+ data: Record<string, unknown>[];
23
+ encoding: TileMapEncoding;
24
+ palette: TileMapPalette;
25
+ chrome: NormalizedChrome;
26
+ legend?: LegendConfig;
27
+ theme: ThemeConfig;
28
+ darkMode: DarkMode;
29
+ watermark: boolean;
30
+ animation?: AnimationSpec;
31
+ valueFormat?: string;
32
+ }