@opendata-ai/openchart-engine 7.6.1 → 7.8.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.
Files changed (42) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1037 -187
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
  7. package/src/__tests__/axes.test.ts +3 -2
  8. package/src/__tests__/facet.test.ts +215 -0
  9. package/src/annotations/__tests__/compute.test.ts +18 -0
  10. package/src/annotations/__tests__/thinning.test.ts +137 -0
  11. package/src/annotations/compute.ts +3 -1
  12. package/src/annotations/thinning.ts +116 -0
  13. package/src/barlist/compile-barlist.ts +1 -0
  14. package/src/charts/bar/labels.ts +1 -0
  15. package/src/charts/column/labels.ts +1 -0
  16. package/src/charts/dot/labels.ts +1 -0
  17. package/src/charts/line/area.ts +16 -2
  18. package/src/charts/line/compute.ts +17 -10
  19. package/src/charts/pie/labels.ts +1 -0
  20. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  21. package/src/compile/color-scale-range.ts +44 -2
  22. package/src/compile.ts +314 -5
  23. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  24. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  25. package/src/compiler/normalize.ts +67 -2
  26. package/src/compiler/types.ts +10 -0
  27. package/src/compiler/validate.ts +79 -0
  28. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  29. package/src/endpoint-labels/compute.ts +179 -5
  30. package/src/endpoint-labels/predict.ts +17 -0
  31. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  32. package/src/layout/axes.ts +8 -2
  33. package/src/layout/dimensions.ts +6 -1
  34. package/src/layout/facet.ts +80 -0
  35. package/src/layout/plan.ts +16 -0
  36. package/src/legend/__tests__/redundancy.test.ts +108 -0
  37. package/src/legend/__tests__/suppression.test.ts +72 -0
  38. package/src/legend/compute.ts +18 -0
  39. package/src/legend/suppression.ts +27 -6
  40. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  41. package/src/tilemap/compile-tilemap.ts +379 -117
  42. package/src/tilemap/types.ts +1 -0
@@ -5,6 +5,11 @@
5
5
  * theme, computes chrome, builds a color scale, computes tile positions and
6
6
  * marks, builds legend and tooltips, and returns a TileMapLayout.
7
7
  *
8
+ * Supports two color modes:
9
+ * - Quantitative (default): opacity-based encoding with a single base color
10
+ * - Categorical: distinct fill colors per category (via `colors` map or
11
+ * `encoding.color` channel)
12
+ *
8
13
  * Pipeline:
9
14
  * validate -> normalize -> resolve theme -> dark mode adapt ->
10
15
  * compute chrome -> extract data -> build color scale -> compute positions ->
@@ -13,9 +18,11 @@
13
18
  */
14
19
 
