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

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;
@@ -243,7 +258,12 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
243
258
  mapSpec.encoding.key.field,
244
259
  mapSpec.geo.idField,
245
260
  );
246
- compileWarnings.push(...joinWarnings);
261
+ // In basemap-only mode (empty data + points layer), all features are intentionally unmatched
262
+ const filteredJoinWarnings =
263
+ mapSpec.data.length === 0 && mapSpec.points
264
+ ? joinWarnings.filter((w) => w.code !== 'UNMATCHED_FEATURES')
265
+ : joinWarnings;
266
+ compileWarnings.push(...filteredJoinWarnings);
247
267
 
248
268
  // 12. Check for null-projecting features (albersUsa drops territories)
249
269
  const droppedFeatures: string[] = [];
@@ -257,13 +277,22 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
257
277
  let continuousLegend: ContinuousLegendLayout | null = null;
258
278
  let categoricalLegend: CategoricalLegendLayout | null = null;
259
279
 
260
- if (isQuantitative) {
280
+ if (!colorEncoding) {
281
+ // Basemap-only mode: all features get neutral fill, no legend
282
+ featureMarks = buildBasemapMarks({
283
+ geoFeatures,
284
+ pathGen,
285
+ neutralFill,
286
+ neutralStroke,
287
+ droppedFeatures,
288
+ });
289
+ } else if (isQuantitative) {
261
290
  const result = buildQuantitativeMarks({
262
291
  geoFeatures,
263
292
  pathGen,
264
293
  joined,
265
294
  colorField: colorEncoding.field,
266
- palette: mapSpec.encoding.color.scale?.scheme ?? 'blue',
295
+ palette: colorEncoding.scale?.scheme ?? 'blue',
267
296
  isDarkMode: !!isDarkMode,
268
297
  neutralFill,
269
298
  neutralStroke,
@@ -364,6 +393,234 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
364
393
  const outlineStroke = isDarkMode ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.3)';
365
394
  const borders: MapBorders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
366
395
 
396
+ // 15b. Point marks (symbol layer above choropleth)
397
+ const pointMarks: MapPointMark[] = [];
398
+ let pointCategoricalLegend: CategoricalLegendLayout | null = null;
399
+ let pointContinuousLegend: ContinuousLegendLayout | null = null;
400
+
401
+ if (mapSpec.points) {
402
+ const pts = mapSpec.points;
403
+ const lonField = pts.longitude.field;
404
+ const latField = pts.latitude.field;
405
+ const keyChannel = pts.key;
406
+
407
+ // Size scale
408
+ const sizeScale = buildSizeScale(pts.size, pts.data, SIZE_SCALE_DEFAULTS.mapPoint);
409
+
410
+ // Color scale (independent from choropleth)
411
+ let pointColorScale: ((val: unknown) => string) | null = null;
412
+ const pointCategories: string[] = [];
413
+ const pointCategoryColors = new Map<string, string>();
414
+
415
+ if (pts.color) {
416
+ if (pts.color.type === 'quantitative') {
417
+ const colorValues: number[] = [];
418
+ for (const row of pts.data) {
419
+ const raw = row[pts.color.field];
420
+ if (raw != null) {
421
+ const v = Number(raw);
422
+ if (Number.isFinite(v)) colorValues.push(v);
423
+ }
424
+ }
425
+ if (colorValues.length > 0) {
426
+ const palette = pts.color.scale?.scheme ?? 'blue';
427
+ const paletteStops = [...(SEQUENTIAL_PALETTES[palette] ?? SEQUENTIAL_PALETTES.blue)];
428
+ const qScale = scaleQuantile<string>().domain(colorValues).range(paletteStops);
429
+ pointColorScale = (val: unknown) => {
430
+ const v = Number(val);
431
+ return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
432
+ };
433
+ }
434
+ } else {
435
+ // nominal / ordinal
436
+ const seen = new Set<string>();
437
+ for (const row of pts.data) {
438
+ const raw = row[pts.color.field];
439
+ if (raw != null) {
440
+ const cat = String(raw);
441
+ if (!seen.has(cat)) {
442
+ seen.add(cat);
443
+ pointCategories.push(cat);
444
+ pointCategoryColors.set(
445
+ cat,
446
+ CATEGORICAL_PALETTE[(pointCategories.length - 1) % CATEGORICAL_PALETTE.length],
447
+ );
448
+ }
449
+ }
450
+ }
451
+ pointColorScale = (val: unknown) => {
452
+ if (val == null) return CATEGORICAL_PALETTE[0];
453
+ return pointCategoryColors.get(String(val)) ?? CATEGORICAL_PALETTE[0];
454
+ };
455
+ }
456
+ }
457
+
458
+ const defaultFill = CATEGORICAL_PALETTE[0];
459
+ let animIndex = 0;
460
+
461
+ for (let i = 0; i < pts.data.length; i++) {
462
+ const row = pts.data[i];
463
+ const lon = Number(row[lonField]);
464
+ const lat = Number(row[latField]);
465
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
466
+
467
+ const projected = projection([lon, lat]);
468
+ if (!projected) {
469
+ compileWarnings.push({
470
+ code: 'POINT_NULL_PROJECTION',
471
+ message: `Point at [${lon}, ${lat}] projects to null and was dropped.`,
472
+ context: { lon, lat },
473
+ });
474
+ continue;
475
+ }
476
+
477
+ const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
478
+ const fill =
479
+ pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
480
+ const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
481
+ const stroke = isDarkMode ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.8)';
482
+
483
+ pointMarks.push({
484
+ type: 'map-point',
485
+ cx: projected[0],
486
+ cy: projected[1],
487
+ r,
488
+ fill,
489
+ stroke,
490
+ strokeWidth: 1,
491
+ key,
492
+ data: row as Record<string, unknown>,
493
+ aria: { role: 'img', label: key },
494
+ animationIndex: animIndex++,
495
+ });
496
+ }
497
+
498
+ // Point categorical legend
499
+ if (
500
+ pts.color &&
501
+ pts.color.type !== 'quantitative' &&
502
+ pointCategories.length > 0 &&
503
+ showLegend
504
+ ) {
505
+ const labelStyle: TextStyle = {
506
+ fontFamily: theme.fonts.family,
507
+ fontSize: theme.fonts.sizes.small,
508
+ fontWeight: theme.fonts.weights.normal,
509
+ fill: theme.colors.text,
510
+ lineHeight: 1.2,
511
+ };
512
+
513
+ const entries: LegendEntry[] = pointCategories.map((cat) => ({
514
+ label: cat,
515
+ color: pointCategoryColors.get(cat)!,
516
+ shape: 'circle' as const,
517
+ }));
518
+
519
+ // Position below the choropleth legend (or at the legend position if none)
520
+ const choroplethLegendHeight = continuousLegend
521
+ ? continuousLegend.bounds.height
522
+ : categoricalLegend
523
+ ? categoricalLegend.bounds.height
524
+ : 0;
525
+ const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
526
+ const legendX = fullArea.x;
527
+ const legendY =
528
+ fullArea.y +
529
+ mapAreaHeight +
530
+ legendReserveGap +
531
+ choroplethLegendHeight +
532
+ choroplethLegendGap;
533
+ const legendWidth = fullArea.width;
534
+ const swatchSize = 10;
535
+ const swatchGap = 6;
536
+ const entryGap = 16;
537
+
538
+ pointCategoricalLegend = {
539
+ type: 'categorical',
540
+ position: 'bottom',
541
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
542
+ labelStyle,
543
+ entries,
544
+ swatchSize,
545
+ swatchGap,
546
+ entryGap,
547
+ swatchChipFill: isDarkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
548
+ };
549
+ }
550
+
551
+ // Point continuous legend (quantitative color)
552
+ if (pts.color && pts.color.type === 'quantitative' && showLegend) {
553
+ const ptColorValues: number[] = [];
554
+ for (const row of pts.data) {
555
+ const v = row[pts.color.field];
556
+ if (v != null) {
557
+ const n = Number(v);
558
+ if (Number.isFinite(n)) ptColorValues.push(n);
559
+ }
560
+ }
561
+ const ptLegendChannel: EncodingChannel = {
562
+ ...pts.color,
563
+ scale: { ...pts.color.scale, type: 'quantile' },
564
+ };
565
+ const ptContinuousContent = computeContinuousLegendContentForChannel(
566
+ ptColorValues,
567
+ ptLegendChannel,
568
+ theme,
569
+ fullArea.width,
570
+ );
571
+ if (ptContinuousContent) {
572
+ const labelStyle: TextStyle = {
573
+ fontFamily: theme.fonts.family,
574
+ fontSize: theme.fonts.sizes.small,
575
+ fontWeight: theme.fonts.weights.normal,
576
+ fill: theme.colors.text,
577
+ lineHeight: 1.3,
578
+ fontVariant: 'tabular-nums',
579
+ };
580
+ const choroplethLegendHeight = continuousLegend
581
+ ? continuousLegend.bounds.height
582
+ : categoricalLegend
583
+ ? categoricalLegend.bounds.height
584
+ : 0;
585
+ const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
586
+ const legendX = fullArea.x;
587
+ const legendY =
588
+ fullArea.y +
589
+ mapAreaHeight +
590
+ legendReserveGap +
591
+ choroplethLegendHeight +
592
+ choroplethLegendGap;
593
+ const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
594
+ const ptLegendHeight =
595
+ ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
596
+ const bounds = {
597
+ x: legendX,
598
+ y: legendY,
599
+ width: ptContinuousContent.barWidth,
600
+ height: ptLegendHeight,
601
+ };
602
+ const bar = {
603
+ x: bounds.x,
604
+ y: bounds.y,
605
+ width: ptContinuousContent.barWidth,
606
+ height: ptContinuousContent.barHeight,
607
+ };
608
+ pointContinuousLegend = {
609
+ type: 'continuous' as const,
610
+ mode: ptContinuousContent.mode,
611
+ position: 'bottom',
612
+ bounds,
613
+ labelStyle,
614
+ bar,
615
+ colorStops: ptContinuousContent.colorStops,
616
+ bins: ptContinuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
617
+ ticks: ptContinuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
618
+ labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
619
+ };
620
+ }
621
+ }
622
+ }
623
+
367
624
  // 16. Tooltips
368
625
  const tooltipChannels = mapSpec.encoding.tooltip
369
626
  ? Array.isArray(mapSpec.encoding.tooltip)
@@ -388,12 +645,12 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
388
645
  });
