@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.
@@ -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
- const isQuantitative = colorEncoding.type === 'quantitative';
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 (showLegend && isQuantitative) {
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
- ...(colorEncoding as EncodingChannel),
185
- scale: { ...(colorEncoding as EncodingChannel).scale, type: 'quantile' },
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 (showLegend) {
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
- const projection = createProjection(projectionType, fullArea.width, mapAreaHeight, geoCollection);
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
- const featureObjs = geoFeatures.map((f) => ({
237
- id: f.id as string | number,
238
- properties: (f.properties ?? {}) as Record<string, unknown>,
239
- }));
240
- const { joined, warnings: joinWarnings } = joinDataToFeatures(
241
- featureObjs,
242
- mapSpec.data,
243
- mapSpec.encoding.key.field,
244
- mapSpec.geo.idField,
245
- );
246
- compileWarnings.push(...joinWarnings);
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 (isQuantitative) {
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: mapSpec.encoding.color.scale?.scheme ?? 'blue',
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 (showLegend && continuousContent) {
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
- showLegend,
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: `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data`,
408
- dataTableFallback: featureMarks
409
- .filter((m) => m.data)
410
- .map((m) => [m.name ?? String(m.id), String(m.data?.[colorEncoding.field] ?? '')]),
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 in data order
1076
+ // Collect unique categories: honor scale.domain for order, then append any unseen from data
627
1077
  const categories: string[] = [];
628
- const seen = new Set<string>();
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], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
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 = fullArea.y + mapAreaHeight + legendReserveGap;
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: 'bottom',
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',