@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.4

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';
@@ -162,14 +165,14 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
162
165
  // 8. Legend height reserve
163
166
  const showLegend = mapSpec.legend?.show !== false;
164
167
  const colorEncoding = mapSpec.encoding.color;
165
- const isQuantitative = colorEncoding.type === 'quantitative';
168
+ const isQuantitative = colorEncoding?.type === 'quantitative';
166
169
  const legendPosition = mapSpec.legend?.position === 'bottom' ? 'bottom' : 'top';
167
170
 
168
171
  // Compute legend block height. For quantitative maps, use the continuous
169
172
  // legend infrastructure; for categorical, estimate a single swatch row.
170
173
  let legendBlockHeight = 0;
171
174
  let continuousContent: ReturnType<typeof computeContinuousLegendContentForChannel> = null;
172
- if (showLegend && isQuantitative) {
175
+ if (showLegend && colorEncoding && isQuantitative) {
173
176
  const colorValues: number[] = [];
174
177
  for (const row of mapSpec.data) {
175
178
  const v = row[colorEncoding.field];
@@ -181,8 +184,8 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
181
184
  // Maps always use a quantile color scale, so tell the legend infra
182
185
  // to produce binned swatches rather than a gradient bar.
183
186
  const legendChannel: EncodingChannel = {
184
- ...(colorEncoding as EncodingChannel),
185
- scale: { ...(colorEncoding as EncodingChannel).scale, type: 'quantile' },
187
+ ...colorEncoding,
188
+ scale: { ...colorEncoding.scale, type: 'quantile' },
186
189
  };
187
190
  continuousContent = computeContinuousLegendContentForChannel(
188
191
  colorValues,
@@ -194,10 +197,22 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
194
197
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
195
198
  legendBlockHeight = continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
196
199
  }
197
- } else if (showLegend) {
200
+ } else if (showLegend && colorEncoding) {
198
201
  const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
199
202
  legendBlockHeight = Math.max(10, labelHeight) + 6;
200
203
  }
204
+
205
+ // Reserve height for point color legend if applicable
206
+ if (showLegend && mapSpec.points?.color) {
207
+ if (mapSpec.points.color.type === 'quantitative') {
208
+ const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
209
+ legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
210
+ } else {
211
+ const ptLabelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
212
+ legendBlockHeight += Math.max(10, ptLabelHeight) + 6 + 8; // swatch row + gap
213
+ }
214
+ }
215
+
201
216
  const legendReserveGap = legendBlockHeight > 0 ? 8 : 0;
202
217
 
203
218
  const mapAreaHeight = fullArea.height - legendBlockHeight - legendReserveGap;
@@ -232,18 +247,27 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
232
247
  }
233
248
  }
234
249
 
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);
250
+ // 11. Join data to features (skip when no key channel, e.g. basemap-only mode)
251
+ let joined = new Map<string | number, Record<string, unknown>>();
252
+ if (mapSpec.encoding.key) {
253
+ const featureObjs = geoFeatures.map((f) => ({
254
+ id: f.id as string | number,
255
+ properties: (f.properties ?? {}) as Record<string, unknown>,
256
+ }));
257
+ const joinResult = joinDataToFeatures(
258
+ featureObjs,
259
+ mapSpec.data,
260
+ mapSpec.encoding.key.field,
261
+ mapSpec.geo.idField,
262
+ );
263
+ joined = joinResult.joined;
264
+ // In basemap-only mode (empty data + points layer), all features are intentionally unmatched
265
+ const filteredJoinWarnings =
266
+ mapSpec.data.length === 0 && mapSpec.points
267
+ ? joinResult.warnings.filter((w) => w.code !== 'UNMATCHED_FEATURES')
268
+ : joinResult.warnings;
269
+ compileWarnings.push(...filteredJoinWarnings);
270
+ }
247
271
 
248
272
  // 12. Check for null-projecting features (albersUsa drops territories)
249
273
  const droppedFeatures: string[] = [];
@@ -257,13 +281,22 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
257
281
  let continuousLegend: ContinuousLegendLayout | null = null;
258
282
  let categoricalLegend: CategoricalLegendLayout | null = null;
259
283
 
