@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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _opendata_ai_openchart_core from '@opendata-ai/openchart-core';
2
- import { LegendLayout, ResolvedChrome, TooltipContent, A11yMetadata, ResolvedTheme, CompileOptions, ChartLayout, LayerSpec, CompileTableOptions, TableLayout, ResolvedAnimationPhase, AnimationSpec, ResolvedAnimation, Point, DataRow, BarListEncoding, ThemeConfig, DarkMode, MapGeo, MapEncoding, LegendConfig, TileMapEncoding, TileMapPalette, MarkType, MarkDef, Encoding, ChromeText, Annotation, LabelConfig, Display, ColumnConfig, GraphSpec, GraphEncoding, GraphLayoutConfig, NodeOverride, SankeyEncoding, SankeyNodeAlign, SankeyLinkColor, ScaleType, EncodingChannel, FieldFormatContext, Rect, LayoutStrategy, VizSpec, Mark, BinTransform, CalculateTransform, ConditionalValueDef, FilterPredicate, TimeUnitTransform, Transform } from '@opendata-ai/openchart-core';
2
+ import { LegendLayout, ResolvedChrome, TooltipContent, A11yMetadata, ResolvedTheme, CompileOptions, ChartLayout, LayerSpec, CompileTableOptions, TableLayout, ResolvedAnimationPhase, AnimationSpec, ResolvedAnimation, Point, DataRow, BarListEncoding, ThemeConfig, DarkMode, MapGeo, MapEncoding, LegendConfig, MapPointsLayer, TileMapEncoding, TileMapPalette, MarkType, MarkDef, Encoding, ChromeText, Annotation, LabelConfig, Display, ColumnConfig, GraphSpec, GraphEncoding, GraphLayoutConfig, NodeOverride, SankeyEncoding, SankeyNodeAlign, SankeyLinkColor, ScaleType, EncodingChannel, FieldFormatContext, Rect, LayoutStrategy, VizSpec, Mark, BinTransform, CalculateTransform, ConditionalValueDef, FilterPredicate, TimeUnitTransform, Transform } from '@opendata-ai/openchart-core';
3
3
  export { BarListLayout, BarListSpec, ChartLayout, ChartSpec, CompileOptions, CompileTableOptions, GraphLayout, GraphSpec, LayerSpec, MapLayout, MapSpec, SankeyLayout, SankeySpec, TableLayout, TableSpec, TileMapLayout, TileMapSpec, VizSpec } from '@opendata-ai/openchart-core';
4
4
  import { ScaleLinear, ScaleTime, ScaleLogarithmic, ScalePower, ScaleSymLog, ScaleBand, ScalePoint, ScaleOrdinal, ScaleQuantile, ScaleQuantize, ScaleThreshold } from 'd3-scale';
5
5
 
@@ -301,6 +301,7 @@ interface NormalizedMapSpec {
301
301
  watermark: boolean;
302
302
  animation?: AnimationSpec;
303
303
  valueFormat?: string;
304
+ points?: MapPointsLayer;
304
305
  }
305
306
 
306
307
  /**
package/dist/index.js CHANGED
@@ -5545,7 +5545,9 @@ var SIZE_SCALE_DEFAULTS = {
5545
5545
  * Text glyphs. Linear, not sqrt -- a word reads by its height, which is
5546
5546
  * linear in font size, where a disc reads by its area.
5547
5547
  */
5548
- text: { curve: "linear", range: [11, 32] }
5548
+ text: { curve: "linear", range: [11, 32] },
5549
+ /** Map point dots. sqrt for area-proportional, tighter range than scatter. */
5550
+ mapPoint: { curve: "sqrt", range: [3, 20] }
5549
5551
  };
5550
5552
  function sizeScaleDefaultsFor(markType) {
5551
5553
  const found = SIZE_SCALE_DEFAULTS[markType];
@@ -10631,7 +10633,8 @@ function normalizeMapSpec(spec, _warnings) {
10631
10633
  darkMode: spec.darkMode ?? "off",
10632
10634
  watermark: spec.watermark ?? true,
10633
10635
  animation: spec.animation,
10634
- valueFormat: spec.valueFormat
10636
+ valueFormat: spec.valueFormat,
10637
+ points: spec.points
10635
10638
  };
10636
10639
  }
