@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.
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1037 -187
- 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__/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.ts +314 -5
- 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 +379 -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,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.
|
|
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
|
|
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;
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
package/src/tilemap/types.ts
CHANGED