260
- if (isQuantitative) {
284
+ if (!colorEncoding) {
285
+ // Basemap-only mode: all features get neutral fill, no legend
286
+ featureMarks = buildBasemapMarks({
287
+ geoFeatures,
288
+ pathGen,
289
+ neutralFill,
290
+ neutralStroke,
291
+ droppedFeatures,
292
+ });
293
+ } else if (isQuantitative) {
261
294
  const result = buildQuantitativeMarks({
262
295
  geoFeatures,
263
296
  pathGen,
264
297
  joined,
265
298
  colorField: colorEncoding.field,
266
- palette: mapSpec.encoding.color.scale?.scheme ?? 'blue',
299
+ palette: colorEncoding.scale?.scheme ?? 'blue',
267
300
  isDarkMode: !!isDarkMode,
268
301
  neutralFill,
269
302
  neutralStroke,
@@ -319,6 +352,7 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
319
352
  pathGen,
320
353
  joined,
321
354
  colorField: colorEncoding.field,
355
+ colorScale: colorEncoding.scale,
322
356
  isDarkMode: !!isDarkMode,
323
357
  neutralFill,
324
358
  neutralStroke,
@@ -364,6 +398,255 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
364
398
  const outlineStroke = isDarkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.3)';
365
399
  const borders: MapBorders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
366
400
 
401
+ // 15b. Point marks (symbol layer above choropleth)
402
+ const pointMarks: MapPointMark[] = [];
403
+ let pointCategoricalLegend: CategoricalLegendLayout | null = null;
404
+ let pointContinuousLegend: ContinuousLegendLayout | null = null;
405
+
406
+ if (mapSpec.points) {
407
+ const pts = mapSpec.points;
408
+ const lonField = pts.longitude.field;
409
+ const latField = pts.latitude.field;
410
+ const keyChannel = pts.key;
411
+
412
+ // Size scale
413
+ const sizeScale = buildSizeScale(pts.size, pts.data, SIZE_SCALE_DEFAULTS.mapPoint);
414
+
415
+ // Color scale (independent from choropleth)
416
+ let pointColorScale: ((val: unknown) => string) | null = null;
417
+ const pointCategories: string[] = [];
418
+ const pointCategoryColors = new Map<string, string>();
419
+
420
+ if (pts.color) {
421
+ if (pts.color.type === 'quantitative') {
422
+ const colorValues: number[] = [];
423
+ for (const row of pts.data) {
424
+ const raw = row[pts.color.field];
425
+ if (raw != null) {
426
+ const v = Number(raw);
427
+ if (Number.isFinite(v)) colorValues.push(v);
428
+ }
429
+ }
430
+ if (colorValues.length > 0) {
431
+ const palette = pts.color.scale?.scheme ?? 'blue';
432
+ const paletteStops = [...(SEQUENTIAL_PALETTES[palette] ?? SEQUENTIAL_PALETTES.blue)];
433
+ const qScale = scaleQuantile<string>().domain(colorValues).range(paletteStops);
434
+ pointColorScale = (val: unknown) => {
435
+ const v = Number(val);
436
+ return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
437
+ };
438
+ }
439
+ } else {
440
+ // nominal / ordinal: honor scale.domain (category order) and scale.range (explicit colors)
441
+ const explicitDomain = pts.color.scale?.domain as string[] | undefined;
442
+ const explicitRange = pts.color.scale?.range as string[] | undefined;
443
+
444
+ if (explicitDomain && Array.isArray(explicitDomain)) {
445
+ for (const cat of explicitDomain) {
446
+ pointCategories.push(String(cat));
447
+ }
448
+ }
449
+ // Also pick up any categories in the data that aren't in the explicit domain
450
+ const seen = new Set<string>(pointCategories);
451
+ for (const row of pts.data) {
452
+ const raw = row[pts.color.field];
453
+ if (raw != null) {
454
+ const cat = String(raw);
455
+ if (!seen.has(cat)) {
456
+ seen.add(cat);
457
+ pointCategories.push(cat);
458
+ }
459
+ }
460
+ }
461
+
462
+ const colorSource =
463
+ explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
464
+ for (let i = 0; i < pointCategories.length; i++) {
465
+ pointCategoryColors.set(pointCategories[i], colorSource[i % colorSource.length]);
466
+ }
467
+
468
+ pointColorScale = (val: unknown) => {
469
+ if (val == null) return colorSource[0];
470
+ return pointCategoryColors.get(String(val)) ?? colorSource[0];
471
+ };
472
+ }
473
+ }
474
+
475
+ const defaultFill = CATEGORICAL_PALETTE[0];
476
+ const pointOpacity = pts.opacity ?? 0.65;
477
+ let animIndex = 0;
478
+ const droppedPoints: Array<[number, number]> = [];
479
+
480
+ for (let i = 0; i < pts.data.length; i++) {
481
+ const row = pts.data[i];
482
+ const lon = Number(row[lonField]);
483
+ const lat = Number(row[latField]);
484
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
485
+
486
+ const projected = projection([lon, lat]);
487
+ if (!projected) {
488
+ droppedPoints.push([lon, lat]);
489
+ continue;
490
+ }
491
+
492
+ const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
493
+ const fill =
494
+ pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
495
+ const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
496
+ const stroke = isDarkMode ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.8)';
497
+
498
+ pointMarks.push({
499
+ type: 'map-point',
500
+ cx: projected[0],
501
+ cy: projected[1],
502
+ r,
503
+ fill,
504
+ stroke,
505
+ strokeWidth: 1,
506
+ fillOpacity: pointOpacity,
507
+ key,
508
+ data: row as Record<string, unknown>,
509
+ aria: { role: 'img', label: key },
510
+ animationIndex: animIndex++,
511
+ });
512
+ }
513
+
514
+ if (droppedPoints.length > 0) {
515
+ const sample = droppedPoints
516
+ .slice(0, 3)
517
+ .map(([lo, la]) => `[${lo}, ${la}]`)
518
+ .join(', ');
519
+ compileWarnings.push({
520
+ code: 'POINT_NULL_PROJECTION',
521
+ message:
522
+ `${droppedPoints.length} point(s) project to null and were dropped (e.g. ${sample}). ` +
523
+ 'This is common with albersUsa which excludes territories. ' +
524
+ 'Consider using projection: "mercator" or "equalEarth".',
525
+ context: { count: droppedPoints.length, samples: droppedPoints.slice(0, 5) },
526
+ });
527
+ }
528
+
529
+ // Point categorical legend
530
+ if (
531
+ pts.color &&
532
+ pts.color.type !== 'quantitative' &&
533
+ pointCategories.length > 0 &&
534
+ showLegend
535
+ ) {
536
+ const labelStyle: TextStyle = {
537
+ fontFamily: theme.fonts.family,
538
+ fontSize: theme.fonts.sizes.small,
539
+ fontWeight: theme.fonts.weights.normal,
540
+ fill: theme.colors.text,
541
+ lineHeight: 1.2,
542
+ };
543
+
544
+ const entries: LegendEntry[] = pointCategories.map((cat) => ({
545
+ label: cat,
546
+ color: pointCategoryColors.get(cat)!,
547
+ shape: 'circle' as const,
548
+ }));
549
+
550
+ // Position below the map. Only offset by choropleth legend height when
551
+ // the choropleth legend is also at the bottom (not 'top').
552
+ const bottomChoroplethHeight =
553
+ legendPosition === 'bottom'
554
+ ? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
555
+ : 0;
556
+ const choroplethLegendGap = bottomChoroplethHeight > 0 ? 8 : 0;
557
+ const legendX = fullArea.x;
558
+ const legendY =
559
+ fullArea.y +
560
+ mapAreaHeight +
561
+ legendReserveGap +
562
+ bottomChoroplethHeight +
563
+ choroplethLegendGap;
564
+ const legendWidth = fullArea.width;
565
+ const swatchSize = 10;
566
+ const swatchGap = 6;
567
+ const entryGap = 16;
568
+
569
+ pointCategoricalLegend = {
570
+ type: 'categorical',
571
+ position: 'bottom',
572
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
573
+ labelStyle,
574
+ entries,
575
+ swatchSize,
576
+ swatchGap,
577
+ entryGap,
578
+ swatchChipFill: isDarkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
579
+ };
580
+ }
581
+
582
+ // Point continuous legend (quantitative color)
583
+ if (pts.color && pts.color.type === 'quantitative' && showLegend) {
584
+ const ptColorValues: number[] = [];
585
+ for (const row of pts.data) {
586
+ const v = row[pts.color.field];
587
+ if (v != null) {
588
+ const n = Number(v);
589
+ if (Number.isFinite(n)) ptColorValues.push(n);
590
+ }
591
+ }
592
+ const ptLegendChannel: EncodingChannel = {
593
+ ...pts.color,
594
+ scale: { ...pts.color.scale, type: 'quantile' },
595
+ };
596
+ const ptContinuousContent = computeContinuousLegendContentForChannel(
597
+ ptColorValues,
598
+ ptLegendChannel,
599
+ theme,
600
+ fullArea.width,
601
+ );
602
+ if (ptContinuousContent) {
603
+ const labelStyle: TextStyle = {
604
+ fontFamily: theme.fonts.family,
605
+ fontSize: theme.fonts.sizes.small,
606
+ fontWeight: theme.fonts.weights.normal,
607
+ fill: theme.colors.text,
608
+ lineHeight: 1.3,
609
+ fontVariant: 'tabular-nums',
610
+ };
611
+ const bottomChoroplethH =
612
+ legendPosition === 'bottom'
613
+ ? (continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0)
614
+ : 0;
615
+ const choroplethLegendGap = bottomChoroplethH > 0 ? 8 : 0;
616
+ const legendX = fullArea.x;
617
+ const legendY =
618
+ fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
619
+ const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
620
+ const ptLegendHeight =
621
+ ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
622
+ const bounds = {
623
+ x: legendX,
624
+ y: legendY,
625
+ width: ptContinuousContent.barWidth,
626
+ height: ptLegendHeight,
627
+ };
628
+ const bar = {
629
+ x: bounds.x,
630
+ y: bounds.y,
631
+ width: ptContinuousContent.barWidth,
632
+ height: ptContinuousContent.barHeight,
633
+ };
634
+ pointContinuousLegend = {
635
+ type: 'continuous' as const,
636
+ mode: ptContinuousContent.mode,
637
+ position: 'bottom',
638
+ bounds,
639
+ labelStyle,
640
+ bar,
641
+ colorStops: ptContinuousContent.colorStops,
642
+ bins: ptContinuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
643
+ ticks: ptContinuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
644
+ labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
645
+ };
646
+ }
647
+ }
648
+ }
649
+
367
650
  // 16. Tooltips
368
651
  const tooltipChannels = mapSpec.encoding.tooltip
369
652
  ? Array.isArray(mapSpec.encoding.tooltip)
@@ -388,12 +671,12 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
388
671
  });