10637
10640
  function normalizeBarListSpec(spec, _warnings) {
@@ -11802,25 +11805,76 @@ function validateMapSpec(spec, errors) {
11802
11805
  }
11803
11806
  }
11804
11807
  const encoding = spec.encoding;
11805
- for (const channel of ["key", "color"]) {
11806
- const ch = encoding[channel];
11807
- if (!ch || typeof ch !== "object") {
11808
+ const hasPoints = spec.points && typeof spec.points === "object" && !Array.isArray(spec.points);
11809
+ const keyCh = encoding.key;
11810
+ if (!keyCh || typeof keyCh !== "object") {
11811
+ errors.push({
11812
+ message: 'Spec error: map encoding requires "key" channel',
11813
+ path: "encoding.key",
11814
+ code: "MISSING_FIELD",
11815
+ suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }'
11816
+ });
11817
+ } else if (!keyCh.field || typeof keyCh.field !== "string") {
11818
+ errors.push({
11819
+ message: 'Spec error: encoding.key must have a "field" string',
11820
+ path: "encoding.key.field",
11821
+ code: "MISSING_FIELD",
11822
+ suggestion: 'Add a field name, e.g. key: { field: "id" }'
11823
+ });
11824
+ }
11825
+ const colorCh = encoding.color;
11826
+ if (!hasPoints) {
11827
+ if (!colorCh || typeof colorCh !== "object") {
11808
11828
  errors.push({
11809
- message: `Spec error: map encoding requires "${channel}" channel`,
11810
- path: `encoding.${channel}`,
11829
+ message: 'Spec error: map encoding requires "color" channel (or add a "points" layer for basemap-only mode)',
11830
+ path: "encoding.color",
11811
11831
  code: "MISSING_FIELD",
11812
- suggestion: `Add encoding.${channel}, e.g. ${channel}: { field: "${channel === "key" ? "id" : "value"}", type: "${channel === "key" ? "nominal" : "quantitative"}" }`
11832
+ suggestion: 'Add encoding.color, e.g. color: { field: "value", type: "quantitative" }'
11813
11833
  });
11814
- continue;
11815
- }
11816
- if (!ch.field || typeof ch.field !== "string") {
11834
+ } else if (!colorCh.field || typeof colorCh.field !== "string") {
11817
11835
  errors.push({
11818
- message: `Spec error: encoding.${channel} must have a "field" string`,
11819
- path: `encoding.${channel}.field`,
11836
+ message: 'Spec error: encoding.color must have a "field" string',
11837
+ path: "encoding.color.field",
11820
11838
  code: "MISSING_FIELD",
11821
- suggestion: `Add a field name, e.g. ${channel}: { field: "${channel === "key" ? "id" : "value"}" }`
11839
+ suggestion: 'Add a field name, e.g. color: { field: "value" }'
11822
11840
  });
11823
11841
  }
11842
+ } else if (colorCh && typeof colorCh === "object" && (!colorCh.field || typeof colorCh.field !== "string")) {
11843
+ errors.push({
11844
+ message: 'Spec error: encoding.color must have a "field" string when provided',
11845
+ path: "encoding.color.field",
11846
+ code: "MISSING_FIELD",
11847
+ suggestion: 'Add a field name, e.g. color: { field: "value" }'
11848
+ });
11849
+ }
11850
+ if (hasPoints) {
11851
+ const points = spec.points;
11852
+ if (!Array.isArray(points.data)) {
11853
+ errors.push({
11854
+ message: "Spec error: points.data must be an array of data rows",
11855
+ path: "points.data",
11856
+ code: "INVALID_TYPE",
11857
+ suggestion: "Provide data as an array of objects with lat/lng fields"
11858
+ });
11859
+ }
11860
+ for (const ch of ["longitude", "latitude"]) {
11861
+ const enc = points[ch];
11862
+ if (!enc || typeof enc !== "object") {
11863
+ errors.push({
11864
+ message: `Spec error: points requires "${ch}" encoding channel`,
11865
+ path: `points.${ch}`,
11866
+ code: "MISSING_FIELD",
11867
+ suggestion: `Add points.${ch}, e.g. ${ch}: { field: "${ch === "longitude" ? "lon" : "lat"}", type: "quantitative" }`
11868
+ });
11869
+ } else if (!enc.field || typeof enc.field !== "string") {
11870
+ errors.push({
11871
+ message: `Spec error: points.${ch} must have a "field" string`,
11872
+ path: `points.${ch}.field`,
11873
+ code: "MISSING_FIELD",
11874
+ suggestion: `Add a field name, e.g. ${ch}: { field: "${ch === "longitude" ? "lon" : "lat"}" }`
11875
+ });
11876
+ }
11877
+ }
11824
11878
  }
11825
11879
  }
