@opendata-ai/openchart-engine 8.0.0-rc.1 → 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.
- package/dist/index.d.ts +2 -8
- package/dist/index.js +793 -91
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +62 -0
- package/src/__tests__/annotation-behavior.test.ts +131 -0
- package/src/__tests__/facet.test.ts +144 -0
- package/src/annotations/geometry.ts +7 -1
- package/src/annotations/resolve-text.ts +6 -0
- package/src/compile/layer.ts +4 -0
- package/src/compile/size-scale.ts +2 -0
- package/src/compile/spec-sugar.ts +10 -1
- package/src/compile.ts +106 -8
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +84 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +162 -0
- package/src/compiler/__tests__/validate.test.ts +102 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/validate.ts +326 -43
- package/src/geo/__tests__/compile-map.test.ts +507 -0
- package/src/geo/compile-map.ts +555 -35
- package/src/geo/projections.ts +21 -1
- package/src/geo/types.ts +2 -0
- package/src/layout/axes.ts +5 -4
- package/src/legend/compute.ts +4 -3
package/src/geo/compile-map.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
MapFocus,
|
|
11
11
|
MapFocusLayout,
|
|
12
12
|
MapLayout,
|
|
13
|
+
MapPointMark,
|
|
13
14
|
ResolvedAnimation,
|
|
14
15
|
ResolvedTheme,
|
|
15
16
|
TextStyle,
|
|
@@ -33,10 +34,12 @@ import { geoArea, geoPath } from 'd3-geo';
|
|
|
33
34
|
import { scaleQuantile } from 'd3-scale';
|
|
34
35
|
import { feature as topoFeature, mesh as topoMesh } from 'topojson-client';
|
|
35
36
|
import type { GeometryCollection, Topology } from 'topojson-specification';
|
|
37
|
+
import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../compile/size-scale';
|
|
36
38
|
import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar';
|
|
37
39
|
import { resolveAnimation } from '../compiler/animation';
|
|
38
40
|
import { compile as compileSpec } from '../compiler/index';
|
|
39
41
|
import {
|
|
42
|
+
CONTINUOUS_BAR_HEIGHT,
|
|
40
43
|
CONTINUOUS_LABEL_GAP,
|
|
41
44
|
computeContinuousLegendContentForChannel,
|
|
42
45
|
} from '../legend/continuous';
|
|
@@ -44,6 +47,13 @@ import { joinDataToFeatures } from './join';
|
|
|
44
47
|
import { createProjection } from './projections';
|
|
45
48
|
import type { NormalizedMapSpec } from './types';
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Inset from the map area's edges for overlay ('top-left') point legends.
|
|
52
|
+
* The renderer's backdrop extends 10px left of the legend x, so the visible
|
|
53
|
+
* gap to the frame edge is OVERLAY_LEGEND_INSET - 10.
|
|
54
|
+
*/
|
|
55
|
+
const OVERLAY_LEGEND_INSET = 18;
|
|
56
|
+
|
|
47
57
|
function validateGeoFeatures(geo: NormalizedMapSpec['geo']): Topology {
|
|
48
58
|
if (!geo.features) {
|
|
49
59
|
throw new Error(
|
|
@@ -162,14 +172,21 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
162
172
|
// 8. Legend height reserve
|
|
163
173
|
const showLegend = mapSpec.legend?.show !== false;
|
|
164
174
|
const colorEncoding = mapSpec.encoding.color;
|
|
165
|
-
|
|
175
|
+
// Channel-level `legend: null` suppresses the choropleth legend (the VL
|
|
176
|
+
// idiom) — without this, basemap-only maps (empty data + points layer)
|
|
177
|
+
// reserved a phantom swatch row that letterboxed the map.
|
|
178
|
+
const showChoroplethLegend = showLegend && colorEncoding?.legend !== null;
|
|
179
|
+
const isQuantitative = colorEncoding?.type === 'quantitative';
|
|
166
180
|
const legendPosition = mapSpec.legend?.position === 'bottom' ? 'bottom' : 'top';
|
|
181
|
+
// 'top-left' floats the point legend inside the map area (own backdrop, no
|
|
182
|
+
// height reserve) so the geography keeps the full frame.
|
|
183
|
+
const pointLegendOverlay = mapSpec.legend?.position === 'top-left';
|
|
167
184
|
|
|
168
185
|
// Compute legend block height. For quantitative maps, use the continuous
|
|
169
186
|
// legend infrastructure; for categorical, estimate a single swatch row.
|
|
170
187
|
let legendBlockHeight = 0;
|
|
171
188
|
let continuousContent: ReturnType<typeof computeContinuousLegendContentForChannel> = null;
|
|
172
|
-
if (
|
|
189
|
+
if (showChoroplethLegend && colorEncoding && isQuantitative) {
|
|
173
190
|
const colorValues: number[] = [];
|
|
174
191
|
for (const row of mapSpec.data) {
|
|
175
192
|
const v = row[colorEncoding.field];
|
|
@@ -181,8 +198,8 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
181
198
|
// Maps always use a quantile color scale, so tell the legend infra
|
|
182
199
|
// to produce binned swatches rather than a gradient bar.
|
|
183
200
|
const legendChannel: EncodingChannel = {
|
|
184
|
-
...
|
|
185
|
-
scale: { ...
|
|
201
|
+
...colorEncoding,
|
|
202
|
+
scale: { ...colorEncoding.scale, type: 'quantile' },
|
|
186
203
|
};
|
|
187
204
|
continuousContent = computeContinuousLegendContentForChannel(
|
|
188
205
|
colorValues,
|
|
@@ -194,10 +211,23 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
194
211
|
const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
195
212
|
legendBlockHeight = continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
|
|
196
213
|
}
|
|
197
|
-
} else if (
|
|
214
|
+
} else if (showChoroplethLegend && colorEncoding) {
|
|
198
215
|
const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
199
216
|
legendBlockHeight = Math.max(10, labelHeight) + 6;
|
|
200
217
|
}
|
|
218
|
+
|
|
219
|
+
// Reserve height for point color legend if applicable (overlay legends
|
|
220
|
+
// float inside the map area and reserve nothing)
|
|
221
|
+
if (showLegend && !pointLegendOverlay && mapSpec.points?.color) {
|
|
222
|
+
if (mapSpec.points.color.type === 'quantitative') {
|
|
223
|
+
const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
224
|
+
legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
|
|
225
|
+
} else {
|
|
226
|
+
const ptLabelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
227
|
+
legendBlockHeight += Math.max(10, ptLabelHeight) + 6 + 8; // swatch row + gap
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
201
231
|
const legendReserveGap = legendBlockHeight > 0 ? 8 : 0;
|
|
202
232
|
|
|
203
233
|
const mapAreaHeight = fullArea.height - legendBlockHeight - legendReserveGap;
|
|
@@ -206,8 +236,21 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
206
236
|
}
|
|
207
237
|
|
|
208
238
|
// 9. Projection + path generator
|
|
239
|
+
// When points are present, inset the projection by the max point radius so
|
|
240
|
+
// circles at the geographic edges don't get clipped by the viewBox.
|
|
209
241
|
const projectionType = mapSpec.geo.projection;
|
|
210
|
-
|
|
242
|
+
let pointInset = 0;
|
|
243
|
+
if (mapSpec.points) {
|
|
244
|
+
const sizeRange = mapSpec.points.size?.scale?.range as readonly [number, number] | undefined;
|
|
245
|
+
pointInset = sizeRange ? sizeRange[1] : SIZE_SCALE_DEFAULTS.mapPoint.range[1];
|
|
246
|
+
}
|
|
247
|
+
const projection = createProjection(
|
|
248
|
+
projectionType,
|
|
249
|
+
fullArea.width,
|
|
250
|
+
mapAreaHeight,
|
|
251
|
+
geoCollection,
|
|
252
|
+
pointInset,
|
|
253
|
+
);
|
|
211
254
|
const pathGen = geoPath(projection);
|
|
212
255
|
|
|
213
256
|
// 10. Check winding order (skip for identity: geoArea uses spherical math
|
|
@@ -232,18 +275,27 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
232
275
|
}
|
|
233
276
|
}
|
|
234
277
|
|
|
235
|
-
// 11. Join data to features
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
278
|
+
// 11. Join data to features (skip when no key channel, e.g. basemap-only mode)
|
|
279
|
+
let joined = new Map<string | number, Record<string, unknown>>();
|
|
280
|
+
if (mapSpec.encoding.key) {
|
|
281
|
+
const featureObjs = geoFeatures.map((f) => ({
|
|
282
|
+
id: f.id as string | number,
|
|
283
|
+
properties: (f.properties ?? {}) as Record<string, unknown>,
|
|
284
|
+
}));
|
|
285
|
+
const joinResult = joinDataToFeatures(
|
|
286
|
+
featureObjs,
|
|
287
|
+
mapSpec.data,
|
|
288
|
+
mapSpec.encoding.key.field,
|
|
289
|
+
mapSpec.geo.idField,
|
|
290
|
+
);
|
|
291
|
+
joined = joinResult.joined;
|
|
292
|
+
// In basemap-only mode (empty data + points layer), all features are intentionally unmatched
|
|
293
|
+
const filteredJoinWarnings =
|
|
294
|
+
mapSpec.data.length === 0 && mapSpec.points
|
|
295
|
+
? joinResult.warnings.filter((w) => w.code !== 'UNMATCHED_FEATURES')
|
|
296
|
+
: joinResult.warnings;
|
|
297
|
+
compileWarnings.push(...filteredJoinWarnings);
|
|
298
|
+
}
|
|
247
299
|
|
|
248
300
|
// 12. Check for null-projecting features (albersUsa drops territories)
|
|
249
301
|
const droppedFeatures: string[] = [];
|
|
@@ -257,13 +309,22 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
257
309
|
let continuousLegend: ContinuousLegendLayout | null = null;
|
|
258
310
|
let categoricalLegend: CategoricalLegendLayout | null = null;
|
|
259
311
|
|
|
260
|
-
if (
|
|
312
|
+
if (!colorEncoding) {
|
|
313
|
+
// Basemap-only mode: all features get neutral fill, no legend
|
|
314
|
+
featureMarks = buildBasemapMarks({
|
|
315
|
+
geoFeatures,
|
|
316
|
+
pathGen,
|
|
317
|
+
neutralFill,
|
|
318
|
+
neutralStroke,
|
|
319
|
+
droppedFeatures,
|
|
320
|
+
});
|
|
321
|
+
} else if (isQuantitative) {
|
|
261
322
|
const result = buildQuantitativeMarks({
|
|
262
323
|
geoFeatures,
|
|
263
324
|
pathGen,
|
|
264
325
|
joined,
|
|
265
326
|
colorField: colorEncoding.field,
|
|
266
|
-
palette:
|
|
327
|
+
palette: colorEncoding.scale?.scheme ?? 'blue',
|
|
267
328
|
isDarkMode: !!isDarkMode,
|
|
268
329
|
neutralFill,
|
|
269
330
|
neutralStroke,
|
|
@@ -274,7 +335,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
274
335
|
|
|
275
336
|
// Build the continuous legend. Maps place it directly (not via placeLegend,
|
|
276
337
|
// which does cartesian-specific positioning above the chart area).
|
|
277
|
-
if (
|
|
338
|
+
if (showChoroplethLegend && continuousContent) {
|
|
278
339
|
const labelStyle: TextStyle = {
|
|
279
340
|
fontFamily: theme.fonts.family,
|
|
280
341
|
fontSize: theme.fonts.sizes.small,
|
|
@@ -319,6 +380,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
319
380
|
pathGen,
|
|
320
381
|
joined,
|
|
321
382
|
colorField: colorEncoding.field,
|
|
383
|
+
colorScale: colorEncoding.scale,
|
|
322
384
|
isDarkMode: !!isDarkMode,
|
|
323
385
|
neutralFill,
|
|
324
386
|
neutralStroke,
|
|
@@ -326,7 +388,8 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
326
388
|
fullArea,
|
|
327
389
|
mapAreaHeight,
|
|
328
390
|
legendReserveGap,
|
|
329
|
-
|
|
391
|
+
legendPosition,
|
|
392
|
+
showLegend: showChoroplethLegend,
|
|
330
393
|
droppedFeatures,
|
|
331
394
|
});
|
|
332
395
|
featureMarks = result.marks;
|
|
@@ -364,6 +427,262 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
364
427
|
const outlineStroke = isDarkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.3)';
|
|
365
428
|
const borders: MapBorders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
|
|
366
429
|
|
|
430
|
+
// 15b. Point marks (symbol layer above choropleth)
|
|
431
|
+
const pointMarks: MapPointMark[] = [];
|
|
432
|
+
let pointCategoricalLegend: CategoricalLegendLayout | null = null;
|
|
433
|
+
let pointContinuousLegend: ContinuousLegendLayout | null = null;
|
|
434
|
+
|
|
435
|
+
if (mapSpec.points) {
|
|
436
|
+
const pts = mapSpec.points;
|
|
437
|
+
const lonField = pts.longitude.field;
|
|
438
|
+
const latField = pts.latitude.field;
|
|
439
|
+
const keyChannel = pts.key;
|
|
440
|
+
|
|
441
|
+
// Size scale
|
|
442
|
+
const sizeScale = buildSizeScale(pts.size, pts.data, SIZE_SCALE_DEFAULTS.mapPoint);
|
|
443
|
+
|
|
444
|
+
// Color scale (independent from choropleth)
|
|
445
|
+
let pointColorScale: ((val: unknown) => string) | null = null;
|
|
446
|
+
const pointCategories: string[] = [];
|
|
447
|
+
const pointCategoryColors = new Map<string, string>();
|
|
448
|
+
|
|
449
|
+
if (pts.color) {
|
|
450
|
+
if (pts.color.type === 'quantitative') {
|
|
451
|
+
const colorValues: number[] = [];
|
|
452
|
+
for (const row of pts.data) {
|
|
453
|
+
const raw = row[pts.color.field];
|
|
454
|
+
if (raw != null) {
|
|
455
|
+
const v = Number(raw);
|
|
456
|
+
if (Number.isFinite(v)) colorValues.push(v);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (colorValues.length > 0) {
|
|
460
|
+
const palette = pts.color.scale?.scheme ?? 'blue';
|
|
461
|
+
const paletteStops = [...(SEQUENTIAL_PALETTES[palette] ?? SEQUENTIAL_PALETTES.blue)];
|
|
462
|
+
const qScale = scaleQuantile<string>().domain(colorValues).range(paletteStops);
|
|
463
|
+
pointColorScale = (val: unknown) => {
|
|
464
|
+
const v = Number(val);
|
|
465
|
+
return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
// nominal / ordinal: honor scale.domain (category order) and scale.range (explicit colors)
|
|
470
|
+
const explicitDomain = pts.color.scale?.domain as string[] | undefined;
|
|
471
|
+
const explicitRange = pts.color.scale?.range as string[] | undefined;
|
|
472
|
+
|
|
473
|
+
if (explicitDomain && Array.isArray(explicitDomain)) {
|
|
474
|
+
for (const cat of explicitDomain) {
|
|
475
|
+
pointCategories.push(String(cat));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Also pick up any categories in the data that aren't in the explicit domain
|
|
479
|
+
const seen = new Set<string>(pointCategories);
|
|
480
|
+
for (const row of pts.data) {
|
|
481
|
+
const raw = row[pts.color.field];
|
|
482
|
+
if (raw != null) {
|
|
483
|
+
const cat = String(raw);
|
|
484
|
+
if (!seen.has(cat)) {
|
|
485
|
+
seen.add(cat);
|
|
486
|
+
pointCategories.push(cat);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const colorSource =
|
|
492
|
+
explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
|
|
493
|
+
for (let i = 0; i < pointCategories.length; i++) {
|
|
494
|
+
pointCategoryColors.set(pointCategories[i], colorSource[i % colorSource.length]);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
pointColorScale = (val: unknown) => {
|
|
498
|
+
if (val == null) return colorSource[0];
|
|
499
|
+
return pointCategoryColors.get(String(val)) ?? colorSource[0];
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const defaultFill = CATEGORICAL_PALETTE[0];
|
|
505
|
+
const pointOpacity = pts.opacity ?? 0.65;
|
|
506
|
+
let animIndex = 0;
|
|
507
|
+
const droppedPoints: Array<[number, number]> = [];
|
|
508
|
+
|
|
509
|
+
for (let i = 0; i < pts.data.length; i++) {
|
|
510
|
+
const row = pts.data[i];
|
|
511
|
+
const lon = Number(row[lonField]);
|
|
512
|
+
const lat = Number(row[latField]);
|
|
513
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
|
514
|
+
|
|
515
|
+
const projected = projection([lon, lat]);
|
|
516
|
+
if (!projected) {
|
|
517
|
+
droppedPoints.push([lon, lat]);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
|
|
522
|
+
const fill =
|
|
523
|
+
pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
|
|
524
|
+
const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
|
|
525
|
+
const stroke = isDarkMode ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.8)';
|
|
526
|
+
|
|
527
|
+
pointMarks.push({
|
|
528
|
+
type: 'map-point',
|
|
529
|
+
cx: projected[0],
|
|
530
|
+
cy: projected[1],
|
|
531
|
+
r,
|
|
532
|
+
fill,
|
|
533
|
+
stroke,
|
|
534
|
+
strokeWidth: 1,
|
|
535
|
+
fillOpacity: pointOpacity,
|
|
536
|
+
key,
|
|
537
|
+
data: row as Record<string, unknown>,
|
|
538
|
+
aria: { role: 'img', label: key },
|
|
539
|
+
animationIndex: animIndex++,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (droppedPoints.length > 0) {
|
|
544
|
+
const sample = droppedPoints
|
|
545
|
+
.slice(0, 3)
|
|
546
|
+
.map(([lo, la]) => `[${lo}, ${la}]`)
|
|
547
|
+
.join(', ');
|
|
548
|
+
compileWarnings.push({
|
|
549
|
+
code: 'POINT_NULL_PROJECTION',
|
|
550
|
+
message:
|
|
551
|
+
`${droppedPoints.length} point(s) project to null and were dropped (e.g. ${sample}). ` +
|
|
552
|
+
'This is common with albersUsa which excludes territories. ' +
|
|
553
|
+
'Consider using projection: "mercator" or "equalEarth".',
|
|
554
|
+
context: { count: droppedPoints.length, samples: droppedPoints.slice(0, 5) },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Point categorical legend
|
|
559
|
+
if (
|
|
560
|
+
pts.color &&
|
|
561
|
+
pts.color.type !== 'quantitative' &&
|
|
562
|
+
pointCategories.length > 0 &&
|
|
563
|
+
showLegend
|
|
564
|
+
) {
|
|
565
|
+
const labelStyle: TextStyle = {
|
|
566
|
+
fontFamily: theme.fonts.family,
|
|
567
|
+
fontSize: theme.fonts.sizes.small,
|
|
568
|
+
fontWeight: theme.fonts.weights.normal,
|
|
569
|
+
fill: theme.colors.text,
|
|
570
|
+
lineHeight: 1.2,
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const entries: LegendEntry[] = pointCategories.map((cat) => ({
|
|
574
|
+
label: cat,
|
|
575
|
+
color: pointCategoryColors.get(cat)!,
|
|
576
|
+
shape: 'circle' as const,
|
|
577
|
+
}));
|
|
578
|
+
|
|
579
|
+
// Default: position below the map, offset by choropleth legend height
|
|
580
|
+
// when that legend is also at the bottom (not 'top'). Overlay
|
|
581
|
+
// ('top-left'): float inside the map area's top-left corner — the
|
|
582
|
+
// renderer's backdrop keeps it readable over geography, and the inset
|
|
583
|
+
// clears the frame edge (plus the backdrop's own 10px x-padding).
|
|
584
|
+
const bottomChoroplethHeight =
|
|
585
|
+
legendPosition === 'bottom'
|
|
586
|
+
? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
|
|
587
|
+
: 0;
|
|
588
|
+
const choroplethLegendGap = bottomChoroplethHeight > 0 ? 8 : 0;
|
|
589
|
+
const legendX = pointLegendOverlay ? fullArea.x + OVERLAY_LEGEND_INSET : fullArea.x;
|
|
590
|
+
const legendY = pointLegendOverlay
|
|
591
|
+
? fullArea.y + OVERLAY_LEGEND_INSET
|
|
592
|
+
: fullArea.y +
|
|
593
|
+
mapAreaHeight +
|
|
594
|
+
legendReserveGap +
|
|
595
|
+
bottomChoroplethHeight +
|
|
596
|
+
choroplethLegendGap;
|
|
597
|
+
const legendWidth = pointLegendOverlay
|
|
598
|
+
? fullArea.width - OVERLAY_LEGEND_INSET * 2
|
|
599
|
+
: fullArea.width;
|
|
600
|
+
const swatchSize = 10;
|
|
601
|
+
const swatchGap = 6;
|
|
602
|
+
const entryGap = 16;
|
|
603
|
+
|
|
604
|
+
pointCategoricalLegend = {
|
|
605
|
+
type: 'categorical',
|
|
606
|
+
position: pointLegendOverlay ? 'top-left' : 'bottom',
|
|
607
|
+
bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
|
|
608
|
+
labelStyle,
|
|
609
|
+
entries,
|
|
610
|
+
swatchSize,
|
|
611
|
+
swatchGap,
|
|
612
|
+
entryGap,
|
|
613
|
+
swatchChipFill: isDarkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Point continuous legend (quantitative color)
|
|
618
|
+
if (pts.color && pts.color.type === 'quantitative' && showLegend) {
|
|
619
|
+
const ptColorValues: number[] = [];
|
|
620
|
+
for (const row of pts.data) {
|
|
621
|
+
const v = row[pts.color.field];
|
|
622
|
+
if (v != null) {
|
|
623
|
+
const n = Number(v);
|
|
624
|
+
if (Number.isFinite(n)) ptColorValues.push(n);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const ptLegendChannel: EncodingChannel = {
|
|
628
|
+
...pts.color,
|
|
629
|
+
scale: { ...pts.color.scale, type: 'quantile' },
|
|
630
|
+
};
|
|
631
|
+
const ptContinuousContent = computeContinuousLegendContentForChannel(
|
|
632
|
+
ptColorValues,
|
|
633
|
+
ptLegendChannel,
|
|
634
|
+
theme,
|
|
635
|
+
fullArea.width,
|
|
636
|
+
);
|
|
637
|
+
if (ptContinuousContent) {
|
|
638
|
+
const labelStyle: TextStyle = {
|
|
639
|
+
fontFamily: theme.fonts.family,
|
|
640
|
+
fontSize: theme.fonts.sizes.small,
|
|
641
|
+
fontWeight: theme.fonts.weights.normal,
|
|
642
|
+
fill: theme.colors.text,
|
|
643
|
+
lineHeight: 1.3,
|
|
644
|
+
fontVariant: 'tabular-nums',
|
|
645
|
+
};
|
|
646
|
+
const bottomChoroplethH =
|
|
647
|
+
legendPosition === 'bottom'
|
|
648
|
+
? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
|
|
649
|
+
: 0;
|
|
650
|
+
const choroplethLegendGap = bottomChoroplethH > 0 ? 8 : 0;
|
|
651
|
+
const legendX = pointLegendOverlay ? fullArea.x + OVERLAY_LEGEND_INSET : fullArea.x;
|
|
652
|
+
const legendY = pointLegendOverlay
|
|
653
|
+
? fullArea.y + OVERLAY_LEGEND_INSET
|
|
654
|
+
: fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
|
|
655
|
+
const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
656
|
+
const ptLegendHeight =
|
|
657
|
+
ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
|
|
658
|
+
const bounds = {
|
|
659
|
+
x: legendX,
|
|
660
|
+
y: legendY,
|
|
661
|
+
width: ptContinuousContent.barWidth,
|
|
662
|
+
height: ptLegendHeight,
|
|
663
|
+
};
|
|
664
|
+
const bar = {
|
|
665
|
+
x: bounds.x,
|
|
666
|
+
y: bounds.y,
|
|
667
|
+
width: ptContinuousContent.barWidth,
|
|
668
|
+
height: ptContinuousContent.barHeight,
|
|
669
|
+
};
|
|
670
|
+
pointContinuousLegend = {
|
|
671
|
+
type: 'continuous' as const,
|
|
672
|
+
mode: ptContinuousContent.mode,
|
|
673
|
+
position: pointLegendOverlay ? ('top-left' as const) : ('bottom' as const),
|
|
674
|
+
bounds,
|
|
675
|
+
labelStyle,
|
|
676
|
+
bar,
|
|
677
|
+
colorStops: ptContinuousContent.colorStops,
|
|
678
|
+
bins: ptContinuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
|
|
679
|
+
ticks: ptContinuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
|
|
680
|
+
labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
367
686
|
// 16. Tooltips
|
|
368
687
|
const tooltipChannels = mapSpec.encoding.tooltip
|
|
369
688
|
? Array.isArray(mapSpec.encoding.tooltip)
|
|
@@ -388,12 +707,12 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
388
707
|
});
|
|
389
708
|
}
|
|
390
709
|
}
|
|
391
|
-
} else if (isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
710
|
+
} else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
392
711
|
fields.push({
|
|
393
712
|
label: colorEncoding.title ?? colorEncoding.field,
|
|
394
713
|
value: formatter(Number(mark.data[colorEncoding.field])),
|
|
395
714
|
});
|
|
396
|
-
} else if (!isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
715
|
+
} else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
|
|
397
716
|
fields.push({
|
|
398
717
|
label: colorEncoding.title ?? colorEncoding.field,
|
|
399
718
|
value: String(mark.data[colorEncoding.field]),
|
|
@@ -402,18 +721,83 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
402
721
|
tooltipDescriptors.set(String(mark.id), { fields });
|
|
403
722
|
}
|
|
404
723
|
|
|
724
|
+
// Point tooltips (keyed with 'point:' prefix to disambiguate from feature IDs)
|
|
725
|
+
if (mapSpec.points && pointMarks.length > 0) {
|
|
726
|
+
const ptTooltipChannels = mapSpec.points.tooltip
|
|
727
|
+
? Array.isArray(mapSpec.points.tooltip)
|
|
728
|
+
? mapSpec.points.tooltip
|
|
729
|
+
: [mapSpec.points.tooltip]
|
|
730
|
+
: null;
|
|
731
|
+
|
|
732
|
+
for (const pm of pointMarks) {
|
|
733
|
+
const fields: TooltipField[] = [];
|
|
734
|
+
if (ptTooltipChannels) {
|
|
735
|
+
for (const ch of ptTooltipChannels) {
|
|
736
|
+
const val = pm.data[ch.field];
|
|
737
|
+
if (val != null) {
|
|
738
|
+
fields.push({
|
|
739
|
+
label: ch.title ?? ch.field,
|
|
740
|
+
value: ch.type === 'quantitative' ? formatter(Number(val)) : String(val),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
// Auto-populate from key, color, and size channels
|
|
746
|
+
const pts = mapSpec.points!;
|
|
747
|
+
if (pts.key) {
|
|
748
|
+
const val = pm.data[pts.key.field];
|
|
749
|
+
if (val != null)
|
|
750
|
+
fields.push({ label: pts.key.title ?? pts.key.field, value: String(val) });
|
|
751
|
+
}
|
|
752
|
+
if (pts.color) {
|
|
753
|
+
const val = pm.data[pts.color.field];
|
|
754
|
+
if (val != null)
|
|
755
|
+
fields.push({
|
|
756
|
+
label: pts.color.title ?? pts.color.field,
|
|
757
|
+
value: pts.color.type === 'quantitative' ? formatter(Number(val)) : String(val),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
if (pts.size) {
|
|
761
|
+
const val = pm.data[pts.size.field];
|
|
762
|
+
if (val != null)
|
|
763
|
+
fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
if (fields.length > 0) {
|
|
767
|
+
tooltipDescriptors.set(`point:${pm.key}`, { fields });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
405
772
|
// 17. Accessibility
|
|
773
|
+
const a11yDataField = colorEncoding?.field;
|
|
406
774
|
const a11y = {
|
|
407
|
-
altText:
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
775
|
+
altText:
|
|
776
|
+
pointMarks.length > 0
|
|
777
|
+
? colorEncoding
|
|
778
|
+
? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data and ${pointMarks.length} point markers`
|
|
779
|
+
: `Map showing ${geoFeatures.length} regions with ${pointMarks.length} point markers`
|
|
780
|
+
: colorEncoding
|
|
781
|
+
? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data`
|
|
782
|
+
: `Map showing ${geoFeatures.length} regions`,
|
|
783
|
+
dataTableFallback: [
|
|
784
|
+
...featureMarks
|
|
785
|
+
.filter((m) => m.data)
|
|
786
|
+
.map((m) => [
|
|
787
|
+
m.name ?? String(m.id),
|
|
788
|
+
a11yDataField ? String(m.data?.[a11yDataField] ?? '') : '',
|
|
789
|
+
]),
|
|
790
|
+
...pointMarks.map((pm) => [
|
|
791
|
+
pm.key,
|
|
792
|
+
mapSpec.points?.color ? String(pm.data[mapSpec.points.color.field] ?? '') : '',
|
|
793
|
+
]),
|
|
794
|
+
],
|
|
411
795
|
role: 'img' as const,
|
|
412
|
-
keyboardNavigable: featureMarks.length > 0,
|
|
796
|
+
keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0,
|
|
413
797
|
};
|
|
414
798
|
|
|
415
799
|
// 18. Resolve focus
|
|
416
|
-
const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, compileWarnings);
|
|
800
|
+
const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, pointMarks, compileWarnings);
|
|
417
801
|
|
|
418
802
|
// 19. Animation
|
|
419
803
|
const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(mapSpec.animation);
|
|
@@ -454,6 +838,9 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
454
838
|
borders,
|
|
455
839
|
continuousLegend,
|
|
456
840
|
categoricalLegend,
|
|
841
|
+
pointMarks,
|
|
842
|
+
pointCategoricalLegend,
|
|
843
|
+
pointContinuousLegend,
|
|
457
844
|
tooltipDescriptors,
|
|
458
845
|
a11y,
|
|
459
846
|
theme,
|
|
@@ -470,6 +857,65 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
|
|
|
470
857
|
};
|
|
471
858
|
}
|
|
472
859
|
|
|
860
|
+
// ---------------------------------------------------------------------------
|
|
861
|
+
// Basemap-only marks (neutral fill, no color encoding)
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
|
|
864
|
+
interface BasemapOptions {
|
|
865
|
+
geoFeatures: GeoJSON.Feature[];
|
|
866
|
+
pathGen: ReturnType<typeof geoPath>;
|
|
867
|
+
neutralFill: string;
|
|
868
|
+
neutralStroke: string;
|
|
869
|
+
droppedFeatures: string[];
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function buildBasemapMarks(opts: BasemapOptions): MapFeatureMark[] {
|
|
873
|
+
const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
|
|
874
|
+
const marks: MapFeatureMark[] = [];
|
|
875
|
+
let animIndex = 0;
|
|
876
|
+
|
|
877
|
+
for (const feat of geoFeatures) {
|
|
878
|
+
const pathD = pathGen(feat);
|
|
879
|
+
if (!pathD) {
|
|
880
|
+
const name =
|
|
881
|
+
((feat.properties as Record<string, unknown> | null)?.name as string) ?? String(feat.id);
|
|
882
|
+
droppedFeatures.push(name);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const featureId = feat.id as string | number;
|
|
887
|
+
const props = feat.properties as Record<string, unknown> | null;
|
|
888
|
+
const name = (props?.name as string) ?? (props?.NAME as string);
|
|
889
|
+
|
|
890
|
+
const b = pathGen.bounds(feat);
|
|
891
|
+
const bx = Number.isFinite(b[0][0]) ? b[0][0] : 0;
|
|
892
|
+
const by = Number.isFinite(b[0][1]) ? b[0][1] : 0;
|
|
893
|
+
const bw = Number.isFinite(b[1][0]) ? b[1][0] - bx : 0;
|
|
894
|
+
const bh = Number.isFinite(b[1][1]) ? b[1][1] - by : 0;
|
|
895
|
+
const bounds = { x: bx, y: by, width: bw, height: bh };
|
|
896
|
+
const rawCentroid = pathGen.centroid(feat);
|
|
897
|
+
const cx = Number.isFinite(rawCentroid[0]) ? rawCentroid[0] : bx + bw / 2;
|
|
898
|
+
const cy = Number.isFinite(rawCentroid[1]) ? rawCentroid[1] : by + bh / 2;
|
|
899
|
+
|
|
900
|
+
marks.push({
|
|
901
|
+
type: 'map-feature',
|
|
902
|
+
path: pathD,
|
|
903
|
+
fill: neutralFill,
|
|
904
|
+
stroke: neutralStroke,
|
|
905
|
+
strokeWidth: 0.5,
|
|
906
|
+
id: featureId,
|
|
907
|
+
name,
|
|
908
|
+
data: null,
|
|
909
|
+
aria: { role: 'img', label: name ?? String(featureId) },
|
|
910
|
+
animationIndex: animIndex++,
|
|
911
|
+
bounds,
|
|
912
|
+
centroid: [cx, cy],
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return marks;
|
|
917
|
+
}
|
|
918
|
+
|
|
473
919
|
// ---------------------------------------------------------------------------
|
|
474
920
|
// Quantitative marks (sequential color scale)
|
|
475
921
|
// ---------------------------------------------------------------------------
|
|
@@ -592,6 +1038,7 @@ interface CategoricalOptions {
|
|
|
592
1038
|
pathGen: ReturnType<typeof geoPath>;
|
|
593
1039
|
joined: Map<string | number, Record<string, unknown>>;
|
|
594
1040
|
colorField: string;
|
|
1041
|
+
colorScale?: { domain?: unknown; range?: unknown };
|
|
595
1042
|
isDarkMode: boolean;
|
|
596
1043
|
neutralFill: string;
|
|
597
1044
|
neutralStroke: string;
|
|
@@ -599,6 +1046,7 @@ interface CategoricalOptions {
|
|
|
599
1046
|
fullArea: { x: number; y: number; width: number; height: number };
|
|
600
1047
|
mapAreaHeight: number;
|
|
601
1048
|
legendReserveGap: number;
|
|
1049
|
+
legendPosition: 'top' | 'bottom';
|
|
602
1050
|
showLegend: boolean;
|
|
603
1051
|
droppedFeatures: string[];
|
|
604
1052
|
}
|
|
@@ -612,6 +1060,7 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
612
1060
|
pathGen,
|
|
613
1061
|
joined,
|
|
614
1062
|
colorField,
|
|
1063
|
+
colorScale: scaleConfig,
|
|
615
1064
|
isDarkMode,
|
|
616
1065
|
neutralFill,
|
|
617
1066
|
neutralStroke,
|
|
@@ -619,13 +1068,20 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
619
1068
|
fullArea,
|
|
620
1069
|
mapAreaHeight,
|
|
621
1070
|
legendReserveGap,
|
|
1071
|
+
legendPosition,
|
|
622
1072
|
showLegend,
|
|
623
1073
|
droppedFeatures,
|
|
624
1074
|
} = opts;
|
|
625
1075
|
|
|
626
|
-
// Collect unique categories
|
|
1076
|
+
// Collect unique categories: honor scale.domain for order, then append any unseen from data
|
|
627
1077
|
const categories: string[] = [];
|
|
628
|
-
const
|
|
1078
|
+
const explicitDomain = scaleConfig?.domain;
|
|
1079
|
+
if (explicitDomain && Array.isArray(explicitDomain)) {
|
|
1080
|
+
for (const cat of explicitDomain) {
|
|
1081
|
+
categories.push(String(cat));
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const seen = new Set<string>(categories);
|
|
629
1085
|
for (const row of joined.values()) {
|
|
630
1086
|
const raw = row[colorField];
|
|
631
1087
|
if (raw != null) {
|
|
@@ -637,9 +1093,12 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
637
1093
|
}
|
|
638
1094
|
}
|
|
639
1095
|
|
|
1096
|
+
const explicitRange = scaleConfig?.range;
|
|
1097
|
+
const colorSource =
|
|
1098
|
+
explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
|
|
640
1099
|
const categoryColors = new Map<string, string>();
|
|
641
1100
|
for (let i = 0; i < categories.length; i++) {
|
|
642
|
-
categoryColors.set(categories[i],
|
|
1101
|
+
categoryColors.set(categories[i], colorSource[i % colorSource.length]);
|
|
643
1102
|
}
|
|
644
1103
|
|
|
645
1104
|
const marks: MapFeatureMark[] = [];
|
|
@@ -721,8 +1180,15 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
721
1180
|
shape: 'square' as const,
|
|
722
1181
|
}));
|
|
723
1182
|
|
|
1183
|
+
// Honor legend.position like the continuous path does. The map drawing
|
|
1184
|
+
// area (mapAreaY in compileMap) already shifts down for top legends, so
|
|
1185
|
+
// the legend row here must agree with it — a bottom-anchored legend with
|
|
1186
|
+
// a top-shifted map is how legends used to land on top of Alaska.
|
|
724
1187
|
const legendX = fullArea.x;
|
|
725
|
-
const legendY =
|
|
1188
|
+
const legendY =
|
|
1189
|
+
legendPosition === 'bottom'
|
|
1190
|
+
? fullArea.y + mapAreaHeight + legendReserveGap
|
|
1191
|
+
: fullArea.y + legendReserveGap;
|
|
726
1192
|
const legendWidth = fullArea.width;
|
|
727
1193
|
const swatchSize = 10;
|
|
728
1194
|
const swatchGap = 6;
|
|
@@ -730,7 +1196,7 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
730
1196
|
|
|
731
1197
|
legend = {
|
|
732
1198
|
type: 'categorical',
|
|
733
|
-
position:
|
|
1199
|
+
position: legendPosition,
|
|
734
1200
|
bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
|
|
735
1201
|
labelStyle,
|
|
736
1202
|
entries,
|
|
@@ -751,10 +1217,61 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
|
|
|
751
1217
|
function resolveFocus(
|
|
752
1218
|
focus: MapFocus | null,
|
|
753
1219
|
features: MapFeatureMark[],
|
|
1220
|
+
pointMarks: MapPointMark[],
|
|
754
1221
|
warnings: CompileWarning[],
|
|
755
1222
|
): MapFocusLayout | null {
|
|
756
1223
|
if (focus === null || focus === undefined) return null;
|
|
757
1224
|
|
|
1225
|
+
// Points form fits the union of the point layer's circle bounds (cx +/- r)
|
|
1226
|
+
// rather than any feature. Use it when the points cluster in a small part of a
|
|
1227
|
+
// large feature, so fitting the feature would leave the cluster small and
|
|
1228
|
+
// off-center. `points: true` fits every point; `points: { field, value }`
|
|
1229
|
+
// fits only the matching subset, so a story can pan between sub-clusters.
|
|
1230
|
+
if (typeof focus === 'object' && !Array.isArray(focus) && 'points' in focus) {
|
|
1231
|
+
const filter = focus.points;
|
|
1232
|
+
const matched =
|
|
1233
|
+
filter === true
|
|
1234
|
+
? pointMarks
|
|
1235
|
+
: pointMarks.filter((p) => p.data[filter.field] === filter.value);
|
|
1236
|
+
|
|
1237
|
+
if (matched.length === 0) {
|
|
1238
|
+
const detail =
|
|
1239
|
+
filter === true
|
|
1240
|
+
? 'the map has no points to fit'
|
|
1241
|
+
: `no points match { field: "${filter.field}", value: ${JSON.stringify(filter.value)} }`;
|
|
1242
|
+
warnings.push({
|
|
1243
|
+
code: 'FOCUS_UNMATCHED',
|
|
1244
|
+
message: `geo.focus.points is set but ${detail}`,
|
|
1245
|
+
context: {},
|
|
1246
|
+
});
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
1249
|
+
const padding = focus.padding ?? 16;
|
|
1250
|
+
let minX = Infinity;
|
|
1251
|
+
let minY = Infinity;
|
|
1252
|
+
let maxX = -Infinity;
|
|
1253
|
+
let maxY = -Infinity;
|
|
1254
|
+
for (const p of matched) {
|
|
1255
|
+
// Use center points only, not cx ± r: the camera counter-scales point
|
|
1256
|
+
// radii to constant screen size, so the base radius doesn't occupy
|
|
1257
|
+
// projected space after the transform. Including it inflates the
|
|
1258
|
+
// bounding box and produces a much shallower zoom than expected.
|
|
1259
|
+
minX = Math.min(minX, p.cx);
|
|
1260
|
+
minY = Math.min(minY, p.cy);
|
|
1261
|
+
maxX = Math.max(maxX, p.cx);
|
|
1262
|
+
maxY = Math.max(maxY, p.cy);
|
|
1263
|
+
}
|
|
1264
|
+
// Guard against degenerate bounding box (single point or coincident points)
|
|
1265
|
+
const w = Math.max(maxX - minX, 1);
|
|
1266
|
+
const h = Math.max(maxY - minY, 1);
|
|
1267
|
+
return {
|
|
1268
|
+
target: { x: minX, y: minY, width: w, height: h, padding },
|
|
1269
|
+
// No feature ids: focus-dim is a feature concept, so a points focus
|
|
1270
|
+
// dims no features (there are no "other" features to mute).
|
|
1271
|
+
ids: [],
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
758
1275
|
// Normalize to { ids, padding }
|
|
759
1276
|
let ids: Array<string | number>;
|
|
760
1277
|
let padding = 16;
|
|
@@ -834,6 +1351,9 @@ function emptyLayout(
|
|
|
834
1351
|
},
|
|
835
1352
|
continuousLegend: null,
|
|
836
1353
|
categoricalLegend: null,
|
|
1354
|
+
pointMarks: [],
|
|
1355
|
+
pointCategoricalLegend: null,
|
|
1356
|
+
pointContinuousLegend: null,
|
|
837
1357
|
tooltipDescriptors: new Map(),
|
|
838
1358
|
a11y: {
|
|
839
1359
|
altText: 'Empty map',
|