389
672
  }
390
673
  }
391
- } else if (isQuantitative && mark.data[colorEncoding.field] != null) {
674
+ } else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
392
675
  fields.push({
393
676
  label: colorEncoding.title ?? colorEncoding.field,
394
677
  value: formatter(Number(mark.data[colorEncoding.field])),
395
678
  });
396
- } else if (!isQuantitative && mark.data[colorEncoding.field] != null) {
679
+ } else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
397
680
  fields.push({
398
681
  label: colorEncoding.title ?? colorEncoding.field,
399
682
  value: String(mark.data[colorEncoding.field]),
@@ -402,14 +685,79 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
402
685
  tooltipDescriptors.set(String(mark.id), { fields });
403
686
  }
404
687
 
688
+ // Point tooltips (keyed with 'point:' prefix to disambiguate from feature IDs)
689
+ if (mapSpec.points && pointMarks.length > 0) {
690
+ const ptTooltipChannels = mapSpec.points.tooltip
691
+ ? Array.isArray(mapSpec.points.tooltip)
692
+ ? mapSpec.points.tooltip
693
+ : [mapSpec.points.tooltip]
694
+ : null;
695
+
696
+ for (const pm of pointMarks) {
697
+ const fields: TooltipField[] = [];
698
+ if (ptTooltipChannels) {
699
+ for (const ch of ptTooltipChannels) {
700
+ const val = pm.data[ch.field];
701
+ if (val != null) {
702
+ fields.push({
703
+ label: ch.title ?? ch.field,
704
+ value: ch.type === 'quantitative' ? formatter(Number(val)) : String(val),
705
+ });
706
+ }
707
+ }
708
+ } else {
709
+ // Auto-populate from key, color, and size channels
710
+ const pts = mapSpec.points!;
711
+ if (pts.key) {
712
+ const val = pm.data[pts.key.field];
713
+ if (val != null)
714
+ fields.push({ label: pts.key.title ?? pts.key.field, value: String(val) });
715
+ }
716
+ if (pts.color) {
717
+ const val = pm.data[pts.color.field];
718
+ if (val != null)
719
+ fields.push({
720
+ label: pts.color.title ?? pts.color.field,
721
+ value: pts.color.type === 'quantitative' ? formatter(Number(val)) : String(val),
722
+ });
723
+ }
724
+ if (pts.size) {
725
+ const val = pm.data[pts.size.field];
726
+ if (val != null)
727
+ fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
728
+ }
729
+ }
730
+ if (fields.length > 0) {
731
+ tooltipDescriptors.set(`point:${pm.key}`, { fields });
732
+ }
733
+ }
734
+ }
735
+
405
736
  // 17. Accessibility
737
+ const a11yDataField = colorEncoding?.field;
406
738
  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] ?? '')]),
