@opendata-ai/openchart-engine 7.6.0 → 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.
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1039 -189
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
- package/src/__tests__/axes.test.ts +3 -2
- package/src/__tests__/compile-layer.test.ts +94 -0
- package/src/__tests__/facet.test.ts +215 -0
- package/src/annotations/__tests__/compute.test.ts +18 -0
- package/src/annotations/__tests__/thinning.test.ts +137 -0
- package/src/annotations/compute.ts +3 -1
- package/src/annotations/thinning.ts +116 -0
- package/src/barlist/compile-barlist.ts +1 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/area.ts +16 -2
- package/src/charts/line/compute.ts +17 -10
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/__tests__/color-scale-range.test.ts +80 -1
- package/src/compile/color-scale-range.ts +44 -2
- package/src/compile/layer.ts +11 -1
- package/src/compile.ts +317 -6
- package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
- package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
- package/src/compiler/normalize.ts +67 -2
- package/src/compiler/types.ts +10 -0
- package/src/compiler/validate.ts +79 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
- package/src/endpoint-labels/compute.ts +179 -5
- package/src/endpoint-labels/predict.ts +17 -0
- package/src/layout/__tests__/scales-highlight.test.ts +192 -0
- package/src/layout/axes.ts +8 -2
- package/src/layout/dimensions.ts +6 -1
- package/src/layout/facet.ts +80 -0
- package/src/layout/plan.ts +16 -0
- package/src/legend/__tests__/redundancy.test.ts +108 -0
- package/src/legend/__tests__/suppression.test.ts +72 -0
- package/src/legend/compute.ts +18 -0
- package/src/legend/suppression.ts +27 -6
- package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
- package/src/tilemap/compile-tilemap.ts +371 -117
- 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.
|
|
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
|
|
175
|
-
const
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
package/src/tilemap/types.ts
CHANGED