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