739
+ altText:
740
+ pointMarks.length > 0
741
+ ? colorEncoding
742
+ ? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data and ${pointMarks.length} point markers`
743
+ : `Map showing ${geoFeatures.length} regions with ${pointMarks.length} point markers`
744
+ : colorEncoding
745
+ ? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data`
746
+ : `Map showing ${geoFeatures.length} regions`,
747
+ dataTableFallback: [
748
+ ...featureMarks
749
+ .filter((m) => m.data)
750
+ .map((m) => [
751
+ m.name ?? String(m.id),
752
+ a11yDataField ? String(m.data?.[a11yDataField] ?? '') : '',
753
+ ]),
754
+ ...pointMarks.map((pm) => [
755
+ pm.key,
756
+ mapSpec.points?.color ? String(pm.data[mapSpec.points.color.field] ?? '') : '',
757
+ ]),
758
+ ],
411
759
  role: 'img' as const,
412
- keyboardNavigable: featureMarks.length > 0,
760
+ keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0,
413
761
  };
414
762
 
415
763
  // 18. Resolve focus
@@ -454,6 +802,9 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
454
802
  borders,
455
803
  continuousLegend,
456
804
  categoricalLegend,
805
+ pointMarks,
806
+ pointCategoricalLegend,
807
+ pointContinuousLegend,
457
808
  tooltipDescriptors,