389
646
  }
390
647
  }
391
- } else if (isQuantitative && mark.data[colorEncoding.field] != null) {
648
+ } else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
392
649
  fields.push({
393
650
  label: colorEncoding.title ?? colorEncoding.field,
394
651
  value: formatter(Number(mark.data[colorEncoding.field])),
395
652
  });
396
- } else if (!isQuantitative && mark.data[colorEncoding.field] != null) {
653
+ } else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
397
654
  fields.push({
398
655
  label: colorEncoding.title ?? colorEncoding.field,
399
656
  value: String(mark.data[colorEncoding.field]),
@@ -402,14 +659,79 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
402
659
  tooltipDescriptors.set(String(mark.id), { fields });
403
660
  }
404
661
 
662
+ // Point tooltips (keyed with 'point:' prefix to disambiguate from feature IDs)
663
+ if (mapSpec.points && pointMarks.length > 0) {
664
+ const ptTooltipChannels = mapSpec.points.tooltip
665
+ ? Array.isArray(mapSpec.points.tooltip)
666
+ ? mapSpec.points.tooltip
667
+ : [mapSpec.points.tooltip]
668
+ : null;
669
+
670
+ for (const pm of pointMarks) {
671
+ const fields: TooltipField[] = [];
672
+ if (ptTooltipChannels) {
673
+ for (const ch of ptTooltipChannels) {
674
+ const val = pm.data[ch.field];
675
+ if (val != null) {
676
+ fields.push({
677
+ label: ch.title ?? ch.field,
678
+ value: ch.type === 'quantitative' ? formatter(Number(val)) : String(val),
679
+ });
680
+ }
681
+ }
682
+ } else {
683
+ // Auto-populate from key, color, and size channels
684
+ const pts = mapSpec.points!;
685
+ if (pts.key) {
686
+ const val = pm.data[pts.key.field];
687
+ if (val != null)
688
+ fields.push({ label: pts.key.title ?? pts.key.field, value: String(val) });
689
+ }
690
+ if (pts.color) {
691
+ const val = pm.data[pts.color.field];
692
+ if (val != null)
693
+ fields.push({
694
+ label: pts.color.title ?? pts.color.field,
695
+ value: pts.color.type === 'quantitative' ? formatter(Number(val)) : String(val),
696
+ });
697
+ }
698
+ if (pts.size) {
699
+ const val = pm.data[pts.size.field];
700
+ if (val != null)
701
+ fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
702
+ }
703
+ }
704
+ if (fields.length > 0) {
705
+ tooltipDescriptors.set(`point:${pm.key}`, { fields });
706
+ }
707
+ }
708
+ }
709
+
405
710
  // 17. Accessibility
711
+ const a11yDataField = colorEncoding?.field;
406
712
  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] ?? '')]),