11826
11880
  function validateBarListSpec(spec, errors) {
@@ -17767,11 +17821,11 @@ function compileMap(spec, options) {
17767
17821
  const compileWarnings = [];
17768
17822
  const showLegend = mapSpec.legend?.show !== false;
17769
17823
  const colorEncoding = mapSpec.encoding.color;
17770
- const isQuantitative = colorEncoding.type === "quantitative";
17824
+ const isQuantitative = colorEncoding?.type === "quantitative";
17771
17825
  const legendPosition = mapSpec.legend?.position === "bottom" ? "bottom" : "top";
17772
17826
  let legendBlockHeight = 0;
17773
17827
  let continuousContent = null;
17774
- if (showLegend && isQuantitative) {
17828
+ if (showLegend && colorEncoding && isQuantitative) {
17775
17829
  const colorValues = [];
17776
17830
  for (const row of mapSpec.data) {
17777
17831
  const v = row[colorEncoding.field];
@@ -17794,10 +17848,19 @@ function compileMap(spec, options) {
17794
17848
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17795
17849
  legendBlockHeight = continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
17796
17850
  }
17797
- } else if (showLegend) {
17851
+ } else if (showLegend && colorEncoding) {
17798
17852
  const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17799
17853
  legendBlockHeight = Math.max(10, labelHeight) + 6;
17800
17854
  }
17855
+ if (showLegend && mapSpec.points?.color) {
17856
+ if (mapSpec.points.color.type === "quantitative") {
17857
+ const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17858
+ legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
17859
+ } else {
17860
+ const ptLabelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17861
+ legendBlockHeight += Math.max(10, ptLabelHeight) + 6 + 8;
17862
+ }
17863
+ }
17801
17864
  const legendReserveGap = legendBlockHeight > 0 ? 8 : 0;
17802
17865
  const mapAreaHeight = fullArea.height - legendBlockHeight - legendReserveGap;
17803
17866
  if (mapAreaHeight <= 0) {
@@ -17829,7 +17892,8 @@ function compileMap(spec, options) {
17829
17892
  mapSpec.encoding.key.field,
17830
17893
  mapSpec.geo.idField
17831
17894
  );
17832
- compileWarnings.push(...joinWarnings);
17895
+ const filteredJoinWarnings = mapSpec.data.length === 0 && mapSpec.points ? joinWarnings.filter((w) => w.code !== "UNMATCHED_FEATURES") : joinWarnings;
17896
+ compileWarnings.push(...filteredJoinWarnings);
17833
17897
  const droppedFeatures = [];
17834
17898
  const formatter = buildD3Formatter2(mapSpec.valueFormat) ?? formatNumber3;
17835
17899
  const neutralFill = isDarkMode ? "#2a2a2a" : "#e8e8e8";
@@ -17837,13 +17901,21 @@ function compileMap(spec, options) {
17837
17901
  let featureMarks;
17838
17902
  let continuousLegend = null;
17839
17903
  let categoricalLegend = null;
17840
- if (isQuantitative) {
17904
+ if (!colorEncoding) {
17905
+ featureMarks = buildBasemapMarks({
17906
+ geoFeatures,
17907
+ pathGen,
17908
+ neutralFill,
17909
+ neutralStroke,
17910
+ droppedFeatures
17911
+ });
17912
+ } else if (isQuantitative) {
17841
17913
  const result = buildQuantitativeMarks({
17842
17914
  geoFeatures,
17843
17915
  pathGen,
17844
17916
  joined,
17845
17917
  colorField: colorEncoding.field,
17846
- palette: mapSpec.encoding.color.scale?.scheme ?? "blue",
17918
+ palette: colorEncoding.scale?.scheme ?? "blue",
17847
17919
  isDarkMode: !!isDarkMode,
17848
17920
  neutralFill,
17849
17921
  neutralStroke,
@@ -17928,6 +18000,187 @@ function compileMap(spec, options) {
17928
18000
  const interiorStroke = isDarkMode ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.15)";
17929
18001
  const outlineStroke = isDarkMode ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.3)";
17930
18002
  const borders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
18003
+ const pointMarks = [];
18004
+ let pointCategoricalLegend = null;
18005
+ let pointContinuousLegend = null;
18006
+ if (mapSpec.points) {
18007
+ const pts = mapSpec.points;
18008
+ const lonField = pts.longitude.field;
18009
+ const latField = pts.latitude.field;
18010
+ const keyChannel = pts.key;
18011
+ const sizeScale = buildSizeScale(pts.size, pts.data, SIZE_SCALE_DEFAULTS.mapPoint);
18012
+ let pointColorScale = null;
18013
+ const pointCategories = [];
18014
+ const pointCategoryColors = /* @__PURE__ */ new Map();
18015
+ if (pts.color) {
18016
+ if (pts.color.type === "quantitative") {
18017
+ const colorValues = [];
18018
+ for (const row of pts.data) {
18019
+ const raw = row[pts.color.field];
18020
+ if (raw != null) {
18021
+ const v = Number(raw);
18022
+ if (Number.isFinite(v)) colorValues.push(v);
18023
+ }
18024
+ }
18025
+ if (colorValues.length > 0) {
18026
+ const palette = pts.color.scale?.scheme ?? "blue";
18027
+ const paletteStops = [...SEQUENTIAL_PALETTES[palette] ?? SEQUENTIAL_PALETTES.blue];
18028
+ const qScale = quantile2().domain(colorValues).range(paletteStops);
18029
+ pointColorScale = (val) => {
18030
+ const v = Number(val);
18031
+ return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
18032
+ };
18033
+ }
18034
+ } else {
18035
+ const seen = /* @__PURE__ */ new Set();
18036
+ for (const row of pts.data) {
18037
+ const raw = row[pts.color.field];
18038
+ if (raw != null) {
18039
+ const cat = String(raw);
18040
+ if (!seen.has(cat)) {
18041
+ seen.add(cat);
18042
+ pointCategories.push(cat);
18043
+ pointCategoryColors.set(
18044
+ cat,
18045
+ CATEGORICAL_PALETTE[(pointCategories.length - 1) % CATEGORICAL_PALETTE.length]
18046
+ );
18047
+ }
18048
+ }
18049
+ }
18050
+ pointColorScale = (val) => {
18051
+ if (val == null) return CATEGORICAL_PALETTE[0];
18052
+ return pointCategoryColors.get(String(val)) ?? CATEGORICAL_PALETTE[0];
18053
+ };
18054
+ }
18055
+ }
18056
+ const defaultFill = CATEGORICAL_PALETTE[0];
18057
+ let animIndex = 0;
18058
+ for (let i = 0; i < pts.data.length; i++) {
18059
+ const row = pts.data[i];
18060
+ const lon = Number(row[lonField]);
18061
+ const lat = Number(row[latField]);
18062
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
18063
+ const projected = projection2([lon, lat]);
18064
+ if (!projected) {
18065
+ compileWarnings.push({
18066
+ code: "POINT_NULL_PROJECTION",
18067
+ message: `Point at [${lon}, ${lat}] projects to null and was dropped.`,
18068
+ context: { lon, lat }
18069
+ });
18070
+ continue;
18071
+ }
18072
+ const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
18073
+ const fill = pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
18074
+ const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
18075
+ const stroke = isDarkMode ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.8)";
18076
+ pointMarks.push({
18077
+ type: "map-point",
18078
+ cx: projected[0],
18079
+ cy: projected[1],
18080
+ r,
18081
+ fill,
18082
+ stroke,
18083
+ strokeWidth: 1,
18084
+ key,
18085
+ data: row,
18086
+ aria: { role: "img", label: key },
18087
+ animationIndex: animIndex++
18088
+ });
18089
+ }
18090
+ if (pts.color && pts.color.type !== "quantitative" && pointCategories.length > 0 && showLegend) {
18091
+ const labelStyle = {
18092
+ fontFamily: theme.fonts.family,
18093
+ fontSize: theme.fonts.sizes.small,
18094
+ fontWeight: theme.fonts.weights.normal,
18095
+ fill: theme.colors.text,
18096
+ lineHeight: 1.2
18097
+ };
18098
+ const entries = pointCategories.map((cat) => ({
18099
+ label: cat,
18100
+ color: pointCategoryColors.get(cat),
18101
+ shape: "circle"
18102
+ }));
18103
+ const choroplethLegendHeight = continuousLegend ? continuousLegend.bounds.height : categoricalLegend ? categoricalLegend.bounds.height : 0;
18104
+ const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
18105
+ const legendX = fullArea.x;
18106
+ const legendY = fullArea.y + mapAreaHeight + legendReserveGap + choroplethLegendHeight + choroplethLegendGap;
18107
+ const legendWidth = fullArea.width;
18108
+ const swatchSize = 10;
18109
+ const swatchGap = 6;
18110
+ const entryGap = 16;
18111
+ pointCategoricalLegend = {
18112
+ type: "categorical",
18113
+ position: "bottom",
18114
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
18115
+ labelStyle,
18116
+ entries,
18117
+ swatchSize,
18118
+ swatchGap,
18119
+ entryGap,
18120
+ swatchChipFill: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)"
18121
+ };
18122
+ }
18123
+ if (pts.color && pts.color.type === "quantitative" && showLegend) {
18124
+ const ptColorValues = [];
18125
+ for (const row of pts.data) {
18126
+ const v = row[pts.color.field];
18127
+ if (v != null) {
18128
+ const n = Number(v);
18129
+ if (Number.isFinite(n)) ptColorValues.push(n);
18130
+ }
18131
+ }
18132
+ const ptLegendChannel = {
18133
+ ...pts.color,
18134
+ scale: { ...pts.color.scale, type: "quantile" }
18135
+ };
18136
+ const ptContinuousContent = computeContinuousLegendContentForChannel(
18137
+ ptColorValues,
18138
+ ptLegendChannel,
18139
+ theme,
18140
+ fullArea.width
18141
+ );
18142
+ if (ptContinuousContent) {
18143
+ const labelStyle = {
18144
+ fontFamily: theme.fonts.family,
18145
+ fontSize: theme.fonts.sizes.small,
18146
+ fontWeight: theme.fonts.weights.normal,
18147
+ fill: theme.colors.text,
18148
+ lineHeight: 1.3,
18149
+ fontVariant: "tabular-nums"
18150
+ };
18151
+ const choroplethLegendHeight = continuousLegend ? continuousLegend.bounds.height : categoricalLegend ? categoricalLegend.bounds.height : 0;
18152
+ const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
18153
+ const legendX = fullArea.x;
18154
+ const legendY = fullArea.y + mapAreaHeight + legendReserveGap + choroplethLegendHeight + choroplethLegendGap;
18155
+ const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
18156
+ const ptLegendHeight = ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
18157
+ const bounds = {
18158
+ x: legendX,
18159
+ y: legendY,
18160
+ width: ptContinuousContent.barWidth,
18161
+ height: ptLegendHeight
18162
+ };
18163
+ const bar = {
18164
+ x: bounds.x,
18165
+ y: bounds.y,
18166
+ width: ptContinuousContent.barWidth,
18167
+ height: ptContinuousContent.barHeight
18168
+ };
18169
+ pointContinuousLegend = {
18170
+ type: "continuous",
18171
+ mode: ptContinuousContent.mode,
18172
+ position: "bottom",
18173
+ bounds,
18174
+ labelStyle,
18175
+ bar,
18176
+ colorStops: ptContinuousContent.colorStops,
18177
+ bins: ptContinuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
18178
+ ticks: ptContinuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
18179
+ labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize
18180
+ };
18181
+ }
18182
+ }
18183
+ }
17931
18184
  const tooltipChannels = mapSpec.encoding.tooltip ? Array.isArray(mapSpec.encoding.tooltip) ? mapSpec.encoding.tooltip : [mapSpec.encoding.tooltip] : null;
17932
18185
  const tooltipDescriptors = /* @__PURE__ */ new Map();
17933
18186
  for (const mark of featureMarks) {
@@ -17945,12 +18198,12 @@ function compileMap(spec, options) {
17945
18198
  });
17946
18199
  }
17947
18200
  }
17948
- } else if (isQuantitative && mark.data[colorEncoding.field] != null) {
18201
+ } else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
17949
18202
  fields.push({
17950
18203
  label: colorEncoding.title ?? colorEncoding.field,
17951
18204
  value: formatter(Number(mark.data[colorEncoding.field]))
17952
18205
  });
17953
- } else if (!isQuantitative && mark.data[colorEncoding.field] != null) {
18206
+ } else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
17954
18207
  fields.push({
17955
18208
  label: colorEncoding.title ?? colorEncoding.field,
17956
18209
  value: String(mark.data[colorEncoding.field])
@@ -17958,11 +18211,61 @@ function compileMap(spec, options) {
17958
18211
  }
17959
18212
  tooltipDescriptors.set(String(mark.id), { fields });
17960
18213
  }
18214
+ if (mapSpec.points && pointMarks.length > 0) {
18215
+ const ptTooltipChannels = mapSpec.points.tooltip ? Array.isArray(mapSpec.points.tooltip) ? mapSpec.points.tooltip : [mapSpec.points.tooltip] : null;
18216
+ for (const pm of pointMarks) {
18217
+ const fields = [];
18218
+ if (ptTooltipChannels) {
18219
+ for (const ch of ptTooltipChannels) {
18220
+ const val = pm.data[ch.field];
18221
+ if (val != null) {
18222
+ fields.push({
18223
+ label: ch.title ?? ch.field,
18224
+ value: ch.type === "quantitative" ? formatter(Number(val)) : String(val)
18225
+ });
18226
+ }
18227
+ }
18228
+ } else {
18229
+ const pts = mapSpec.points;
18230
+ if (pts.key) {
18231
+ const val = pm.data[pts.key.field];
18232
+ if (val != null)
18233
+ fields.push({ label: pts.key.title ?? pts.key.field, value: String(val) });
18234
+ }
18235
+ if (pts.color) {
18236
+ const val = pm.data[pts.color.field];
18237
+ if (val != null)
18238
+ fields.push({
18239
+ label: pts.color.title ?? pts.color.field,
18240
+ value: pts.color.type === "quantitative" ? formatter(Number(val)) : String(val)
18241
+ });
18242
+ }
18243
+ if (pts.size) {
18244
+ const val = pm.data[pts.size.field];
18245
+ if (val != null)
18246
+ fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
18247
+ }
18248
+ }
18249
+ if (fields.length > 0) {
18250
+ tooltipDescriptors.set(`point:${pm.key}`, { fields });
18251
+ }
18252
+ }
18253
+ }
18254
+ const a11yDataField = colorEncoding?.field;
17961
18255
  const a11y = {
17962
- altText: `Map showing ${geoFeatures.length} regions with ${isQuantitative ? "quantitative" : "categorical"} data`,
17963
- dataTableFallback: featureMarks.filter((m) => m.data).map((m) => [m.name ?? String(m.id), String(m.data?.[colorEncoding.field] ?? "")]),
18256
+ altText: pointMarks.length > 0 ? colorEncoding ? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? "quantitative" : "categorical"} data and ${pointMarks.length} point markers` : `Map showing ${geoFeatures.length} regions with ${pointMarks.length} point markers` : colorEncoding ? `Map showing ${geoFeatures.length} regions with ${isQuantitative ? "quantitative" : "categorical"} data` : `Map showing ${geoFeatures.length} regions`,
18257
+ dataTableFallback: [
18258
+ ...featureMarks.filter((m) => m.data).map((m) => [
18259
+ m.name ?? String(m.id),
18260
+ a11yDataField ? String(m.data?.[a11yDataField] ?? "") : ""
18261
+ ]),
18262
+ ...pointMarks.map((pm) => [
18263
+ pm.key,
18264
+ mapSpec.points?.color ? String(pm.data[mapSpec.points.color.field] ?? "") : ""
18265
+ ])
18266
+ ],
17964
18267
  role: "img",
17965
- keyboardNavigable: featureMarks.length > 0
18268
+ keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0
17966
18269
  };
17967
18270
  const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, compileWarnings);
17968
18271
  const resolvedAnimation = resolveAnimation(mapSpec.animation);
@@ -17987,6 +18290,9 @@ function compileMap(spec, options) {
17987
18290
  borders,
17988
18291
  continuousLegend,
17989
18292
  categoricalLegend,
18293
+ pointMarks,
18294
+ pointCategoricalLegend,
18295
+ pointContinuousLegend,
17990
18296
  tooltipDescriptors,
17991
18297
  a11y,
17992
18298
  theme,
@@ -18000,6 +18306,46 @@ function compileMap(spec, options) {
18000
18306
  focus: resolvedFocus
18001
18307
  };
18002
18308
  }
18309
+ function buildBasemapMarks(opts) {
18310
+ const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
18311
+ const marks = [];
18312
+ let animIndex = 0;
18313
+ for (const feat of geoFeatures) {
18314
+ const pathD = pathGen(feat);
18315
+ if (!pathD) {
18316
+ const name2 = feat.properties?.name ?? String(feat.id);
18317
+ droppedFeatures.push(name2);
18318
+ continue;
18319
+ }
18320
+ const featureId = feat.id;
18321
+ const props = feat.properties;
18322
+ const name = props?.name ?? props?.NAME;
18323
+ const b = pathGen.bounds(feat);
18324
+ const bx = Number.isFinite(b[0][0]) ? b[0][0] : 0;
18325
+ const by = Number.isFinite(b[0][1]) ? b[0][1] : 0;
18326
+ const bw = Number.isFinite(b[1][0]) ? b[1][0] - bx : 0;
18327
+ const bh = Number.isFinite(b[1][1]) ? b[1][1] - by : 0;
18328
+ const bounds = { x: bx, y: by, width: bw, height: bh };
18329
+ const rawCentroid = pathGen.centroid(feat);
18330
+ const cx = Number.isFinite(rawCentroid[0]) ? rawCentroid[0] : bx + bw / 2;
18331
+ const cy = Number.isFinite(rawCentroid[1]) ? rawCentroid[1] : by + bh / 2;
18332
+ marks.push({
18333
+ type: "map-feature",
18334
+ path: pathD,
18335
+ fill: neutralFill,
18336
+ stroke: neutralStroke,
18337
+ strokeWidth: 0.5,
18338
+ id: featureId,
18339
+ name,
18340
+ data: null,
18341
+ aria: { role: "img", label: name ?? String(featureId) },
18342
+ animationIndex: animIndex++,
18343
+ bounds,
18344
+ centroid: [cx, cy]
18345
+ });
18346
+ }
18347
+ return marks;
18348
+ }
18003
18349
  function buildQuantitativeMarks(opts) {
18004
18350
  const {
18005
18351
  geoFeatures,
@@ -18256,6 +18602,9 @@ function emptyLayout3(chrome, theme, options, watermark) {
18256
18602
  },
18257
18603
  continuousLegend: null,
18258
18604
  categoricalLegend: null,
18605
+ pointMarks: [],
18606
+ pointCategoricalLegend: null,
18607
+ pointContinuousLegend: null,
18259
18608
  tooltipDescriptors: /* @__PURE__ */ new Map(),
18260
18609
  a11y: {
18261
18610
  altText: "Empty map",