458
809
  a11y,
459
810
  theme,
@@ -470,6 +821,65 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
470
821
  };
471
822
  }
472
823
 
824
+ // ---------------------------------------------------------------------------
825
+ // Basemap-only marks (neutral fill, no color encoding)
826
+ // ---------------------------------------------------------------------------
827
+
828
+ interface BasemapOptions {
829
+ geoFeatures: GeoJSON.Feature[];
830
+ pathGen: ReturnType<typeof geoPath>;
831
+ neutralFill: string;
832
+ neutralStroke: string;
833
+ droppedFeatures: string[];
834
+ }
835
+
836
+ function buildBasemapMarks(opts: BasemapOptions): MapFeatureMark[] {
837
+ const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
838
+ const marks: MapFeatureMark[] = [];
839
+ let animIndex = 0;
840
+
841
+ for (const feat of geoFeatures) {
842
+ const pathD = pathGen(feat);
843
+ if (!pathD) {
844
+ const name =
845
+ ((feat.properties as Record<string, unknown> | null)?.name as string) ?? String(feat.id);
846
+ droppedFeatures.push(name);
847
+ continue;
848
+ }
849
+
850
+ const featureId = feat.id as string | number;
851
+ const props = feat.properties as Record<string, unknown> | null;
852
+ const name = (props?.name as string) ?? (props?.NAME as string);
853
+
854
+ const b = pathGen.bounds(feat);
855
+ const bx = Number.isFinite(b[0][0]) ? b[0][0] : 0;
856
+ const by = Number.isFinite(b[0][1]) ? b[0][1] : 0;
857
+ const bw = Number.isFinite(b[1][0]) ? b[1][0] - bx : 0;
858
+ const bh = Number.isFinite(b[1][1]) ? b[1][1] - by : 0;
859
+ const bounds = { x: bx, y: by, width: bw, height: bh };
860
+ const rawCentroid = pathGen.centroid(feat);
861
+ const cx = Number.isFinite(rawCentroid[0]) ? rawCentroid[0] : bx + bw / 2;
862
+ const cy = Number.isFinite(rawCentroid[1]) ? rawCentroid[1] : by + bh / 2;
863
+
864
+ marks.push({
865
+ type: 'map-feature',
866
+ path: pathD,
867
+ fill: neutralFill,
868
+ stroke: neutralStroke,
869
+ strokeWidth: 0.5,
870
+ id: featureId,
871
+ name,
872
+ data: null,
873
+ aria: { role: 'img', label: name ?? String(featureId) },
874
+ animationIndex: animIndex++,
875
+ bounds,
876
+ centroid: [cx, cy],
877
+ });
878
+ }
879
+
880
+ return marks;
881
+ }
882
+
473
883
  // ---------------------------------------------------------------------------
