@opendata-ai/openchart-engine 6.25.3 → 6.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +46 -4
- package/dist/index.js +888 -80
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compound-labels.test.ts +147 -0
- package/src/compile.ts +64 -21
- package/src/compiler/normalize.ts +74 -7
- package/src/compiler/types.ts +3 -1
- package/src/compiler/validate.ts +124 -5
- package/src/index.ts +16 -1
- package/src/layout/axes/ticks.ts +34 -2
- package/src/layout/axes.ts +27 -1
- package/src/layout/dimensions.ts +21 -3
- package/src/sankey/compile-sankey.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +322 -0
- package/src/tilemap/compile-tilemap.ts +383 -0
- package/src/tilemap/layout.ts +172 -0
- package/src/tilemap/types.ts +32 -0
- package/src/transforms/__tests__/filter-relative.test.ts +202 -0
- package/src/transforms/__tests__/window.test.ts +286 -0
- package/src/transforms/filter.ts +108 -3
- package/src/transforms/index.ts +5 -1
- package/src/transforms/predicates.ts +39 -9
- package/src/transforms/window.ts +185 -0
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* not from the chart area. Density thinning lives in ./thinning.ts.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
AxisLabelDensity,
|
|
10
|
+
AxisTick,
|
|
11
|
+
DataRow,
|
|
12
|
+
MeasureTextFn,
|
|
13
|
+
} from '@opendata-ai/openchart-core';
|
|
9
14
|
import {
|
|
10
15
|
abbreviateNumber,
|
|
11
16
|
buildD3Formatter,
|
|
@@ -221,6 +226,7 @@ export function categoricalTicks(
|
|
|
221
226
|
fontSize?: number,
|
|
222
227
|
fontWeight?: number,
|
|
223
228
|
measureText?: MeasureTextFn,
|
|
229
|
+
subtitleContext?: { data: DataRow[]; fieldName: string; labelField: string },
|
|
224
230
|
): AxisTick[] {
|
|
225
231
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
226
232
|
const domain: string[] = scale.domain();
|
|
@@ -275,6 +281,23 @@ export function categoricalTicks(
|
|
|
275
281
|
}
|
|
276
282
|
// vertical band scale (horizontal bar y-axis): always show all labels
|
|
277
283
|
|
|
284
|
+
let subtitleMap: Map<string, string> | undefined;
|
|
285
|
+
if (subtitleContext) {
|
|
286
|
+
const { data, fieldName, labelField } = subtitleContext;
|
|
287
|
+
if (data.length > 0) {
|
|
288
|
+
subtitleMap = new Map();
|
|
289
|
+
for (const row of data) {
|
|
290
|
+
const key = String(row[fieldName] ?? '');
|
|
291
|
+
if (!subtitleMap.has(key)) {
|
|
292
|
+
const val = row[labelField];
|
|
293
|
+
if (val != null) {
|
|
294
|
+
subtitleMap.set(key, String(val));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
278
301
|
const ticks = selectedValues.map((value: string) => {
|
|
279
302
|
// Band scales: use the center of the band
|
|
280
303
|
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
@@ -282,11 +305,20 @@ export function categoricalTicks(
|
|
|
282
305
|
? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2
|
|
283
306
|
: ((scale(value) as number | undefined) ?? 0);
|
|
284
307
|
|
|
285
|
-
|
|
308
|
+
const tick: AxisTick = {
|
|
286
309
|
value,
|
|
287
310
|
position: pos,
|
|
288
311
|
label: value,
|
|
289
312
|
};
|
|
313
|
+
|
|
314
|
+
if (subtitleMap) {
|
|
315
|
+
const subtitle = subtitleMap.get(value);
|
|
316
|
+
if (subtitle !== undefined) {
|
|
317
|
+
tick.subtitle = subtitle;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return tick;
|
|
290
322
|
});
|
|
291
323
|
|
|
292
324
|
return ticks;
|
package/src/layout/axes.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
AxisLabelDensity,
|
|
11
11
|
AxisLayout,
|
|
12
12
|
AxisTick,
|
|
13
|
+
DataRow,
|
|
14
|
+
Encoding,
|
|
13
15
|
Gridline,
|
|
14
16
|
LayoutStrategy,
|
|
15
17
|
MeasureTextFn,
|
|
@@ -191,6 +193,14 @@ export interface AxesResult {
|
|
|
191
193
|
y?: AxisLayout;
|
|
192
194
|
}
|
|
193
195
|
|
|
196
|
+
/** Optional data context for axis computation (enables labelField subtitles). */
|
|
197
|
+
export interface AxesDataContext {
|
|
198
|
+
/** The data rows for subtitle lookup. */
|
|
199
|
+
data: DataRow[];
|
|
200
|
+
/** The encoding object to resolve field names. */
|
|
201
|
+
encoding: Encoding;
|
|
202
|
+
}
|
|
203
|
+
|
|
194
204
|
/**
|
|
195
205
|
* Compute axis layouts with tick positions, labels, and axis lines.
|
|
196
206
|
*
|
|
@@ -199,6 +209,7 @@ export interface AxesResult {
|
|
|
199
209
|
* @param strategy - Responsive layout strategy.
|
|
200
210
|
* @param theme - Resolved theme for styling.
|
|
201
211
|
* @param measureText - Optional real text measurement from the adapter.
|
|
212
|
+
* @param dataContext - Optional data context for labelField subtitle support.
|
|
202
213
|
*/
|
|
203
214
|
export function computeAxes(
|
|
204
215
|
scales: ResolvedScales,
|
|
@@ -206,6 +217,7 @@ export function computeAxes(
|
|
|
206
217
|
strategy: LayoutStrategy,
|
|
207
218
|
theme: ResolvedTheme,
|
|
208
219
|
measureText?: MeasureTextFn,
|
|
220
|
+
dataContext?: AxesDataContext,
|
|
209
221
|
): AxesResult {
|
|
210
222
|
const result: AxesResult = {};
|
|
211
223
|
const baseDensity = strategy.axisLabelDensity;
|
|
@@ -362,7 +374,21 @@ export function computeAxes(
|
|
|
362
374
|
if (axisConfig?.values) {
|
|
363
375
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
|
|
364
376
|
} else if (!isContinuousY) {
|
|
365
|
-
|
|
377
|
+
const yFieldName = dataContext?.encoding.y?.field;
|
|
378
|
+
const yLabelField = axisConfig?.labelField;
|
|
379
|
+
allTicks = categoricalTicks(
|
|
380
|
+
scales.y,
|
|
381
|
+
yDensity,
|
|
382
|
+
'vertical',
|
|
383
|
+
undefined,
|
|
384
|
+
undefined,
|
|
385
|
+
undefined,
|
|
386
|
+
undefined,
|
|
387
|
+
undefined,
|
|
388
|
+
yFieldName && yLabelField && dataContext
|
|
389
|
+
? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField }
|
|
390
|
+
: undefined,
|
|
391
|
+
);
|
|
366
392
|
} else {
|
|
367
393
|
allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
|
|
368
394
|
}
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -275,10 +275,26 @@ export function computeDimensions(
|
|
|
275
275
|
) {
|
|
276
276
|
// Category labels on the left for bar/dot charts
|
|
277
277
|
const yField = encoding.y.field;
|
|
278
|
+
const yLabelField = (encoding.y.axis as Record<string, unknown> | undefined)?.labelField as
|
|
279
|
+
| string
|
|
280
|
+
| undefined;
|
|
278
281
|
let maxLabelWidth = 0;
|
|
279
282
|
for (const row of spec.data) {
|
|
280
283
|
const label = String(row[yField] ?? '');
|
|
281
|
-
|
|
284
|
+
let w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
285
|
+
// When labelField is set, add a gap and the subtitle width
|
|
286
|
+
if (yLabelField) {
|
|
287
|
+
const subtitle = String(row[yLabelField] ?? '');
|
|
288
|
+
if (subtitle) {
|
|
289
|
+
const gap = theme.fonts.sizes.axisTick * 0.6;
|
|
290
|
+
const subtitleWidth = estimateTextWidth(
|
|
291
|
+
subtitle,
|
|
292
|
+
theme.fonts.sizes.axisTick,
|
|
293
|
+
theme.fonts.weights.normal,
|
|
294
|
+
);
|
|
295
|
+
w += gap + subtitleWidth;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
282
298
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
283
299
|
}
|
|
284
300
|
if (maxLabelWidth > 0) {
|
|
@@ -359,7 +375,7 @@ export function computeDimensions(
|
|
|
359
375
|
}
|
|
360
376
|
|
|
361
377
|
// Reserve legend space
|
|
362
|
-
if (legendLayout.entries.length > 0) {
|
|
378
|
+
if ('entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
363
379
|
const gap = legendGap(width);
|
|
364
380
|
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
365
381
|
margins.right += legendLayout.bounds.width + 8;
|
|
@@ -407,7 +423,9 @@ export function computeDimensions(
|
|
|
407
423
|
const gap = legendGap(width);
|
|
408
424
|
margins.top =
|
|
409
425
|
newTop +
|
|
410
|
-
(
|
|
426
|
+
('entries' in legendLayout &&
|
|
427
|
+
legendLayout.entries.length > 0 &&
|
|
428
|
+
legendLayout.position === 'top'
|
|
411
429
|
? legendLayout.bounds.height + gap
|
|
412
430
|
: 0);
|
|
413
431
|
margins.bottom = newBottom;
|
|
@@ -312,7 +312,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
312
312
|
);
|
|
313
313
|
|
|
314
314
|
// Reserve legend space by shrinking the drawing area
|
|
315
|
-
const legendGap = legend.entries.length > 0 ? 4 : 0;
|
|
315
|
+
const legendGap = 'entries' in legend && legend.entries.length > 0 ? 4 : 0;
|
|
316
316
|
const area: Rect = {
|
|
317
317
|
x: fullArea.x,
|
|
318
318
|
y: fullArea.y + legend.bounds.height + legendGap,
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compileTileMap } from '../../compile';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Shared fixtures
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const basicSpec = {
|
|
9
|
+
type: 'tilemap' as const,
|
|
10
|
+
data: { CA: 5.4, TX: 4.1, NY: 4.5, FL: 3.3, IL: 4.6 } as Record<string, number>,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const fullSpec = {
|
|
14
|
+
type: 'tilemap' as const,
|
|
15
|
+
data: {
|
|
16
|
+
AL: 2.7,
|
|
17
|
+
AK: 6.4,
|
|
18
|
+
AZ: 3.5,
|
|
19
|
+
AR: 3.4,
|
|
20
|
+
CA: 5.4,
|
|
21
|
+
CO: 3.4,
|
|
22
|
+
CT: 4.1,
|
|
23
|
+
DE: 4.4,
|
|
24
|
+
FL: 3.3,
|
|
25
|
+
GA: 3.4,
|
|
26
|
+
HI: 3.2,
|
|
27
|
+
ID: 3.0,
|
|
28
|
+
IL: 4.6,
|
|
29
|
+
IN: 3.3,
|
|
30
|
+
IA: 2.7,
|
|
31
|
+
KS: 3.2,
|
|
32
|
+
KY: 4.4,
|
|
33
|
+
LA: 3.6,
|
|
34
|
+
ME: 3.6,
|
|
35
|
+
MD: 1.8,
|
|
36
|
+
MA: 3.3,
|
|
37
|
+
MI: 4.2,
|
|
38
|
+
MN: 2.8,
|
|
39
|
+
MS: 3.7,
|
|
40
|
+
MO: 3.5,
|
|
41
|
+
MT: 2.9,
|
|
42
|
+
NE: 2.2,
|
|
43
|
+
NV: 5.4,
|
|
44
|
+
NH: 2.4,
|
|
45
|
+
NJ: 4.8,
|
|
46
|
+
NM: 4.1,
|
|
47
|
+
NY: 4.5,
|
|
48
|
+
NC: 3.5,
|
|
49
|
+
ND: 1.9,
|
|
50
|
+
OH: 4.0,
|
|
51
|
+
OK: 3.9,
|
|
52
|
+
OR: 4.2,
|
|
53
|
+
PA: 3.4,
|
|
54
|
+
RI: 3.8,
|
|
55
|
+
SC: 3.3,
|
|
56
|
+
SD: 2.0,
|
|
57
|
+
TN: 3.5,
|
|
58
|
+
TX: 4.1,
|
|
59
|
+
UT: 2.9,
|
|
60
|
+
VT: 2.3,
|
|
61
|
+
VA: 2.9,
|
|
62
|
+
WA: 4.6,
|
|
63
|
+
WV: 4.0,
|
|
64
|
+
WI: 2.9,
|
|
65
|
+
WY: 3.2,
|
|
66
|
+
DC: 5.2,
|
|
67
|
+
} as Record<string, number>,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const defaultOptions = { width: 600, height: 400 };
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Tests
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe('compileTileMap', () => {
|
|
77
|
+
it('always renders all 51 state tiles', () => {
|
|
78
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
79
|
+
expect(result.tiles).toHaveLength(51);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('marks data-bearing states as hasData: true', () => {
|
|
83
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
84
|
+
|
|
85
|
+
const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
|
|
86
|
+
expect(caTile.hasData).toBe(true);
|
|
87
|
+
expect(caTile.value).toBe(5.4);
|
|
88
|
+
|
|
89
|
+
const dataTiles = result.tiles.filter((t) => t.hasData);
|
|
90
|
+
expect(dataTiles).toHaveLength(5);
|
|
91
|
+
const codes = dataTiles.map((t) => t.stateCode).sort();
|
|
92
|
+
expect(codes).toEqual(['CA', 'FL', 'IL', 'NY', 'TX']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('marks missing states as hasData: false with neutral fill', () => {
|
|
96
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
97
|
+
|
|
98
|
+
const akTile = result.tiles.find((t) => t.stateCode === 'AK')!;
|
|
99
|
+
expect(akTile).toBeDefined();
|
|
100
|
+
expect(akTile.hasData).toBe(false);
|
|
101
|
+
expect(akTile.value).toBeNull();
|
|
102
|
+
expect(akTile.formattedValue).toBe('–');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('all tiles have valid position and size (x >= 0, y >= 0, size > 0)', () => {
|
|
106
|
+
const result = compileTileMap(fullSpec, defaultOptions);
|
|
107
|
+
|
|
108
|
+
for (const tile of result.tiles) {
|
|
109
|
+
expect(tile.x).toBeGreaterThanOrEqual(0);
|
|
110
|
+
expect(tile.y).toBeGreaterThanOrEqual(0);
|
|
111
|
+
expect(tile.size).toBeGreaterThan(0);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('data tiles have fill colors from the sequential palette', () => {
|
|
116
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
117
|
+
|
|
118
|
+
const dataTiles = result.tiles.filter((t) => t.hasData);
|
|
119
|
+
for (const tile of dataTiles) {
|
|
120
|
+
expect(tile.fill).toBeTruthy();
|
|
121
|
+
expect(typeof tile.fill).toBe('string');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fills = new Set(dataTiles.map((t) => t.fill));
|
|
125
|
+
expect(fills.size).toBeGreaterThan(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('compiles tabular DataRow[] data with encoding', () => {
|
|
129
|
+
const spec = {
|
|
130
|
+
type: 'tilemap' as const,
|
|
131
|
+
data: [
|
|
132
|
+
{ code: 'CA', rate: 5.4 },
|
|
133
|
+
{ code: 'TX', rate: 4.1 },
|
|
134
|
+
{ code: 'NY', rate: 4.5 },
|
|
135
|
+
],
|
|
136
|
+
encoding: {
|
|
137
|
+
state: { field: 'code', type: 'nominal' as const },
|
|
138
|
+
value: { field: 'rate', type: 'quantitative' as const },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
143
|
+
|
|
144
|
+
expect(result.tiles).toHaveLength(51);
|
|
145
|
+
const dataTiles = result.tiles.filter((t) => t.hasData);
|
|
146
|
+
expect(dataTiles).toHaveLength(3);
|
|
147
|
+
const codes = dataTiles.map((t) => t.stateCode).sort();
|
|
148
|
+
expect(codes).toEqual(['CA', 'NY', 'TX']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('handles null values in record-map data as missing', () => {
|
|
152
|
+
const spec = {
|
|
153
|
+
type: 'tilemap' as const,
|
|
154
|
+
data: { CA: 5.4, TX: null, NY: 4.5 } as Record<string, number | null>,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
158
|
+
|
|
159
|
+
const caTile = result.tiles.find((t) => t.stateCode === 'CA')!;
|
|
160
|
+
expect(caTile.hasData).toBe(true);
|
|
161
|
+
|
|
162
|
+
const txTile = result.tiles.find((t) => t.stateCode === 'TX')!;
|
|
163
|
+
expect(txTile.hasData).toBe(false);
|
|
164
|
+
expect(txTile.value).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('gradient legend', () => {
|
|
168
|
+
it('has correct min/max labels', () => {
|
|
169
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
170
|
+
|
|
171
|
+
expect(result.gradientLegend).not.toBeNull();
|
|
172
|
+
expect(result.gradientLegend!.minLabel).toBeTruthy();
|
|
173
|
+
expect(result.gradientLegend!.maxLabel).toBeTruthy();
|
|
174
|
+
expect(Number(result.gradientLegend!.minLabel)).toBeLessThan(
|
|
175
|
+
Number(result.gradientLegend!.maxLabel),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('has colorStops', () => {
|
|
180
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
181
|
+
|
|
182
|
+
expect(result.gradientLegend!.colorStops.length).toBeGreaterThan(0);
|
|
183
|
+
for (const stop of result.gradientLegend!.colorStops) {
|
|
184
|
+
expect(stop.offset).toBeGreaterThanOrEqual(0);
|
|
185
|
+
expect(stop.offset).toBeLessThanOrEqual(1);
|
|
186
|
+
expect(stop.color).toBeTruthy();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('is null when legend.show is false', () => {
|
|
191
|
+
const spec = { ...basicSpec, legend: { show: false } };
|
|
192
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
193
|
+
|
|
194
|
+
expect(result.gradientLegend).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('valueFormat', () => {
|
|
199
|
+
it('applies to tile formattedValue', () => {
|
|
200
|
+
const spec = { ...basicSpec, valueFormat: '.1f' };
|
|
201
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
202
|
+
|
|
203
|
+
const caTile = result.tiles.find((t) => t.stateCode === 'CA');
|
|
204
|
+
expect(caTile).toBeDefined();
|
|
205
|
+
expect(caTile!.formattedValue).toBe('5.4');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('dark mode', () => {
|
|
210
|
+
it('reverses palette direction compared to light mode', () => {
|
|
211
|
+
const lightResult = compileTileMap(basicSpec, defaultOptions);
|
|
212
|
+
const darkResult = compileTileMap(basicSpec, { ...defaultOptions, darkMode: true });
|
|
213
|
+
|
|
214
|
+
const lightFL = lightResult.tiles.find((t) => t.stateCode === 'FL');
|
|
215
|
+
const darkFL = darkResult.tiles.find((t) => t.stateCode === 'FL');
|
|
216
|
+
expect(lightFL!.fill).not.toBe(darkFL!.fill);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('chrome', () => {
|
|
221
|
+
it('resolves title and subtitle', () => {
|
|
222
|
+
const spec = {
|
|
223
|
+
...basicSpec,
|
|
224
|
+
chrome: {
|
|
225
|
+
title: 'Test Title',
|
|
226
|
+
subtitle: 'Test Subtitle',
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
const result = compileTileMap(spec, defaultOptions);
|
|
230
|
+
|
|
231
|
+
expect(result.chrome.title).toBeDefined();
|
|
232
|
+
expect(result.chrome.title!.text).toBe('Test Title');
|
|
233
|
+
expect(result.chrome.subtitle).toBeDefined();
|
|
234
|
+
expect(result.chrome.subtitle!.text).toBe('Test Subtitle');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('tooltip descriptors', () => {
|
|
239
|
+
it('contains entries for all tiles', () => {
|
|
240
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
241
|
+
|
|
242
|
+
expect(result.tooltipDescriptors.has('CA')).toBe(true);
|
|
243
|
+
expect(result.tooltipDescriptors.has('TX')).toBe(true);
|
|
244
|
+
expect(result.tooltipDescriptors.size).toBe(51);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('tooltip has title and value field', () => {
|
|
248
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
249
|
+
|
|
250
|
+
const tooltip = result.tooltipDescriptors.get('CA')!;
|
|
251
|
+
expect(tooltip.title).toBe('California');
|
|
252
|
+
expect(tooltip.fields.length).toBeGreaterThan(0);
|
|
253
|
+
expect(tooltip.fields.some((f) => f.label === 'Value')).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('a11y', () => {
|
|
258
|
+
it('generates descriptive alt text', () => {
|
|
259
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
260
|
+
|
|
261
|
+
expect(result.a11y.altText).toContain('Tile map');
|
|
262
|
+
expect(result.a11y.altText).toContain('US states');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('has a data table fallback', () => {
|
|
266
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
267
|
+
|
|
268
|
+
expect(result.a11y.dataTableFallback.length).toBeGreaterThan(0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('validation', () => {
|
|
273
|
+
it('throws on non-tilemap spec', () => {
|
|
274
|
+
const chartSpec = {
|
|
275
|
+
mark: 'bar' as const,
|
|
276
|
+
data: [{ x: 1, y: 2 }],
|
|
277
|
+
encoding: {
|
|
278
|
+
x: { field: 'x', type: 'quantitative' as const },
|
|
279
|
+
y: { field: 'y', type: 'quantitative' as const },
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
expect(() => compileTileMap(chartSpec, defaultOptions)).toThrow(/non-tilemap/);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('throws on empty record-map data', () => {
|
|
287
|
+
const spec = {
|
|
288
|
+
type: 'tilemap' as const,
|
|
289
|
+
data: {} as Record<string, number>,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
expect(() => compileTileMap(spec, defaultOptions)).toThrow();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('dimensions', () => {
|
|
297
|
+
it('reflects the compile options', () => {
|
|
298
|
+
const result = compileTileMap(basicSpec, defaultOptions);
|
|
299
|
+
|
|
300
|
+
expect(result.width).toBe(600);
|
|
301
|
+
expect(result.height).toBe(400);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('works with different container sizes', () => {
|
|
305
|
+
const result = compileTileMap(basicSpec, { width: 800, height: 600 });
|
|
306
|
+
|
|
307
|
+
expect(result.width).toBe(800);
|
|
308
|
+
expect(result.height).toBe(600);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('palette', () => {
|
|
313
|
+
it('uses the specified palette (data tiles differ between palettes)', () => {
|
|
314
|
+
const blueResult = compileTileMap(basicSpec, defaultOptions);
|
|
315
|
+
const greenResult = compileTileMap({ ...basicSpec, palette: 'green' }, defaultOptions);
|
|
316
|
+
|
|
317
|
+
const blueCa = blueResult.tiles.find((t) => t.stateCode === 'CA')!;
|
|
318
|
+
const greenCa = greenResult.tiles.find((t) => t.stateCode === 'CA')!;
|
|
319
|
+
expect(blueCa.fill).not.toBe(greenCa.fill);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|