@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1

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 (159) hide show
  1. package/dist/index.d.ts +142 -7
  2. package/dist/index.js +16182 -10032
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -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__/dimensions.test.ts +49 -4
  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 +120 -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 +222 -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 +312 -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 +117 -21
  91. package/src/compile/size-scale.ts +139 -0
  92. package/src/compile/spec-sugar.ts +758 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +315 -103
  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__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/normalize.ts +150 -7
  122. package/src/compiler/types.ts +11 -0
  123. package/src/compiler/validate.ts +664 -18
  124. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  125. package/src/endpoint-labels/compute.ts +40 -12
  126. package/src/endpoint-labels/format.ts +45 -15
  127. package/src/endpoint-labels/predict.ts +50 -36
  128. package/src/format/__tests__/field-format.test.ts +164 -0
  129. package/src/format/field-format.ts +27 -0
  130. package/src/geo/__tests__/compile-map.test.ts +621 -0
  131. package/src/geo/compile-map.ts +856 -0
  132. package/src/geo/join.ts +115 -0
  133. package/src/geo/projections.ts +45 -0
  134. package/src/geo/types.ts +24 -0
  135. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  136. package/src/graphs/compile-graph.ts +27 -18
  137. package/src/index.ts +28 -2
  138. package/src/layout/axes/ticks.ts +59 -10
  139. package/src/layout/axes.ts +30 -2
  140. package/src/layout/dimensions.ts +178 -95
  141. package/src/layout/facet.ts +14 -3
  142. package/src/layout/plan.ts +86 -35
  143. package/src/layout/scales.ts +132 -15
  144. package/src/legend/__tests__/continuous.test.ts +318 -0
  145. package/src/legend/__tests__/size.test.ts +348 -0
  146. package/src/legend/__tests__/suppression.test.ts +17 -1
  147. package/src/legend/compute.ts +126 -14
  148. package/src/legend/continuous.ts +327 -0
  149. package/src/legend/size.ts +407 -0
  150. package/src/legend/suppression.ts +8 -7
  151. package/src/legend/wrap.ts +8 -0
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,621 @@
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('assigns neutral fill to features without data', () => {
300
+ const layout = compileMap(
301
+ {
302
+ type: 'map',
303
+ geo: { features: MINI_TOPO, projection: 'mercator' },
304
+ data: [{ fips: '06', value: 10 }],
305
+ encoding: {
306
+ key: { field: 'fips', type: 'nominal' },
307
+ color: { field: 'value', type: 'quantitative' },
308
+ },
309
+ },
310
+ DEFAULT_OPTIONS,
311
+ );
312
+
313
+ const matched = layout.features.find((f) => f.id === '06');
314
+ const unmatched = layout.features.find((f) => f.id === '48');
315
+ expect(matched).toBeDefined();
316
+ expect(unmatched).toBeDefined();
317
+ expect(unmatched!.fill).not.toBe(matched!.fill);
318
+ });
319
+
320
+ it('emits warnings via onWarn callback', () => {
321
+ const warnings: string[] = [];
322
+ compileMap(
323
+ {
324
+ type: 'map',
325
+ geo: { features: MINI_TOPO, projection: 'mercator' },
326
+ data: [{ fips: '99', value: 100 }],
327
+ encoding: {
328
+ key: { field: 'fips', type: 'nominal' },
329
+ color: { field: 'value', type: 'quantitative' },
330
+ },
331
+ },
332
+ { ...DEFAULT_OPTIONS, onWarn: (msg) => warnings.push(msg) },
333
+ );
334
+
335
+ expect(warnings.length).toBeGreaterThan(0);
336
+ expect(warnings.some((w) => w.includes('99'))).toBe(true);
337
+ });
338
+
339
+ it('identity projection paths fit inside layout.area', () => {
340
+ // Pre-projected coordinates (pixel-space) that identity should scale to fit
341
+ const PRE_PROJECTED_TOPO = {
342
+ type: 'Topology',
343
+ objects: {
344
+ regions: {
345
+ type: 'GeometryCollection',
346
+ geometries: [
347
+ { type: 'Polygon', id: 'A', properties: { name: 'Region A' }, arcs: [[0]] },
348
+ { type: 'Polygon', id: 'B', properties: { name: 'Region B' }, arcs: [[1]] },
349
+ ],
350
+ },
351
+ },
352
+ arcs: [
353
+ // Region A: (0,0) -> (100,0) -> (100,50) -> (0,50) -> (0,0)
354
+ [
355
+ [0, 0],
356
+ [100, 0],
357
+ [0, 50],
358
+ [-100, 0],
359
+ [0, -50],
360
+ ],
361
+ // Region B: (100,0) -> (200,0) -> (200,50) -> (100,50) -> (100,0)
362
+ [
363
+ [100, 0],
364
+ [100, 0],
365
+ [0, 50],
366
+ [-100, 0],
367
+ [0, -50],
368
+ ],
369
+ ],
370
+ };
371
+
372
+ const layout = compileMap(
373
+ {
374
+ type: 'map',
375
+ geo: { features: PRE_PROJECTED_TOPO, projection: 'identity' },
376
+ data: [
377
+ { id: 'A', value: 10 },
378
+ { id: 'B', value: 20 },
379
+ ],
380
+ encoding: {
381
+ key: { field: 'id', type: 'nominal' },
382
+ color: { field: 'value', type: 'quantitative' },
383
+ },
384
+ },
385
+ DEFAULT_OPTIONS,
386
+ );
387
+
388
+ expect(layout.features.length).toBe(2);
389
+ // Verify paths exist and geometry actually scaled (not just passthrough)
390
+ for (const f of layout.features) {
391
+ expect(f.path).toMatch(/^M/);
392
+ // Extract coordinates from the path and verify they fit within layout area
393
+ const coords = f.path.match(/[-\d.]+/g)?.map(Number) ?? [];
394
+ expect(coords.length).toBeGreaterThan(0);
395
+ }
396
+ });
397
+
398
+ it('identity projection emits no INVERTED_WINDING warning', () => {
399
+ const warnings: string[] = [];
400
+ const layout = compileMap(
401
+ {
402
+ type: 'map',
403
+ geo: { features: MINI_TOPO, projection: 'identity' },
404
+ data: [{ fips: '06', value: 10 }],
405
+ encoding: {
406
+ key: { field: 'fips', type: 'nominal' },
407
+ color: { field: 'value', type: 'quantitative' },
408
+ },
409
+ },
410
+ { ...DEFAULT_OPTIONS, onWarn: (msg) => warnings.push(msg) },
411
+ );
412
+
413
+ const windingWarning = layout.warnings.find((w) => w.code === 'INVERTED_WINDING');
414
+ expect(windingWarning).toBeUndefined();
415
+ });
416
+
417
+ it('every feature has finite bounds and centroid within mapSize', () => {
418
+ const layout = compileMap(
419
+ {
420
+ type: 'map',
421
+ geo: { features: MINI_TOPO, projection: 'mercator' },
422
+ data: [
423
+ { fips: '06', value: 10 },
424
+ { fips: '48', value: 20 },
425
+ { fips: '36', value: 30 },
426
+ ],
427
+ encoding: {
428
+ key: { field: 'fips', type: 'nominal' },
429
+ color: { field: 'value', type: 'quantitative' },
430
+ },
431
+ },
432
+ DEFAULT_OPTIONS,
433
+ );
434
+
435
+ const { mapSize } = layout;
436
+ for (const f of layout.features) {
437
+ expect(Number.isFinite(f.bounds.x)).toBe(true);
438
+ expect(Number.isFinite(f.bounds.y)).toBe(true);
439
+ expect(Number.isFinite(f.bounds.width)).toBe(true);
440
+ expect(Number.isFinite(f.bounds.height)).toBe(true);
441
+ expect(f.bounds.width).toBeGreaterThan(0);
442
+ expect(f.bounds.height).toBeGreaterThan(0);
443
+
444
+ // Centroid should be finite and within the mapSize
445
+ expect(Number.isFinite(f.centroid[0])).toBe(true);
446
+ expect(Number.isFinite(f.centroid[1])).toBe(true);
447
+ expect(f.centroid[0]).toBeGreaterThanOrEqual(0);
448
+ expect(f.centroid[0]).toBeLessThanOrEqual(mapSize.width);
449
+ expect(f.centroid[1]).toBeGreaterThanOrEqual(0);
450
+ expect(f.centroid[1]).toBeLessThanOrEqual(mapSize.height);
451
+ }
452
+ });
453
+
454
+ it('mapSize has positive width and height', () => {
455
+ const layout = compileMap(
456
+ {
457
+ type: 'map',
458
+ geo: { features: MINI_TOPO, projection: 'mercator' },
459
+ data: [
460
+ { fips: '06', value: 10 },
461
+ { fips: '48', value: 20 },
462
+ ],
463
+ encoding: {
464
+ key: { field: 'fips', type: 'nominal' },
465
+ color: { field: 'value', type: 'quantitative' },
466
+ },
467
+ },
468
+ DEFAULT_OPTIONS,
469
+ );
470
+
471
+ expect(layout.mapSize.width).toBeGreaterThan(0);
472
+ expect(layout.mapSize.height).toBeGreaterThan(0);
473
+ });
474
+
475
+ it('resolves chrome (title, subtitle)', () => {
476
+ const layout = compileMap(
477
+ {
478
+ type: 'map',
479
+ geo: { features: MINI_TOPO, projection: 'mercator' },
480
+ data: [{ fips: '06', value: 10 }],
481
+ encoding: {
482
+ key: { field: 'fips', type: 'nominal' },
483
+ color: { field: 'value', type: 'quantitative' },
484
+ },
485
+ chrome: { title: 'Test Map', subtitle: 'Test subtitle' },
486
+ },
487
+ DEFAULT_OPTIONS,
488
+ );
489
+
490
+ expect(layout.chrome.title).toBeDefined();
491
+ expect(layout.chrome.subtitle).toBeDefined();
492
+ });
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Focus resolution
496
+ // ---------------------------------------------------------------------------
497
+
498
+ it('geo.focus as single string produces layout.focus with matching bounds', () => {
499
+ const layout = compileMap(
500
+ {
501
+ type: 'map',
502
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: '36' },
503
+ data: [
504
+ { fips: '06', value: 10 },
505
+ { fips: '48', value: 20 },
506
+ { fips: '36', value: 30 },
507
+ ],
508
+ encoding: {
509
+ key: { field: 'fips', type: 'nominal' },
510
+ color: { field: 'value', type: 'quantitative' },
511
+ },
512
+ },
513
+ DEFAULT_OPTIONS,
514
+ );
515
+
516
+ expect(layout.focus).not.toBeNull();
517
+ expect(layout.focus!.ids).toEqual(['36']);
518
+ expect(layout.focus!.target.width).toBeGreaterThan(0);
519
+ expect(layout.focus!.target.height).toBeGreaterThan(0);
520
+ expect(layout.focus!.target.padding).toBe(16);
521
+ });
522
+
523
+ it('geo.focus as array produces union bounds', () => {
524
+ const layout = compileMap(
525
+ {
526
+ type: 'map',
527
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: ['36', '06'] },
528
+ data: [
529
+ { fips: '06', value: 10 },
530
+ { fips: '36', value: 30 },
531
+ ],
532
+ encoding: {
533
+ key: { field: 'fips', type: 'nominal' },
534
+ color: { field: 'value', type: 'quantitative' },
535
+ },
536
+ },
537
+ DEFAULT_OPTIONS,
538
+ );
539
+
540
+ expect(layout.focus).not.toBeNull();
541
+ expect(layout.focus!.ids).toHaveLength(2);
542
+ expect(layout.focus!.ids.map(String).sort()).toEqual(['06', '36']);
543
+ });
544
+
545
+ it('geo.focus object form with custom padding', () => {
546
+ const layout = compileMap(
547
+ {
548
+ type: 'map',
549
+ geo: {
550
+ features: MINI_TOPO,
551
+ projection: 'mercator',
552
+ focus: { features: '36', padding: 32 },
553
+ },
554
+ data: [{ fips: '36', value: 30 }],
555
+ encoding: {
556
+ key: { field: 'fips', type: 'nominal' },
557
+ color: { field: 'value', type: 'quantitative' },
558
+ },
559
+ },
560
+ DEFAULT_OPTIONS,
561
+ );
562
+
563
+ expect(layout.focus).not.toBeNull();
564
+ expect(layout.focus!.target.padding).toBe(32);
565
+ expect(layout.focus!.ids).toEqual(['36']);
566
+ });
567
+
568
+ it('geo.focus: null produces layout.focus: null', () => {
569
+ const layout = compileMap(
570
+ {
571
+ type: 'map',
572
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: null },
573
+ data: [{ fips: '06', value: 10 }],
574
+ encoding: {
575
+ key: { field: 'fips', type: 'nominal' },
576
+ color: { field: 'value', type: 'quantitative' },
577
+ },
578
+ },
579
+ DEFAULT_OPTIONS,
580
+ );
581
+
582
+ expect(layout.focus).toBeNull();
583
+ });
584
+
585
+ it('unknown focus id emits FOCUS_UNMATCHED warning', () => {
586
+ const layout = compileMap(
587
+ {
588
+ type: 'map',
589
+ geo: { features: MINI_TOPO, projection: 'mercator', focus: '99' },
590
+ data: [{ fips: '06', value: 10 }],
591
+ encoding: {
592
+ key: { field: 'fips', type: 'nominal' },
593
+ color: { field: 'value', type: 'quantitative' },
594
+ },
595
+ },
596
+ DEFAULT_OPTIONS,
597
+ );
598
+
599
+ const warning = layout.warnings.find((w) => w.code === 'FOCUS_UNMATCHED');
600
+ expect(warning).toBeDefined();
601
+ expect(warning!.message).toContain('99');
602
+ expect(layout.focus).toBeNull();
603
+ });
604
+
605
+ it('no focus specified produces layout.focus: null', () => {
606
+ const layout = compileMap(
607
+ {
608
+ type: 'map',
609
+ geo: { features: MINI_TOPO, projection: 'mercator' },
610
+ data: [{ fips: '06', value: 10 }],
611
+ encoding: {
612
+ key: { field: 'fips', type: 'nominal' },
613
+ color: { field: 'value', type: 'quantitative' },
614
+ },
615
+ },
616
+ DEFAULT_OPTIONS,
617
+ );
618
+
619
+ expect(layout.focus).toBeNull();
620
+ });
621
+ });