713
+ altText:
714
+ pointMarks.length > 0
715
+ ? colorEncoding
716
+ ? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data and ${pointMarks.length} point markers`
717
+ : `Map showing ${geoFeatures.length} regions with ${pointMarks.length} point markers`
718
+ : colorEncoding
719
+ ? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? 'quantitative' : 'categorical'} data`
720
+ : `Map showing ${geoFeatures.length} regions`,
721
+ dataTableFallback: [
722
+ ...featureMarks
723
+ .filter((m) => m.data)
724
+ .map((m) => [
725
+ m.name ?? String(m.id),
726
+ a11yDataField ? String(m.data?.[a11yDataField] ?? '') : '',
727
+ ]),
728
+ ...pointMarks.map((pm) => [
729
+ pm.key,
730
+ mapSpec.points?.color ? String(pm.data[mapSpec.points.color.field] ?? '') : '',
731
+ ]),
732
+ ],
411
733
  role: 'img' as const,
412
- keyboardNavigable: featureMarks.length > 0,
734
+ keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0,
413
735
  };
414
736
 
415
737
  // 18. Resolve focus
@@ -454,6 +776,9 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
454
776
  borders,
455
777
  continuousLegend,
456
778
  categoricalLegend,
779
+ pointMarks,
780
+ pointCategoricalLegend,
781
+ pointContinuousLegend,
457
782
  tooltipDescriptors,