474
884
  // Quantitative marks (sequential color scale)
475
885
  // ---------------------------------------------------------------------------
@@ -592,6 +1002,7 @@ interface CategoricalOptions {
592
1002
  pathGen: ReturnType<typeof geoPath>;
593
1003
  joined: Map<string | number, Record<string, unknown>>;
594
1004
  colorField: string;
1005
+ colorScale?: { domain?: unknown; range?: unknown };
595
1006
  isDarkMode: boolean;
596
1007
  neutralFill: string;
597
1008
  neutralStroke: string;
@@ -612,6 +1023,7 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
612
1023
  pathGen,
613
1024
  joined,
614
1025
  colorField,
1026
+ colorScale: scaleConfig,
615
1027
  isDarkMode,
616
1028
  neutralFill,
617
1029
  neutralStroke,
@@ -623,9 +1035,15 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
623
1035
  droppedFeatures,
624
1036
  } = opts;
625
1037
 
626
- // Collect unique categories in data order
1038
+ // Collect unique categories: honor scale.domain for order, then append any unseen from data
627
1039
  const categories: string[] = [];
628
- const seen = new Set<string>();
1040
+ const explicitDomain = scaleConfig?.domain;
1041
+ if (explicitDomain && Array.isArray(explicitDomain)) {
1042
+ for (const cat of explicitDomain) {
1043
+ categories.push(String(cat));
1044
+ }
1045
+ }
1046
+ const seen = new Set<string>(categories);
629
1047
  for (const row of joined.values()) {
630
1048
  const raw = row[colorField];
631
1049
  if (raw != null) {
@@ -637,9 +1055,12 @@ function buildCategoricalMarks(opts: CategoricalOptions): {
637
1055
  }
638
1056
  }
639
1057
 
1058
+ const explicitRange = scaleConfig?.range;
1059
+ const colorSource =
1060
+ explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
640
1061
  const categoryColors = new Map<string, string>();
641
1062
  for (let i = 0; i < categories.length; i++) {
642
- categoryColors.set(categories[i], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
1063
+ categoryColors.set(categories[i], colorSource[i % colorSource.length]);
643
1064
  }
644
1065
 
645
1066
  const marks: MapFeatureMark[] = [];
@@ -834,6 +1255,9 @@ function emptyLayout(
834
1255
  },
835
1256
  continuousLegend: null,
836
1257
  categoricalLegend: null,
1258
+ pointMarks: [],
1259
+ pointCategoricalLegend: null,
1260
+ pointContinuousLegend: null,
837
1261
  tooltipDescriptors: new Map(),
838
1262
  a11y: {
839
1263
  altText: 'Empty map',
package/src/geo/types.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  LegendConfig,
5
5
  MapEncoding,
6
6
  MapGeo,
7
+ MapPointsLayer,
7
8
  ThemeConfig,
8
9
  } from '@opendata-ai/openchart-core';
9
10
 
@@ -21,4 +22,5 @@ export interface NormalizedMapSpec {
21
22
  watermark: boolean;
22
23
  animation?: AnimationSpec;
23
24
  valueFormat?: string;
25
+ points?: MapPointsLayer;
24
26
  }