@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.10

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 (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/facet.test.ts +144 -0
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +69 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +228 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +318 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,1128 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileMap } from '../../compile';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Shared fixtures
6
+ // ---------------------------------------------------------------------------
7
+
8
+ // Minimal valid TopoJSON with 3 US states.
9
+ // Arcs are delta-encoded as per the TopoJSON specification:
10
+ // first point is absolute, subsequent points are deltas.
11
+ const MINI_TOPO = {
12
+ type: 'Topology',
13
+ objects: {
14
+ states: {
15
+ type: 'GeometryCollection',
16
+ geometries: [
17
+ { type: 'Polygon', id: '06', properties: { name: 'California' }, arcs: [[0]] },
18
+ { type: 'Polygon', id: '48', properties: { name: 'Texas' }, arcs: [[1]] },
19
+ { type: 'Polygon', id: '36', properties: { name: 'New York' }, arcs: [[2]] },
20
+ ],
21
+ },
22
+ },
23
+ arcs: [
24
+ // California (approximate): (-122,37) -> (-118,34) -> (-121,36) -> (-122,37)
25
+ [
26
+ [-122, 37],
27
+ [4, -3],
28
+ [-3, 2],
29
+ [-1, 1],
30
+ ],
31
+ // Texas (approximate): (-100,31) -> (-94,30) -> (-97,26) -> (-100,31)
32
+ [
33
+ [-100, 31],
34
+ [6, -1],
35
+ [-3, -4],
36
+ [-3, 5],
37
+ ],
38
+ // New York (approximate): (-74,41) -> (-73,40) -> (-76,42) -> (-74,41)
39
+ [
40
+ [-74, 41],
41
+ [1, -1],
42
+ [-3, 2],
43
+ [2, -1],
44
+ ],
45
+ ],
46
+ };
47
+
48
+ const DEFAULT_OPTIONS = { width: 600, height: 400 };
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Tests
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe('compileMap', () => {
55
+ it('compiles a minimal MapSpec and produces features with paths', () => {
56
+ const layout = compileMap(
57
+ {
58
+ type: 'map',
59
+ geo: { features: MINI_TOPO, projection: 'mercator' },
60
+ data: [
61
+ { fips: '06', value: 10 },
62
+ { fips: '48', value: 20 },
63
+ { fips: '36', value: 30 },
64
+ ],
65
+ encoding: {
66
+ key: { field: 'fips', type: 'nominal' },
67
+ color: { field: 'value', type: 'quantitative' },
68
+ },
69
+ },
70
+ DEFAULT_OPTIONS,
71
+ );
72
+
73
+ expect(layout.features).toHaveLength(3);
74
+ for (const f of layout.features) {
75
+ expect(f.path).toBeTruthy();
76
+ expect(f.fill).toBeTruthy();
77
+ expect(f.type).toBe('map-feature');
78
+ }
79
+ // Non-shared arcs produce no interior mesh, but outlines should exist
80
+ expect(layout.borders.outlinePath).toBeTruthy();
81
+ expect(layout.width).toBe(600);
82
+ });
83
+
84
+ it('produces unmatched data key warnings', () => {
85
+ const layout = compileMap(
86
+ {
87
+ type: 'map',
88
+ geo: { features: MINI_TOPO, projection: 'mercator' },
89
+ data: [{ fips: '99', value: 100 }],
90
+ encoding: {
91
+ key: { field: 'fips', type: 'nominal' },
92
+ color: { field: 'value', type: 'quantitative' },
93
+ },
94
+ },
95
+ DEFAULT_OPTIONS,
96
+ );
97
+
98
+ const warning = layout.warnings.find((w) => w.code === 'UNMATCHED_DATA_KEYS');
99
+ expect(warning).toBeDefined();
100
+ expect(warning!.message).toContain('99');
101
+ });
102
+
103
+ it('produces unmatched features warning when features have no data', () => {
104
+ const layout = compileMap(
105
+ {
106
+ type: 'map',
107
+ geo: { features: MINI_TOPO, projection: 'mercator' },
108
+ data: [{ fips: '06', value: 10 }],
109
+ encoding: {
110
+ key: { field: 'fips', type: 'nominal' },
111
+ color: { field: 'value', type: 'quantitative' },
112
+ },
113
+ },
114
+ DEFAULT_OPTIONS,
115
+ );
116
+
117
+ const warning = layout.warnings.find((w) => w.code === 'UNMATCHED_FEATURES');
118
+ expect(warning).toBeDefined();
119
+ expect(warning!.message).toContain('2');
120
+ });
121
+
122
+ it('throws self-documenting error when geo.features is missing', () => {
123
+ expect(() =>
124
+ compileMap(
125
+ {
126
+ type: 'map',
127
+ geo: { features: null },
128
+ data: [],
129
+ encoding: {
130
+ key: { field: 'fips', type: 'nominal' },
131
+ color: { field: 'value', type: 'quantitative' },
132
+ },
133
+ },
134
+ DEFAULT_OPTIONS,
135
+ ),
136
+ ).toThrow(/features/);
137
+ });
138
+
139
+ it('throws when geo.features is not valid TopoJSON', () => {
140
+ expect(() =>
141
+ compileMap(
142
+ {
143
+ type: 'map',
144
+ geo: { features: { type: 'FeatureCollection', features: [] } },
145
+ data: [],
146
+ encoding: {
147
+ key: { field: 'fips', type: 'nominal' },
148
+ color: { field: 'value', type: 'quantitative' },
149
+ },
150
+ },
151
+ DEFAULT_OPTIONS,
152
+ ),
153
+ ).toThrow(/Topology/);
154
+ });
155
+
156
+ it('uses mercator projection and produces valid SVG paths', () => {
157
+ const layout = compileMap(
158
+ {
159
+ type: 'map',
160
+ geo: { features: MINI_TOPO, projection: 'mercator' },
161
+ data: [{ fips: '06', value: 10 }],
162
+ encoding: {
163
+ key: { field: 'fips', type: 'nominal' },
164
+ color: { field: 'value', type: 'quantitative' },
165
+ },
166
+ },
167
+ DEFAULT_OPTIONS,
168
+ );
169
+
170
+ expect(layout.features.length).toBeGreaterThan(0);
171
+ expect(layout.features[0].path).toMatch(/^M/);
172
+ });
173
+
174
+ it('uses equalEarth projection', () => {
175
+ const layout = compileMap(
176
+ {
177
+ type: 'map',
178
+ geo: { features: MINI_TOPO, projection: 'equalEarth' },
179
+ data: [{ fips: '06', value: 10 }],
180
+ encoding: {
181
+ key: { field: 'fips', type: 'nominal' },
182
+ color: { field: 'value', type: 'quantitative' },
183
+ },
184
+ },
185
+ DEFAULT_OPTIONS,
186
+ );
187
+
188
+ expect(layout.features.length).toBeGreaterThan(0);
189
+ });
190
+
191
+ it('includes a continuous legend for quantitative encoding', () => {
192
+ const layout = compileMap(
193
+ {
194
+ type: 'map',
195
+ geo: { features: MINI_TOPO, projection: 'mercator' },
196
+ data: [
197
+ { fips: '06', value: 10 },
198
+ { fips: '48', value: 50 },
199
+ ],
200
+ encoding: {
201
+ key: { field: 'fips', type: 'nominal' },
202
+ color: { field: 'value', type: 'quantitative' },
203
+ },
204
+ },
205
+ DEFAULT_OPTIONS,
206
+ );
207
+
208
+ expect(layout.continuousLegend).not.toBeNull();
209
+ expect(layout.continuousLegend!.type).toBe('continuous');
210
+ expect(layout.categoricalLegend).toBeNull();
211
+ });
212
+
213
+ it('continuous legend has 5 bins by default', () => {
214
+ const layout = compileMap(
215
+ {
216
+ type: 'map',
217
+ geo: { features: MINI_TOPO, projection: 'mercator' },
218
+ data: [
219
+ { fips: '06', value: 10 },
220
+ { fips: '48', value: 50 },
221
+ { fips: '36', value: 30 },
222
+ ],
223
+ encoding: {
224
+ key: { field: 'fips', type: 'nominal' },
225
+ color: { field: 'value', type: 'quantitative' },
226
+ },
227
+ },
228
+ DEFAULT_OPTIONS,
229
+ );
230
+
231
+ expect(layout.continuousLegend).not.toBeNull();
232
+ expect(layout.continuousLegend!.mode).toBe('binned');
233
+ expect(layout.continuousLegend!.bins.length).toBe(5);
234
+ });
235
+
236
+ it('continuous legend defaults to top position', () => {
237
+ const layout = compileMap(
238
+ {
239
+ type: 'map',
240
+ geo: { features: MINI_TOPO, projection: 'mercator' },
241
+ data: [
242
+ { fips: '06', value: 10 },
243
+ { fips: '48', value: 50 },
244
+ ],
245
+ encoding: {
246
+ key: { field: 'fips', type: 'nominal' },
247
+ color: { field: 'value', type: 'quantitative' },
248
+ },
249
+ },
250
+ DEFAULT_OPTIONS,
251
+ );
252
+
253
+ expect(layout.continuousLegend!.position).toBe('top');
254
+ });
255
+
256
+ it('honors position: bottom for continuous legend', () => {
257
+ const layout = compileMap(
258
+ {
259
+ type: 'map',
260
+ geo: { features: MINI_TOPO, projection: 'mercator' },
261
+ data: [
262
+ { fips: '06', value: 10 },
263
+ { fips: '48', value: 50 },
264
+ ],
265
+ encoding: {
266
+ key: { field: 'fips', type: 'nominal' },
267
+ color: { field: 'value', type: 'quantitative' },
268
+ },
269
+ legend: { position: 'bottom' },
270
+ },
271
+ DEFAULT_OPTIONS,
272
+ );
273
+
274
+ expect(layout.continuousLegend!.position).toBe('bottom');
275
+ });
276
+
277
+ it('builds categorical legend for nominal encoding', () => {
278
+ const layout = compileMap(
279
+ {
280
+ type: 'map',
281
+ geo: { features: MINI_TOPO, projection: 'mercator' },
282
+ data: [
283
+ { fips: '06', category: 'west' },
284
+ { fips: '48', category: 'south' },
285
+ ],
286
+ encoding: {
287
+ key: { field: 'fips', type: 'nominal' },
288
+ color: { field: 'category', type: 'nominal' },
289
+ },
290
+ },
291
+ DEFAULT_OPTIONS,
292
+ );
293
+
294
+ expect(layout.categoricalLegend).not.toBeNull();
295
+ expect(layout.categoricalLegend!.entries).toHaveLength(2);
296
+ expect(layout.continuousLegend).toBeNull();
297
+ });
298
+
299
+ it('categorical legend defaults to top and sits above the map area', () => {
300
+ const layout = compileMap(
301
+ {
302
+ type: 'map',
303
+ geo: { features: MINI_TOPO, projection: 'mercator' },
304
+ data: [
305
+ { fips: '06', category: 'west' },
306
+ { fips: '48', category: 'south' },
307
+ ],
308
+ encoding: {
309
+ key: { field: 'fips', type: 'nominal' },
310
+ color: { field: 'category', type: 'nominal' },
311
+ },
312
+ },
313
+ DEFAULT_OPTIONS,
314
+ );
315
+
316
+ const legend = layout.categoricalLegend!;
317
+ expect(legend.position).toBe('top');
318
+ // The map area shifts down to make room for a top legend; the legend row
319
+ // must land above it, not inside it (the old bottom-anchored legend
320
+ // overlapped the map because the two disagreed about the layout).
321
+ expect(legend.bounds.y + legend.bounds.height).toBeLessThanOrEqual(layout.area.y);
322
+ });
323
+
324
+ it('honors position: bottom for categorical legend', () => {
325
+ const layout = compileMap(
326
+ {
327
+ type: 'map',
328
+ geo: { features: MINI_TOPO, projection: 'mercator' },
329
+ data: [
330
+ { fips: '06', category: 'west' },
331
+ { fips: '48', category: 'south' },
332
+ ],
333
+ encoding: {
334
+ key: { field: 'fips', type: 'nominal' },
335
+ color: { field: 'category', type: 'nominal' },
336
+ },
337
+ legend: { position: 'bottom' },
338
+ },
339
+ DEFAULT_OPTIONS,
340
+ );
341
+
342
+ const legend = layout.categoricalLegend!;
343
+ expect(legend.position).toBe('bottom');
344
+ // Bottom legend starts at or below the map area's bottom edge.
345
+ expect(legend.bounds.y).toBeGreaterThanOrEqual(layout.area.y + layout.area.height);
346
+ });
347
+
348
+ it('assigns neutral fill to features without data', () => {
349
+ const layout = compileMap(
350
+ {
351
+ type: 'map',
352
+ geo: { features: MINI_TOPO, projection: 'mercator' },
353
+ data: [{ fips: '06', value: 10 }],
354
+ encoding: {
355
+ key: { field: 'fips', type: 'nominal' },
356
+ color: { field: 'value', type: 'quantitative' },
357
+ },
358
+ },
359
+ DEFAULT_OPTIONS,
360
+ );
361
+
362
+ const matched = layout.features.find((f) => f.id === '06');
363
+ const unmatched = layout.features.find((f) => f.id === '48');
364
+ expect(matched).toBeDefined();
365
+ expect(unmatched).toBeDefined();
366
+ expect(unmatched!.fill).not.toBe(matched!.fill);
367
+ });
368
+
369
+ it('emits warnings via onWarn callback', () => {
370
+ const warnings: string[] = [];
371
+ compileMap(
372
+ {
373
+ type: 'map',
374
+ geo: { features: MINI_TOPO, projection: 'mercator' },
375
+ data: [{ fips: '99', value: 100 }],
376
+ encoding: {
377
+ key: { field: 'fips', type: 'nominal' },
378
+ color: { field: 'value', type: 'quantitative' },
379
+ },
380
+ },
381
+ { ...DEFAULT_OPTIONS, onWarn: (msg) => warnings.push(msg) },
382
+ );
383
+
384
+ expect(warnings.length).toBeGreaterThan(0);
385
+ expect(warnings.some((w) => w.includes('99'))).toBe(true);
386
+ });
387
+
388
+ it('identity projection paths fit inside layout.area', () => {
389
+ // Pre-projected coordinates (pixel-space) that identity should scale to fit
390
+ const PRE_PROJECTED_TOPO = {
391
+ type: 'Topology',
392
+ objects: {
393
+ regions: {
394
+ type: 'GeometryCollection',
395
+ geometries: [
396
+ { type: 'Polygon', id: 'A', properties: { name: 'Region A' }, arcs: [[0]] },
397
+ { type: 'Polygon', id: 'B', properties: { name: 'Region B' }, arcs: [[1]] },
398
+ ],
399
+ },
400
+ },
401
+ arcs: [
402
+ // Region A: (0,0) -> (100,0) -> (100,50) -> (0,50) -> (0,0)
403
+ [
404
+ [0, 0],
405
+ [100, 0],
406
+ [0, 50],
407
+ [-100, 0],
408
+ [0, -50],
409
+ ],
410
+ // Region B: (100,0) -> (200,0) -> (200,50) -> (100,50) -> (100,0)
411
+ [
412
+ [100, 0],
413
+ [100, 0],
414
+ [0, 50],
415
+ [-100, 0],
416
+ [0, -50],
417
+ ],
418
+ ],
419
+ };
420
+
421
+ const layout = compileMap(
422
+ {
423
+ type: 'map',
424
+ geo: { features: PRE_PROJECTED_TOPO, projection: 'identity' },
425
+ data: [
426
+ { id: 'A', value: 10 },
427
+ { id: 'B', value: 20 },
428
+ ],
429
+ encoding: {
430
+ key: { field: 'id', type: 'nominal' },
431
+ color: { field: 'value', type: 'quantitative' },
432
+ },
433
+ },
434
+ DEFAULT_OPTIONS,
435
+ );
436
+
437
+ expect(layout.features.length).toBe(2);
438
+ // Verify paths exist and geometry actually scaled (not just passthrough)
439
+ for (const f of layout.features) {
440
+ expect(f.path).toMatch(/^M/);
441
+ // Extract coordinates from the path and verify they fit within layout area
442
+ const coords = f.path.match(/[-\d.]+/g)?.map(Number) ?? [];
443
+ expect(coords.length).toBeGreaterThan(0);
444
+ }
445
+ });
446
+
447
+ it('identity projection emits no INVERTED_WINDING warning', () => {
448
+ const warnings: string[] = [];
449
+ const layout = compileMap(
450
+ {
451
+ type: 'map',
452
+ geo: { features: MINI_TOPO, projection: 'identity' },
453
+ data: [{ fips: '06', value: 10 }],
454
+ encoding: {
455
+ key: { field: 'fips', type: 'nominal' },
456
+ color: { field: 'value', type: 'quantitative' },
457
+ },
458
+ },
459
+ { ...DEFAULT_OPTIONS, onWarn: (msg) => warnings.push(msg) },
460
+ );
461
+
462
+ const windingWarning = layout.warnings.find((w) => w.code === 'INVERTED_WINDING');
463
+ expect(windingWarning).toBeUndefined();
464
+ });
465
+
466
+ it('every feature has finite bounds and centroid within mapSize', () => {
467
+ const layout = compileMap(
468
+ {
469
+ type: 'map',
470
+ geo: { features: MINI_TOPO, projection: 'mercator' },
471
+ data: [
472
+ { fips: '06', value: 10 },
473
+ { fips: '48', value: 20 },
474
+ { fips: '36', value: 30 },
475
+ ],
476
+ encoding: {
477
+ key: { field: 'fips', type: 'nominal' },
478
+ color: { field: 'value', type: 'quantitative' },
479
+ },
480
+ },
481
+ DEFAULT_OPTIONS,
482
+ );
483
+
484
+ const { mapSize } = layout;
485
+ for (const f of layout.features) {
486
+ expect(Number.isFinite(f.bounds.x)).toBe(true);
487
+ expect(Number.isFinite(f.bounds.y)).toBe(true);
488
+ expect(Number.isFinite(f.bounds.width)).toBe(true);
489
+ expect(Number.isFinite(f.bounds.height)).toBe(true);
490
+ expect(f.bounds.width).toBeGreaterThan(0);
491
+ expect(f.bounds.height).toBeGreaterThan(0);
492
+
493
+ // Centroid should be finite and within the mapSize
494
+ expect(Number.isFinite(f.centroid[0])).toBe(true);
495
+ expect(Number.isFinite(f.centroid[1])).toBe(true);
496
+ expect(f.centroid[0]).toBeGreaterThanOrEqual(0);
497
+ expect(f.centroid[0]).toBeLessThanOrEqual(mapSize.width);
498
+ expect(f.centroid[1]).toBeGreaterThanOrEqual(0);
499
+ expect(f.centroid[1]).toBeLessThanOrEqual(mapSize.height);
500
+ }
501
+ });
502
+
503
+ it('mapSize has positive width and height', () => {
504
+ const layout = compileMap(
505
+ {
506
+ type: 'map',
507
+ geo: { features: MINI_TOPO, projection: 'mercator' },
508
+ data: [
509
+ { fips: '06', value: 10 },
510
+ { fips: '48', value: 20 },
511
+ ],
512
+ encoding: {
513
+ key: { field: 'fips', type: 'nominal' },
514
+ color: { field: 'value', type: 'quantitative' },
515
+ },
516
+ },
517
+ DEFAULT_OPTIONS,
518
+ );
519
+
520
+ expect(layout.mapSize.width).toBeGreaterThan(0);
521
+ expect(layout.mapSize.height).toBeGreaterThan(0);
522
+ });
523
+
524
+ it('resolves chrome (title, subtitle)', () => {
525
+ const layout = compileMap(
526
+ {
527
+ type: 'map',
528
+ geo: { features: MINI_TOPO, projection: 'mercator' },
529
+ data: [{ fips: '06', value: 10 }],
530
+ encoding: {
531
+ key: { field: 'fips', type: 'nominal' },
532
+ color: { field: 'value', type: 'quantitative' },
533
+ },
534
+ chrome: { title: 'Test Map', subtitle: 'Test subtitle' },
535
+ },
536
+ DEFAULT_OPTIONS,
537
+ );
538
+
539
+ expect(layout.chrome.title).toBeDefined();
540
+ expect(layout.chrome.subtitle).toBeDefined();
541
+ });
542
+
543
+ // ---------------------------------------------------------------------------
544
+ // Focus resolution
545
+ // ---------------------------------------------------------------------------
546
+
547
+ it('geo.focus as single string produces layout.focus with matching bounds', () => {
548
+ const layout = compileMap(
549
+ {
550
+ type: 'map',
551
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: '36' },
552
+ data: [
553
+ { fips: '06', value: 10 },
554
+ { fips: '48', value: 20 },
555
+ { fips: '36', value: 30 },
556
+ ],
557
+ encoding: {
558
+ key: { field: 'fips', type: 'nominal' },
559
+ color: { field: 'value', type: 'quantitative' },
560
+ },
561
+ },
562
+ DEFAULT_OPTIONS,
563
+ );
564
+
565
+ expect(layout.focus).not.toBeNull();
566
+ expect(layout.focus!.ids).toEqual(['36']);
567
+ expect(layout.focus!.target.width).toBeGreaterThan(0);
568
+ expect(layout.focus!.target.height).toBeGreaterThan(0);
569
+ expect(layout.focus!.target.padding).toBe(16);
570
+ });
571
+
572
+ it('geo.focus as array produces union bounds', () => {
573
+ const layout = compileMap(
574
+ {
575
+ type: 'map',
576
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: ['36', '06'] },
577
+ data: [
578
+ { fips: '06', value: 10 },
579
+ { fips: '36', value: 30 },
580
+ ],
581
+ encoding: {
582
+ key: { field: 'fips', type: 'nominal' },
583
+ color: { field: 'value', type: 'quantitative' },
584
+ },
585
+ },
586
+ DEFAULT_OPTIONS,
587
+ );
588
+
589
+ expect(layout.focus).not.toBeNull();
590
+ expect(layout.focus!.ids).toHaveLength(2);
591
+ expect(layout.focus!.ids.map(String).sort()).toEqual(['06', '36']);
592
+ });
593
+
594
+ it('geo.focus object form with custom padding', () => {
595
+ const layout = compileMap(
596
+ {
597
+ type: 'map',
598
+ geo: {
599
+ features: MINI_TOPO,
600
+ projection: 'mercator',
601
+ focus: { features: '36', padding: 32 },
602
+ },
603
+ data: [{ fips: '36', value: 30 }],
604
+ encoding: {
605
+ key: { field: 'fips', type: 'nominal' },
606
+ color: { field: 'value', type: 'quantitative' },
607
+ },
608
+ },
609
+ DEFAULT_OPTIONS,
610
+ );
611
+
612
+ expect(layout.focus).not.toBeNull();
613
+ expect(layout.focus!.target.padding).toBe(32);
614
+ expect(layout.focus!.ids).toEqual(['36']);
615
+ });
616
+
617
+ it('geo.focus: null produces layout.focus: null', () => {
618
+ const layout = compileMap(
619
+ {
620
+ type: 'map',
621
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: null },
622
+ data: [{ fips: '06', value: 10 }],
623
+ encoding: {
624
+ key: { field: 'fips', type: 'nominal' },
625
+ color: { field: 'value', type: 'quantitative' },
626
+ },
627
+ },
628
+ DEFAULT_OPTIONS,
629
+ );
630
+
631
+ expect(layout.focus).toBeNull();
632
+ });
633
+
634
+ it('geo.focus { points: true } fits the point cluster, not any feature', () => {
635
+ const layout = compileMap(
636
+ {
637
+ type: 'map',
638
+ geo: {
639
+ features: MINI_TOPO,
640
+ projection: 'mercator',
641
+ focus: { points: true, padding: 4 },
642
+ },
643
+ data: [],
644
+ encoding: { key: { field: 'id', type: 'nominal' } },
645
+ points: {
646
+ data: [
647
+ { lat: 34, lon: -118, name: 'LA' },
648
+ { lat: 40.7, lon: -74, name: 'NYC' },
649
+ ],
650
+ longitude: { field: 'lon', type: 'quantitative' },
651
+ latitude: { field: 'lat', type: 'quantitative' },
652
+ key: { field: 'name', type: 'nominal' },
653
+ },
654
+ },
655
+ DEFAULT_OPTIONS,
656
+ );
657
+
658
+ expect(layout.focus).not.toBeNull();
659
+ expect(layout.focus!.target.padding).toBe(4);
660
+ // No feature ids: an all-points focus dims no features.
661
+ expect(layout.focus!.ids).toEqual([]);
662
+ // The target spans the two points (plus their radii), which sit far apart,
663
+ // so it has real width and height.
664
+ expect(layout.focus!.target.width).toBeGreaterThan(0);
665
+ expect(layout.focus!.target.height).toBeGreaterThan(0);
666
+ // The fitted cluster must be smaller than the full map extent.
667
+ expect(layout.focus!.target.width).toBeLessThan(layout.mapSize.width);
668
+ });
669
+
670
+ it('geo.focus { points: { field, value } } fits only the matching subset', () => {
671
+ const opts = {
672
+ data: [
673
+ { lat: 34, lon: -118, name: 'LA', rating: 'A' },
674
+ { lat: 40.7, lon: -74, name: 'NYC', rating: 'F' },
675
+ ],
676
+ longitude: { field: 'lon', type: 'quantitative' as const },
677
+ latitude: { field: 'lat', type: 'quantitative' as const },
678
+ key: { field: 'name', type: 'nominal' as const },
679
+ };
680
+ const all = compileMap(
681
+ {
682
+ type: 'map',
683
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: { points: true } },
684
+ data: [],
685
+ encoding: { key: { field: 'id', type: 'nominal' } },
686
+ points: opts,
687
+ },
688
+ DEFAULT_OPTIONS,
689
+ );
690
+ const subset = compileMap(
691
+ {
692
+ type: 'map',
693
+ geo: {
694
+ features: MINI_TOPO,
695
+ projection: 'mercator',
696
+ focus: { points: { field: 'rating', value: 'A' } },
697
+ },
698
+ data: [],
699
+ encoding: { key: { field: 'id', type: 'nominal' } },
700
+ points: opts,
701
+ },
702
+ DEFAULT_OPTIONS,
703
+ );
704
+
705
+ expect(subset.focus).not.toBeNull();
706
+ // One point vs two: the subset target is strictly smaller than the all-points target.
707
+ expect(subset.focus!.target.width).toBeLessThan(all.focus!.target.width);
708
+ });
709
+
710
+ it('geo.focus { points: { field, value } } with no match emits FOCUS_UNMATCHED', () => {
711
+ const layout = compileMap(
712
+ {
713
+ type: 'map',
714
+ geo: {
715
+ features: MINI_TOPO,
716
+ projection: 'mercator',
717
+ focus: { points: { field: 'rating', value: 'Z' } },
718
+ },
719
+ data: [],
720
+ encoding: { key: { field: 'id', type: 'nominal' } },
721
+ points: {
722
+ data: [{ lat: 34, lon: -118, name: 'LA', rating: 'A' }],
723
+ longitude: { field: 'lon', type: 'quantitative' },
724
+ latitude: { field: 'lat', type: 'quantitative' },
725
+ key: { field: 'name', type: 'nominal' },
726
+ },
727
+ },
728
+ DEFAULT_OPTIONS,
729
+ );
730
+
731
+ expect(layout.focus).toBeNull();
732
+ expect(layout.warnings.some((w) => w.code === 'FOCUS_UNMATCHED')).toBe(true);
733
+ });
734
+
735
+ it('geo.focus { points: true } with no points emits FOCUS_UNMATCHED and no focus', () => {
736
+ const layout = compileMap(
737
+ {
738
+ type: 'map',
739
+ geo: {
740
+ features: MINI_TOPO,
741
+ projection: 'mercator',
742
+ focus: { points: true },
743
+ },
744
+ data: [{ fips: '06', value: 10 }],
745
+ encoding: {
746
+ key: { field: 'fips', type: 'nominal' },
747
+ color: { field: 'value', type: 'quantitative' },
748
+ },
749
+ },
750
+ DEFAULT_OPTIONS,
751
+ );
752
+
753
+ expect(layout.focus).toBeNull();
754
+ expect(layout.warnings.some((w) => w.code === 'FOCUS_UNMATCHED')).toBe(true);
755
+ });
756
+
757
+ it('unknown focus id emits FOCUS_UNMATCHED warning', () => {
758
+ const layout = compileMap(
759
+ {
760
+ type: 'map',
761
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: '99' },
762
+ data: [{ fips: '06', value: 10 }],
763
+ encoding: {
764
+ key: { field: 'fips', type: 'nominal' },
765
+ color: { field: 'value', type: 'quantitative' },
766
+ },
767
+ },
768
+ DEFAULT_OPTIONS,
769
+ );
770
+
771
+ const warning = layout.warnings.find((w) => w.code === 'FOCUS_UNMATCHED');
772
+ expect(warning).toBeDefined();
773
+ expect(warning!.message).toContain('99');
774
+ expect(layout.focus).toBeNull();
775
+ });
776
+
777
+ it('no focus specified produces layout.focus: null', () => {
778
+ const layout = compileMap(
779
+ {
780
+ type: 'map',
781
+ geo: { features: MINI_TOPO, projection: 'mercator' },
782
+ data: [{ fips: '06', value: 10 }],
783
+ encoding: {
784
+ key: { field: 'fips', type: 'nominal' },
785
+ color: { field: 'value', type: 'quantitative' },
786
+ },
787
+ },
788
+ DEFAULT_OPTIONS,
789
+ );
790
+
791
+ expect(layout.focus).toBeNull();
792
+ });
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // Point layer
796
+ // ---------------------------------------------------------------------------
797
+
798
+ describe('point layer', () => {
799
+ it('compiles points through projection', () => {
800
+ const layout = compileMap(
801
+ {
802
+ type: 'map',
803
+ geo: { features: MINI_TOPO, projection: 'mercator' },
804
+ data: [],
805
+ encoding: { key: { field: 'id', type: 'nominal' } },
806
+ points: {
807
+ data: [
808
+ { lat: 34, lon: -118, name: 'LA' },
809
+ { lat: 40.7, lon: -74, name: 'NYC' },
810
+ ],
811
+ longitude: { field: 'lon', type: 'quantitative' },
812
+ latitude: { field: 'lat', type: 'quantitative' },
813
+ key: { field: 'name', type: 'nominal' },
814
+ },
815
+ },
816
+ DEFAULT_OPTIONS,
817
+ );
818
+
819
+ expect(layout.pointMarks).toHaveLength(2);
820
+ for (const pm of layout.pointMarks) {
821
+ expect(Number.isFinite(pm.cx)).toBe(true);
822
+ expect(Number.isFinite(pm.cy)).toBe(true);
823
+ }
824
+ });
825
+
826
+ it('drops null-projecting points with warning', () => {
827
+ const layout = compileMap(
828
+ {
829
+ type: 'map',
830
+ geo: { features: MINI_TOPO, projection: 'albersUsa' },
831
+ data: [],
832
+ encoding: { key: { field: 'id', type: 'nominal' } },
833
+ points: {
834
+ data: [{ lat: 0, lon: 0, name: 'Pacific' }],
835
+ longitude: { field: 'lon', type: 'quantitative' },
836
+ latitude: { field: 'lat', type: 'quantitative' },
837
+ key: { field: 'name', type: 'nominal' },
838
+ },
839
+ },
840
+ DEFAULT_OPTIONS,
841
+ );
842
+
843
+ expect(layout.pointMarks).toHaveLength(0);
844
+ const warning = layout.warnings.find((w) => w.code === 'POINT_NULL_PROJECTION');
845
+ expect(warning).toBeDefined();
846
+ });
847
+
848
+ it('builds sqrt size scale for points', () => {
849
+ const layout = compileMap(
850
+ {
851
+ type: 'map',
852
+ geo: { features: MINI_TOPO, projection: 'mercator' },
853
+ data: [],
854
+ encoding: { key: { field: 'id', type: 'nominal' } },
855
+ points: {
856
+ data: [
857
+ { lat: 34, lon: -118, name: 'A', value: 10 },
858
+ { lat: 40.7, lon: -74, name: 'B', value: 1000 },
859
+ ],
860
+ longitude: { field: 'lon', type: 'quantitative' },
861
+ latitude: { field: 'lat', type: 'quantitative' },
862
+ size: { field: 'value', type: 'quantitative' },
863
+ key: { field: 'name', type: 'nominal' },
864
+ },
865
+ },
866
+ DEFAULT_OPTIONS,
867
+ );
868
+
869
+ expect(layout.pointMarks).toHaveLength(2);
870
+ const radii = layout.pointMarks.map((pm) => pm.r);
871
+ expect(radii[0]).not.toBe(radii[1]);
872
+ // Map point size scale range is [3, 20]
873
+ for (const r of radii) {
874
+ expect(r).toBeGreaterThanOrEqual(3);
875
+ expect(r).toBeLessThanOrEqual(20);
876
+ }
877
+ });
878
+
879
+ it('builds independent categorical color scale', () => {
880
+ const layout = compileMap(
881
+ {
882
+ type: 'map',
883
+ geo: { features: MINI_TOPO, projection: 'mercator' },
884
+ data: [],
885
+ encoding: { key: { field: 'id', type: 'nominal' } },
886
+ points: {
887
+ data: [
888
+ { lat: 34, lon: -118, name: 'A', cat: 'x' },
889
+ { lat: 40.7, lon: -74, name: 'B', cat: 'y' },
890
+ { lat: 37, lon: -100, name: 'C', cat: 'z' },
891
+ ],
892
+ longitude: { field: 'lon', type: 'quantitative' },
893
+ latitude: { field: 'lat', type: 'quantitative' },
894
+ color: { field: 'cat', type: 'nominal' },
895
+ key: { field: 'name', type: 'nominal' },
896
+ },
897
+ },
898
+ DEFAULT_OPTIONS,
899
+ );
900
+
901
+ expect(layout.pointMarks).toHaveLength(3);
902
+ const fills = layout.pointMarks.map((pm) => pm.fill);
903
+ // 3 different categories should produce 3 different colors
904
+ expect(new Set(fills).size).toBe(3);
905
+ });
906
+
907
+ it('basemap-only: neutral fill when no encoding.color', () => {
908
+ const layout = compileMap(
909
+ {
910
+ type: 'map',
911
+ geo: { features: MINI_TOPO, projection: 'mercator' },
912
+ data: [],
913
+ encoding: { key: { field: 'id', type: 'nominal' } },
914
+ points: {
915
+ data: [{ lat: 34, lon: -118, name: 'LA' }],
916
+ longitude: { field: 'lon', type: 'quantitative' },
917
+ latitude: { field: 'lat', type: 'quantitative' },
918
+ key: { field: 'name', type: 'nominal' },
919
+ },
920
+ },
921
+ DEFAULT_OPTIONS,
922
+ );
923
+
924
+ // Features should all have the neutral fill (#e8e8e8 in light mode)
925
+ for (const f of layout.features) {
926
+ expect(f.fill).toBe('#e8e8e8');
927
+ }
928
+ });
929
+
930
+ it('generates point tooltips with point: prefix', () => {
931
+ const layout = compileMap(
932
+ {
933
+ type: 'map',
934
+ geo: { features: MINI_TOPO, projection: 'mercator' },
935
+ data: [],
936
+ encoding: { key: { field: 'id', type: 'nominal' } },
937
+ points: {
938
+ data: [
939
+ { lat: 34, lon: -118, name: 'LA', value: 100 },
940
+ { lat: 40.7, lon: -74, name: 'NYC', value: 200 },
941
+ ],
942
+ longitude: { field: 'lon', type: 'quantitative' },
943
+ latitude: { field: 'lat', type: 'quantitative' },
944
+ tooltip: [
945
+ { field: 'name', type: 'nominal', title: 'City' },
946
+ { field: 'value', type: 'quantitative', title: 'Pop' },
947
+ ],
948
+ key: { field: 'name', type: 'nominal' },
949
+ },
950
+ },
951
+ DEFAULT_OPTIONS,
952
+ );
953
+
954
+ const pointKeys = [...layout.tooltipDescriptors.keys()].filter((k) => k.startsWith('point:'));
955
+ expect(pointKeys).toHaveLength(2);
956
+ expect(pointKeys).toContain('point:LA');
957
+ expect(pointKeys).toContain('point:NYC');
958
+
959
+ const laTooltip = layout.tooltipDescriptors.get('point:LA');
960
+ expect(laTooltip).toBeDefined();
961
+ expect(laTooltip!.fields).toHaveLength(2);
962
+ expect(laTooltip!.fields[0].label).toBe('City');
963
+ });
964
+
965
+ it('handles points alongside choropleth', () => {
966
+ const layout = compileMap(
967
+ {
968
+ type: 'map',
969
+ geo: { features: MINI_TOPO, projection: 'mercator' },
970
+ data: [
971
+ { fips: '06', value: 10 },
972
+ { fips: '48', value: 20 },
973
+ ],
974
+ encoding: {
975
+ key: { field: 'fips', type: 'nominal' },
976
+ color: { field: 'value', type: 'quantitative' },
977
+ },
978
+ points: {
979
+ data: [
980
+ { lat: 34, lon: -118, name: 'LA', pop: 400 },
981
+ { lat: 31, lon: -100, name: 'TX City', pop: 200 },
982
+ ],
983
+ longitude: { field: 'lon', type: 'quantitative' },
984
+ latitude: { field: 'lat', type: 'quantitative' },
985
+ key: { field: 'name', type: 'nominal' },
986
+ },
987
+ },
988
+ DEFAULT_OPTIONS,
989
+ );
990
+
991
+ // Choropleth features should have color-encoded fills
992
+ const matchedFeature = layout.features.find((f) => f.id === '06');
993
+ expect(matchedFeature).toBeDefined();
994
+ expect(matchedFeature!.fill).not.toBe('#e8e8e8');
995
+
996
+ // Point marks should also be present
997
+ expect(layout.pointMarks).toHaveLength(2);
998
+ for (const pm of layout.pointMarks) {
999
+ expect(Number.isFinite(pm.cx)).toBe(true);
1000
+ expect(Number.isFinite(pm.cy)).toBe(true);
1001
+ }
1002
+ });
1003
+
1004
+ it('encoding.color legend: null suppresses the choropleth legend reserve (basemap-only)', () => {
1005
+ const base = {
1006
+ type: 'map',
1007
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1008
+ data: [] as Array<Record<string, unknown>>,
1009
+ points: {
1010
+ data: [{ lat: 34, lon: -118, name: 'LA', v: 10 }],
1011
+ longitude: { field: 'lon', type: 'quantitative' },
1012
+ latitude: { field: 'lat', type: 'quantitative' },
1013
+ color: { field: 'v', type: 'quantitative' },
1014
+ key: { field: 'name', type: 'nominal' },
1015
+ },
1016
+ theme: { spacing: { padding: 0 } },
1017
+ };
1018
+ const withLegendNull = compileMap(
1019
+ {
1020
+ ...base,
1021
+ encoding: {
1022
+ key: { field: 'id', type: 'nominal' },
1023
+ color: { field: 'id', type: 'nominal', legend: null },
1024
+ },
1025
+ },
1026
+ DEFAULT_OPTIONS,
1027
+ );
1028
+ const withoutLegendNull = compileMap(
1029
+ {
1030
+ ...base,
1031
+ encoding: {
1032
+ key: { field: 'id', type: 'nominal' },
1033
+ color: { field: 'id', type: 'nominal' },
1034
+ },
1035
+ },
1036
+ DEFAULT_OPTIONS,
1037
+ );
1038
+
1039
+ // legend: null frees the phantom choropleth swatch row, so the map
1040
+ // area is taller than the spec that reserves it.
1041
+ expect(withLegendNull.categoricalLegend).toBeNull();
1042
+ expect(withLegendNull.area.height).toBeGreaterThan(withoutLegendNull.area.height);
1043
+ });
1044
+
1045
+ it("legend position 'top-left' overlays the point legend inside the map area", () => {
1046
+ const spec = {
1047
+ type: 'map',
1048
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1049
+ data: [] as Array<Record<string, unknown>>,
1050
+ encoding: {
1051
+ key: { field: 'id', type: 'nominal' },
1052
+ color: { field: 'id', type: 'nominal', legend: null },
1053
+ },
1054
+ points: {
1055
+ data: [
1056
+ { lat: 34, lon: -118, name: 'LA', v: 10 },
1057
+ { lat: 40.7, lon: -74, name: 'NYC', v: 90 },
1058
+ ],
1059
+ longitude: { field: 'lon', type: 'quantitative' },
1060
+ latitude: { field: 'lat', type: 'quantitative' },
1061
+ color: { field: 'v', type: 'quantitative' },
1062
+ key: { field: 'name', type: 'nominal' },
1063
+ },
1064
+ theme: { spacing: { padding: 0 } },
1065
+ watermark: false,
1066
+ };
1067
+ const overlay = compileMap(
1068
+ { ...spec, legend: { show: true, position: 'top-left' } },
1069
+ DEFAULT_OPTIONS,
1070
+ );
1071
+ const bottom = compileMap({ ...spec, legend: { show: true } }, DEFAULT_OPTIONS);
1072
+
1073
+ // Overlay reserves no figure height: map area fills the full frame.
1074
+ expect(overlay.area.height).toBeGreaterThan(bottom.area.height);
1075
+ expect(overlay.area.height).toBe(DEFAULT_OPTIONS.height);
1076
+
1077
+ // Legend floats inside the map area's top-left corner, inset from the
1078
+ // edges, and is marked as an overlay for the renderer's backdrop.
1079
+ const legend = overlay.pointContinuousLegend!;
1080
+ expect(legend.position).toBe('top-left');
1081
+ expect(legend.bounds.x).toBeGreaterThan(overlay.area.x);
1082
+ expect(legend.bounds.y).toBeGreaterThan(overlay.area.y);
1083
+ expect(legend.bounds.y + legend.bounds.height).toBeLessThan(
1084
+ overlay.area.y + overlay.area.height,
1085
+ );
1086
+
1087
+ // Default (no position) keeps the bottom-row placement.
1088
+ expect(bottom.pointContinuousLegend!.position).toBe('bottom');
1089
+ expect(bottom.pointContinuousLegend!.bounds.y).toBeGreaterThan(
1090
+ overlay.pointContinuousLegend!.bounds.y,
1091
+ );
1092
+ });
1093
+
1094
+ it("legend position 'top-left' overlays a categorical point legend too", () => {
1095
+ const layout = compileMap(
1096
+ {
1097
+ type: 'map',
1098
+ geo: { features: MINI_TOPO, projection: 'mercator' },
1099
+ data: [],
1100
+ encoding: {
1101
+ key: { field: 'id', type: 'nominal' },
1102
+ color: { field: 'id', type: 'nominal', legend: null },
1103
+ },
1104
+ points: {
1105
+ data: [
1106
+ { lat: 34, lon: -118, name: 'LA', cat: 'x' },
1107
+ { lat: 40.7, lon: -74, name: 'NYC', cat: 'y' },
1108
+ ],
1109
+ longitude: { field: 'lon', type: 'quantitative' },
1110
+ latitude: { field: 'lat', type: 'quantitative' },
1111
+ color: { field: 'cat', type: 'nominal' },
1112
+ key: { field: 'name', type: 'nominal' },
1113
+ },
1114
+ legend: { show: true, position: 'top-left' },
1115
+ theme: { spacing: { padding: 0 } },
1116
+ watermark: false,
1117
+ },
1118
+ DEFAULT_OPTIONS,
1119
+ );
1120
+
1121
+ expect(layout.area.height).toBe(DEFAULT_OPTIONS.height);
1122
+ const legend = layout.pointCategoricalLegend!;
1123
+ expect(legend.position).toBe('top-left');
1124
+ expect(legend.bounds.x).toBeGreaterThan(layout.area.x);
1125
+ expect(legend.bounds.y).toBeGreaterThan(layout.area.y);
1126
+ });
1127
+ });
1128
+ });