458
783
  a11y,
459
784
  theme,
@@ -470,6 +795,65 @@ export function compileMap(spec: unknown, options: CompileOptions): MapLayout {
470
795
  };
471
796
  }
472
797
 
798
+ // ---------------------------------------------------------------------------
799
+ // Basemap-only marks (neutral fill, no color encoding)
800
+ // ---------------------------------------------------------------------------
801
+
802
+ interface BasemapOptions {
803
+ geoFeatures: GeoJSON.Feature[];
804
+ pathGen: ReturnType<typeof geoPath>;
805
+ neutralFill: string;
806
+ neutralStroke: string;
807
+ droppedFeatures: string[];
808
+ }
809
+
810
+ function buildBasemapMarks(opts: BasemapOptions): MapFeatureMark[] {
811
+ const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
812
+ const marks: MapFeatureMark[] = [];
813
+ let animIndex = 0;
814
+
815
+ for (const feat of geoFeatures) {
816
+ const pathD = pathGen(feat);
817
+ if (!pathD) {
818
+ const name =
819
+ ((feat.properties as Record<string, unknown> | null)?.name as string) ?? String(feat.id);
820
+ droppedFeatures.push(name);
821
+ continue;
822
+ }
823
+
824
+ const featureId = feat.id as string | number;
825
+ const props = feat.properties as Record<string, unknown> | null;
826
+ const name = (props?.name as string) ?? (props?.NAME as string);
827
+
828
+ const b = pathGen.bounds(feat);
829
+ const bx = Number.isFinite(b[0][0]) ? b[0][0] : 0;
830
+ const by = Number.isFinite(b[0][1]) ? b[0][1] : 0;
831
+ const bw = Number.isFinite(b[1][0]) ? b[1][0] - bx : 0;
832
+ const bh = Number.isFinite(b[1][1]) ? b[1][1] - by : 0;
833
+ const bounds = { x: bx, y: by, width: bw, height: bh };
834
+ const rawCentroid = pathGen.centroid(feat);
835
+ const cx = Number.isFinite(rawCentroid[0]) ? rawCentroid[0] : bx + bw / 2;
836
+ const cy = Number.isFinite(rawCentroid[1]) ? rawCentroid[1] : by + bh / 2;
837
+
838
+ marks.push({
839
+ type: 'map-feature',
840
+ path: pathD,
841
+ fill: neutralFill,
842
+ stroke: neutralStroke,
843
+ strokeWidth: 0.5,
844
+ id: featureId,
845
+ name,
846
+ data: null,
847
+ aria: { role: 'img', label: name ?? String(featureId) },
848
+ animationIndex: animIndex++,
849
+ bounds,
850
+ centroid: [cx, cy],
851
+ });
852
+ }
853
+
854
+ return marks;
855
+ }
856
+
473
857
  // ---------------------------------------------------------------------------
474
858
  // Quantitative marks (sequential color scale)
475
859
  // ---------------------------------------------------------------------------
@@ -834,6 +1218,9 @@ function emptyLayout(
834
1218
  },
835
1219
  continuousLegend: null,
836
1220
  categoricalLegend: null,
1221
+ pointMarks: [],
1222
+ pointCategoricalLegend: null,
1223
+ pointContinuousLegend: null,
837
1224
  tooltipDescriptors: new Map(),
838
1225
  a11y: {
839
1226
  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
  }