15
20
  import type {
21
+ CategoricalLegendLayout,
16
22
  CompileOptions,
17
23
  GradientColorStop,
18
24
  GradientLegendLayout,
25
+ LegendEntry,
19
26
  ResolvedAnimation,
20
27
  ResolvedTheme,
21
28
  TextStyle,
@@ -27,9 +34,11 @@ import type {
27
34
  import {
28
35
  adaptTheme,
29
36
  buildD3Formatter,
37
+ CATEGORICAL_PALETTE,
30
38
  computeChrome,
31
39
  estimateTextWidth,
32
40
  formatNumber,
41
+ pickLabelColor,
33
42
  resolveTheme,
34
43
  SEQUENTIAL_PALETTES,
35
44
  } from '@opendata-ai/openchart-core';
@@ -46,6 +55,9 @@ import type { NormalizedTileMapSpec } from './types';
46
55
 
47
56
  const TILE_CORNER_RADIUS = 6;
48
57
  const TILE_STROKE_WIDTH = 1;
58
+ const LEGEND_SWATCH_SIZE = 10;
59
+ const LEGEND_SWATCH_GAP = 6;
60
+ const LEGEND_ENTRY_GAP = 16;
49
61
 
50
62
  // ---------------------------------------------------------------------------
51
63
  // Public API
@@ -119,17 +131,36 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
119
131
  return emptyLayout(chrome, theme, options, watermark);
120
132
  }
121
133
 
122
- // 5. Extract encoding fields
134
+ // 5. Detect categorical vs quantitative mode
135
+ const isCategorical =
136
+ tilemapSpec.encoding.color !== undefined || tilemapSpec.colors !== undefined;
137
+
138
+ if (isCategorical) {
139
+ return compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark);
140
+ }
141
+ return compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark);
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Quantitative mode (opacity-based sequential coloring)
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function compileQuantitative(
149
+ tilemapSpec: NormalizedTileMapSpec,
150
+ options: CompileOptions,
151
+ theme: ResolvedTheme,
152
+ chrome: ReturnType<typeof computeChrome>,
153
+ fullArea: { x: number; y: number; width: number; height: number },
154
+ isDarkMode: boolean | undefined,
155
+ watermark: boolean,
156
+ ): TileMapLayout {
123
157
  const stateField = tilemapSpec.encoding.state.field;
124
158
  const valueField = tilemapSpec.encoding.value.field;
125
159
 
126
- // 6. Extract values from data, preserving null/undefined as missing
127
160
  const stateValueMap = new Map<string, number>();
128
-
129
161
  for (const row of tilemapSpec.data) {
130
162
  const stateCode = String(row[stateField]);
131
163
  const raw = row[valueField];
132
-
133
164
  if (STATE_CODE_SET.has(stateCode) && raw !== null && raw !== undefined) {
134
165
  const value = Number(raw);
135
166
  if (!Number.isNaN(value)) {
@@ -138,49 +169,35 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
138
169
  }
139
170
  }
140
171
 
141
- // 7. Compute value range for color scale
142
172
  const values = Array.from(stateValueMap.values());
143
173
  const min = values.length > 0 ? Math.min(...values) : 0;
144
174
  const max = values.length > 0 ? Math.max(...values) : 100;
145
175
 
146
- // 8. Build opacity scale: single base color, opacity encodes value
147
176
  const paletteStops = [...(SEQUENTIAL_PALETTES[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES.blue)];
148
177
  const baseColor = isDarkMode ? paletteStops[0] : paletteStops[paletteStops.length - 1];
149
178
  const opacityRange: [number, number] = isDarkMode ? [0.15, 1] : [0.2, 1];
150
179
  const opacityScale = scaleLinear<number>().domain([min, max]).range(opacityRange).clamp(true);
151
180
 
152
- // 9. Reserve space for gradient legend at bottom (unless hidden)
153
181
  const showLegend = tilemapSpec.legend?.show !== false;
154
182
  const legendBarHeight = 6;
155
183
  const legendLabelGap = 6;
156
184
  const legendTotalHeight = showLegend ? legendBarHeight + legendLabelGap + 14 : 0;
157
185
 
158
- // 10. Compute tile positions in the remaining area
159
186
  const legendGap = showLegend ? 8 : 0;
160
187
  const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap;
161
188
  const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
162
189
 
163
- // Center tile grid horizontally
164
190
  const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
165
191
  const tileGridOffsetY = fullArea.y;
166
-
167
- // Position for legend
168
192
  const legendX = tileGridOffsetX;
169
193
  const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap;
170
194
  const legendWidth = tilePositions.gridWidth;
171
195
 
172
- // 11. Build TileMapTileMark[]
173
196
  const formatter = buildD3Formatter(tilemapSpec.valueFormat) ?? formatNumber;
174
- const neutralFillLight = '#e0e0e0';
175
- const neutralFillDark = '#1e2a30';
176
- const neutralStrokeLight = '#d0d0d0';
177
- const neutralStrokeDark = 'rgba(255,255,255,0.08)';
178
-
179
- const neutralFill = isDarkMode ? neutralFillDark : neutralFillLight;
180
- const neutralStroke = isDarkMode ? neutralStrokeDark : neutralStrokeLight;
197
+ const neutralFill = isDarkMode ? '#1e2a30' : '#e0e0e0';
198
+ const neutralStroke = isDarkMode ? 'rgba(255,255,255,0.08)' : '#d0d0d0';
181
199
 
182
200
  const tiles: TileMapTileMark[] = [];
183
-
184
201
  for (const { state: stateCode } of US_STATE_TILES) {
185
202
  const pos = tilePositions.positions.get(stateCode);
186
203
  if (!pos) continue;
@@ -196,88 +213,28 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
196
213
  : neutralStroke;
197
214
  const formattedValue = hasData ? formatter(value!) : '–';
198
215
 
199
- const labelStyle: TextStyle = {
200
- fontFamily: theme.fonts.family,
201
- fontSize: tilePositions.tileSize > 24 ? 10 : 7,
202
- fontWeight: 700,
203
- fill: '#ffffff',
204
- lineHeight: 1.2,
205
- };
206
-
207
- const valueLabelStyle: TextStyle = {
208
- fontFamily: theme.fonts.family,
209
- fontSize: tilePositions.tileSize > 24 ? 10 : 7,
210
- fontWeight: 300,
211
- fill: 'rgba(255,255,255,0.6)',
212
- lineHeight: 1.2,
213
- };
214
-
215
- const tileCenterX = tileGridOffsetX + pos.x + tilePositions.tileSize / 2;
216
- const tileTopY = tileGridOffsetY + pos.y;
217
- const sz = tilePositions.tileSize;
218
-
219
- // Only show value label on larger tiles
220
- const valueLabel =
221
- sz < 24
222
- ? { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false }
223
- : {
224
- text: formattedValue,
225
- x: tileCenterX,
226
- y: tileTopY + sz * 0.78,
227
- style: valueLabelStyle,
228
- visible: true,
229
- };
230
-
231
- const tile: TileMapTileMark = {
232
- type: 'tile' as const,
233
- stateCode,
234
- x: tileGridOffsetX + pos.x,
235
- y: tileTopY,
236
- size: sz,
237
- fill,
238
- fillOpacity: hasData ? opacity : 1,
239
- stroke,
240
- strokeWidth: TILE_STROKE_WIDTH,
241
- cornerRadius: TILE_CORNER_RADIUS,
242
- value: value ?? null,
243
- formattedValue,
244
- hasData,
245
- label: {
246
- text: stateCode,
247
- x: tileCenterX,
248
- y: tileTopY + sz * 0.28,
249
- style: labelStyle,
250
- visible: true,
251
- },
252
- valueLabel,
253
- data: { state: stateCode, value, stateName: STATE_NAMES[stateCode] ?? stateCode },
254
- aria: {
255
- role: 'img',
256
- label: `${STATE_NAMES[stateCode] ?? stateCode}: ${formattedValue}`,
257
- },
258
- animationIndex: 0,
259
- };
260
-
261
- tiles.push(tile);
216
+ tiles.push(
217
+ buildTileMark({
218
+ stateCode,
219
+ pos,
220
+ tileSize: tilePositions.tileSize,
221
+ gridOffsetX: tileGridOffsetX,
222
+ gridOffsetY: tileGridOffsetY,
223
+ fill,
224
+ fillOpacity: hasData ? opacity : 1,
225
+ stroke,
226
+ value,
227
+ formattedValue,
228
+ hasData,
229
+ theme,
230
+ isDarkMode: !!isDarkMode,
231
+ }),
232
+ );
262
233
  }
263
234
 
264
- // Assign shuffled animation indices for a scattered pop-in effect.
265
- // Uses a deterministic Fisher-Yates shuffle so the order looks random
266
- // but is consistent across renders.
267
- const indices = Array.from({ length: tiles.length }, (_, i) => i);
268
- let seed = 42;
269
- for (let i = indices.length - 1; i > 0; i--) {
270
- seed = (seed * 1103515245 + 12345) & 0x7fffffff;
271
- const j = seed % (i + 1);
272
- [indices[i], indices[j]] = [indices[j], indices[i]];
273
- }
274
- for (let i = 0; i < tiles.length; i++) {
275
- tiles[i].animationIndex = indices[i];
276
- }
235
+ assignAnimationIndices(tiles);
277
236
 
278
- // 12. Build gradient legend (null when legend is hidden)
279
237
  let gradientLegend: GradientLegendLayout | null = null;
280
-
281
238
  if (showLegend) {
282
239
  const numStops = 5;
283
240
  const gradientColorStops: GradientColorStop[] = Array.from({ length: numStops }, (_, i) => {
@@ -303,22 +260,7 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
303
260
  };
304
261
  }
305
262
 
306
- // 13. Build tooltip descriptors
307
- const tooltipDescriptors = new Map<string, TooltipContent>();
308
- for (const tile of tiles) {
309
- const fields: TooltipField[] = [
310
- {
311
- label: 'Value',
312
- value: tile.formattedValue,
313
- },
314
- ];
315
- tooltipDescriptors.set(tile.stateCode, {
316
- title: STATE_NAMES[tile.stateCode] ?? tile.stateCode,
317
- fields,
318
- });
319
- }
320
-
321
- // 14. Build a11y metadata
263
+ const tooltipDescriptors = buildTooltips(tiles);
322
264
  const a11y = {
323
265
  altText: `Tile map of US states showing values from ${formatter(min)} to ${formatter(max)}`,
324
266
  dataTableFallback: tiles.map((t) => [t.stateCode, t.formattedValue]),
@@ -326,10 +268,8 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
326
268
  keyboardNavigable: tiles.length > 0,
327
269
  };
328
270
 
329
- // 15. Resolve animation
330
271
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
331
-
332
- // Tight content height: tiles + legend + chrome + padding
272
+ const padding = theme.spacing.padding;
333
273
  const contentHeight =
334
274
  tileGridOffsetY +
335
275
  tilePositions.gridHeight +
@@ -343,6 +283,7 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
343
283
  chrome,
344
284
  tiles,
345
285
  gradientLegend,
286
+ categoricalLegend: null,
346
287
  tooltipDescriptors,
347
288
  a11y,
348
289
  theme,
@@ -356,6 +297,326 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
356
297
  };
357
298
  }
358
299
 
300
+ // ---------------------------------------------------------------------------
301
+ // Categorical mode (distinct fill colors per category)
302
+ // ---------------------------------------------------------------------------
303
+
304
+ function compileCategorical(
305
+ tilemapSpec: NormalizedTileMapSpec,
306
+ options: CompileOptions,
307
+ theme: ResolvedTheme,
308
+ chrome: ReturnType<typeof computeChrome>,
309
+ fullArea: { x: number; y: number; width: number; height: number },
310
+ isDarkMode: boolean | undefined,
311
+ watermark: boolean,
312
+ ): TileMapLayout {
313
+ const stateField = tilemapSpec.encoding.state.field;
314
+ const colorField = tilemapSpec.encoding.color?.field ?? tilemapSpec.encoding.value.field;
315
+
316
+ // Extract category per state
317
+ const stateCategoryMap = new Map<string, string>();
318
+ for (const row of tilemapSpec.data) {
319
+ const stateCode = String(row[stateField]);
320
+ const raw = row[colorField];
321
+ if (STATE_CODE_SET.has(stateCode) && raw !== null && raw !== undefined) {
322
+ stateCategoryMap.set(stateCode, String(raw));
323
+ }
324
+ }
325
+
326
+ // Collect unique categories in data order
327
+ const categories: string[] = [];
328
+ const seen = new Set<string>();
329
+ for (const row of tilemapSpec.data) {
330
+ const raw = row[colorField];
331
+ if (raw !== null && raw !== undefined) {
332
+ const cat = String(raw);
333
+ if (!seen.has(cat)) {
334
+ seen.add(cat);
335
+ categories.push(cat);
336
+ }
337
+ }
338
+ }
339
+
340
+ // Build category -> color mapping
341
+ const categoryColors = new Map<string, string>();
342
+ if (tilemapSpec.colors) {
343
+ for (const cat of categories) {
344
+ categoryColors.set(
345
+ cat,
346
+ tilemapSpec.colors[cat] ??
347
+ CATEGORICAL_PALETTE[categoryColors.size % CATEGORICAL_PALETTE.length],
348
+ );
349
+ }
350
+ } else {
351
+ for (let i = 0; i < categories.length; i++) {
352
+ categoryColors.set(categories[i], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
353
+ }
354
+ }
355
+
356
+ const showLegend = tilemapSpec.legend?.show !== false;
357
+ const legendRowHeight = LEGEND_SWATCH_SIZE + 6;
358
+ const legendTotalHeight = showLegend ? legendRowHeight : 0;
359
+
360
+ const legendGap = showLegend ? 8 : 0;
361
+ const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap;
362
+ const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
363
+
364
+ const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
365
+ const tileGridOffsetY = fullArea.y;
366
+ const legendX = tileGridOffsetX;
367
+ const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap;
368
+ const legendWidth = tilePositions.gridWidth;
369
+
370
+ const neutralFill = isDarkMode ? '#1e2a30' : '#e0e0e0';
371
+ const neutralStroke = isDarkMode ? 'rgba(255,255,255,0.08)' : '#d0d0d0';
372
+
373
+ const tiles: TileMapTileMark[] = [];
374
+ for (const { state: stateCode } of US_STATE_TILES) {
375
+ const pos = tilePositions.positions.get(stateCode);
376
+ if (!pos) continue;
377
+
378
+ const hasData = stateCategoryMap.has(stateCode);
379
+ const category = hasData ? stateCategoryMap.get(stateCode)! : null;
380
+ const fill = hasData ? (categoryColors.get(category!) ?? neutralFill) : neutralFill;
381
+ const stroke = hasData
382
+ ? isDarkMode
383
+ ? 'rgba(255,255,255,0.1)'
384
+ : 'rgba(0,0,0,0.1)'
385
+ : neutralStroke;
386
+ const formattedValue = category ? formatCategoryLabel(category) : '–';
387
+
388
+ tiles.push(
389
+ buildTileMark({
390
+ stateCode,
391
+ pos,
392
+ tileSize: tilePositions.tileSize,
393
+ gridOffsetX: tileGridOffsetX,
394
+ gridOffsetY: tileGridOffsetY,
395
+ fill,
396
+ fillOpacity: 1,
397
+ stroke,
398
+ value: null,
399
+ formattedValue,
400
+ hasData,
401
+ theme,
402
+ isDarkMode: !!isDarkMode,
403
+ category: hasData ? category : undefined,
404
+ }),
405
+ );
406
+ }
407
+
408
+ assignAnimationIndices(tiles);
409
+
410
+ let categoricalLegend: CategoricalLegendLayout | null = null;
411
+ if (showLegend) {
412
+ const labelStyle: TextStyle = {
413
+ fontFamily: theme.fonts.family,
414
+ fontSize: 11,
415
+ fontWeight: 400,
416
+ fill: theme.colors.text,
417
+ lineHeight: 1.2,
418
+ };
419
+
420
+ const entries: LegendEntry[] = categories.map((cat) => ({
421
+ label: formatCategoryLabel(cat),
422
+ color: categoryColors.get(cat)!,
423
+ shape: 'square' as const,
424
+ }));
425
+
426
+ categoricalLegend = {
427
+ type: 'categorical',
428
+ position: 'bottom',
429
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: legendRowHeight },
430
+ labelStyle,
431
+ entries,
432
+ swatchSize: LEGEND_SWATCH_SIZE,
433
+ swatchGap: LEGEND_SWATCH_GAP,
434
+ entryGap: LEGEND_ENTRY_GAP,
435
+ swatchChipFill: 'transparent',
436
+ };
437
+ }
438
+
439
+ const tooltipDescriptors = buildTooltips(tiles, 'Category');
440
+ const categoryList = categories.map(formatCategoryLabel).join(', ');
441
+ const a11y = {
442
+ altText: `Tile map of US states showing categories: ${categoryList}`,
443
+ dataTableFallback: tiles.map((t) => [t.stateCode, t.formattedValue]),
444
+ role: 'img',
445
+ keyboardNavigable: tiles.length > 0,
446
+ };
447
+
448
+ const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
449
+ const padding = theme.spacing.padding;
450
+ const contentHeight =
451
+ tileGridOffsetY +
452
+ tilePositions.gridHeight +
453
+ legendGap +
454
+ legendTotalHeight +
455
+ chrome.bottomHeight +
456
+ padding;
457
+
458
+ return {
459
+ area: fullArea,
460
+ chrome,
461
+ tiles,
462
+ gradientLegend: null,
463
+ categoricalLegend,
464
+ tooltipDescriptors,
465
+ a11y,
466
+ theme,
467
+ width: options.width,
468
+ height: contentHeight,
469
+ animation: resolvedAnimation,
470
+ watermark,
471
+ measureText:
472
+ options.measureText ??
473
+ ((text, fontSize) => ({ width: estimateTextWidth(text, fontSize), height: fontSize })),
474
+ };
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Shared helpers
479
+ // ---------------------------------------------------------------------------
480
+
481
+ interface TileMarkOptions {
482
+ stateCode: string;
483
+ pos: { x: number; y: number };
484
+ tileSize: number;
485
+ gridOffsetX: number;
486
+ gridOffsetY: number;
487
+ fill: string;
488
+ fillOpacity: number;
489
+ stroke: string;
490
+ value: number | null;
491
+ formattedValue: string;
492
+ hasData: boolean;
493
+ theme: ResolvedTheme;
494
+ isDarkMode: boolean;
495
+ category?: string | null;
496
+ }
497
+
498
+ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
499
+ const {
500
+ stateCode,
501
+ pos,
502
+ tileSize,
503
+ gridOffsetX,
504
+ gridOffsetY,
505
+ fill,
506
+ fillOpacity,
507
+ stroke,
508
+ value,
509
+ formattedValue,
510
+ hasData,
511
+ theme,
512
+ isDarkMode,
513
+ category,
514
+ } = opts;
515
+ const tileCenterX = gridOffsetX + pos.x + tileSize / 2;
516
+ const tileTopY = gridOffsetY + pos.y;
517
+
518
+ const textColor = hasData ? pickLabelColor(fill, isDarkMode) : '#ffffff';
519
+ const isLightText = textColor === '#ffffff';
520
+
521
+ const labelStyle: TextStyle = {
522
+ fontFamily: theme.fonts.family,
523
+ fontSize: tileSize > 24 ? 10 : 7,
524
+ fontWeight: 700,
525
+ fill: textColor,
526
+ lineHeight: 1.2,
527
+ };
528
+
529
+ const valueLabelStyle: TextStyle = {
530
+ fontFamily: theme.fonts.family,
531
+ fontSize: tileSize > 24 ? 10 : 7,
532
+ fontWeight: 300,
533
+ fill: isLightText ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.45)',
534
+ lineHeight: 1.2,
535
+ };
536
+
537
+ const valueLabel =
538
+ tileSize < 24
539
+ ? { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false }
540
+ : {
541
+ text: formattedValue,
542
+ x: tileCenterX,
543
+ y: tileTopY + tileSize * 0.78,
544
+ style: valueLabelStyle,
545
+ visible: true,
546
+ };
547
+
548
+ const data: Record<string, unknown> = {
549
+ state: stateCode,
550
+ value,
551
+ stateName: STATE_NAMES[stateCode] ?? stateCode,
552
+ };
553
+ if (category !== undefined) {
554
+ data.category = category;
555
+ }
556
+
557
+ return {
558
+ type: 'tile' as const,
559
+ stateCode,
560
+ x: gridOffsetX + pos.x,
561
+ y: tileTopY,
562
+ size: tileSize,
563
+ fill,
564
+ fillOpacity,
565
+ stroke,
566
+ strokeWidth: TILE_STROKE_WIDTH,
567
+ cornerRadius: TILE_CORNER_RADIUS,
568
+ value,
569
+ formattedValue,
570
+ hasData,
571
+ label: {
572
+ text: stateCode,
573
+ x: tileCenterX,
574
+ y: tileTopY + tileSize * 0.28,
575
+ style: labelStyle,
576
+ visible: true,
577
+ },
578
+ valueLabel,
579
+ data,
580
+ aria: {
581
+ role: 'img',
582
+ label: `${STATE_NAMES[stateCode] ?? stateCode}: ${formattedValue}`,
583
+ },
584
+ animationIndex: 0,
585
+ };
586
+ }
587
+
588
+ function assignAnimationIndices(tiles: TileMapTileMark[]): void {
589
+ const indices = Array.from({ length: tiles.length }, (_, i) => i);
590
+ let seed = 42;
591
+ for (let i = indices.length - 1; i > 0; i--) {
592
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
593
+ const j = seed % (i + 1);
594
+ [indices[i], indices[j]] = [indices[j], indices[i]];
595
+ }
596
+ for (let i = 0; i < tiles.length; i++) {
597
+ tiles[i].animationIndex = indices[i];
598
+ }
599
+ }
600
+
601
+ function buildTooltips(
602
+ tiles: TileMapTileMark[],
603
+ fieldLabel = 'Value',
604
+ ): Map<string, TooltipContent> {
605
+ const tooltipDescriptors = new Map<string, TooltipContent>();
606
+ for (const tile of tiles) {
607
+ const fields: TooltipField[] = [{ label: fieldLabel, value: tile.formattedValue }];
608
+ tooltipDescriptors.set(tile.stateCode, {
609
+ title: STATE_NAMES[tile.stateCode] ?? tile.stateCode,
610
+ fields,
611
+ });
612
+ }
613
+ return tooltipDescriptors;
614
+ }
615
+
616
+ function formatCategoryLabel(category: string): string {
617
+ return category.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
618
+ }
619
+
359
620
  // ---------------------------------------------------------------------------
360
621
  // Empty layout fallback
361
622
  // ---------------------------------------------------------------------------
@@ -385,6 +646,7 @@ function emptyLayout(
385
646
  minLabel: '0',
386
647
  maxLabel: '0',
387
648
  },
649
+ categoricalLegend: null,
388
650
  tooltipDescriptors: new Map(),
389
651
  a11y: {
390
652
  altText: 'Empty tile map',
@@ -22,6 +22,7 @@ export interface NormalizedTileMapSpec {
22
22
  data: Record<string, unknown>[];
23
23
  encoding: TileMapEncoding;
24
24
  palette: TileMapPalette;
25
+ colors?: Record<string, string>;
25
26
  chrome: NormalizedChrome;
26
27
  legend?: LegendConfig;
27
28
  theme: ThemeConfig;