@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.
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,91 @@ 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 specData = spec.data;
11810
+ const isBasemapOnly = hasPoints && (!specData || specData.length === 0);
11811
+ const keyCh = encoding.key;
11812
+ if (!isBasemapOnly) {
11813
+ if (!keyCh || typeof keyCh !== "object") {
11808
11814
  errors.push({
11809
- message: `Spec error: map encoding requires "${channel}" channel`,
11810
- path: `encoding.${channel}`,
11815
+ message: 'Spec error: map encoding requires "key" channel',
11816
+ path: "encoding.key",
11811
11817
  code: "MISSING_FIELD",
11812
- suggestion: `Add encoding.${channel}, e.g. ${channel}: { field: "${channel === "key" ? "id" : "value"}", type: "${channel === "key" ? "nominal" : "quantitative"}" }`
11818
+ suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }'
11819
+ });
11820
+ } else if (!keyCh.field || typeof keyCh.field !== "string") {
11821
+ errors.push({
11822
+ message: 'Spec error: encoding.key must have a "field" string',
11823
+ path: "encoding.key.field",
11824
+ code: "MISSING_FIELD",
11825
+ suggestion: 'Add a field name, e.g. key: { field: "id" }'
11813
11826
  });
11814
- continue;
11815
11827
  }
11816
- if (!ch.field || typeof ch.field !== "string") {
11828
+ }
11829
+ const colorCh = encoding.color;
11830
+ if (!hasPoints) {
11831
+ if (!colorCh || typeof colorCh !== "object") {
11817
11832
  errors.push({
11818
- message: `Spec error: encoding.${channel} must have a "field" string`,
11819
- path: `encoding.${channel}.field`,
11833
+ message: 'Spec error: map encoding requires "color" channel (or add a "points" layer for basemap-only mode)',
11834
+ path: "encoding.color",
11835
+ code: "MISSING_FIELD",
11836
+ suggestion: 'Add encoding.color, e.g. color: { field: "value", type: "quantitative" }'
11837
+ });
11838
+ } else if (!colorCh.field || typeof colorCh.field !== "string") {
11839
+ errors.push({
11840
+ message: 'Spec error: encoding.color must have a "field" string',
11841
+ path: "encoding.color.field",
11820
11842
  code: "MISSING_FIELD",
11821
- suggestion: `Add a field name, e.g. ${channel}: { field: "${channel === "key" ? "id" : "value"}" }`
11843
+ suggestion: 'Add a field name, e.g. color: { field: "value" }'
11822
11844
  });
11823
11845
  }
11846
+ } else if (colorCh && typeof colorCh === "object" && (!colorCh.field || typeof colorCh.field !== "string")) {
11847
+ errors.push({
11848
+ message: 'Spec error: encoding.color must have a "field" string when provided',
11849
+ path: "encoding.color.field",
11850
+ code: "MISSING_FIELD",
11851
+ suggestion: 'Add a field name, e.g. color: { field: "value" }'
11852
+ });
11853
+ }
11854
+ if (hasPoints) {
11855
+ const points = spec.points;
11856
+ if (!Array.isArray(points.data)) {
11857
+ errors.push({
11858
+ message: "Spec error: points.data must be an array of data rows",
11859
+ path: "points.data",
11860
+ code: "INVALID_TYPE",
11861
+ suggestion: "Provide data as an array of objects with lat/lng fields"
11862
+ });
11863
+ }
11864
+ for (const ch of ["longitude", "latitude"]) {
11865
+ const enc = points[ch];
11866
+ if (!enc || typeof enc !== "object") {
11867
+ errors.push({
11868
+ message: `Spec error: points requires "${ch}" encoding channel`,
11869
+ path: `points.${ch}`,
11870
+ code: "MISSING_FIELD",
11871
+ suggestion: `Add points.${ch}, e.g. ${ch}: { field: "${ch === "longitude" ? "lon" : "lat"}", type: "quantitative" }`
11872
+ });
11873
+ } else if (!enc.field || typeof enc.field !== "string") {
11874
+ errors.push({
11875
+ message: `Spec error: points.${ch} must have a "field" string`,
11876
+ path: `points.${ch}.field`,
11877
+ code: "MISSING_FIELD",
11878
+ suggestion: `Add a field name, e.g. ${ch}: { field: "${ch === "longitude" ? "lon" : "lat"}" }`
11879
+ });
11880
+ }
11881
+ }
11882
+ for (const ch of ["size", "color", "key"]) {
11883
+ const enc = points[ch];
11884
+ if (enc && typeof enc === "object" && (!enc.field || typeof enc.field !== "string")) {
11885
+ errors.push({
11886
+ message: `Spec error: points.${ch} must have a "field" string when provided`,
11887
+ path: `points.${ch}.field`,
11888
+ code: "MISSING_FIELD",
11889
+ suggestion: `Add a field name, e.g. ${ch}: { field: "myField" }`
11890
+ });
11891
+ }
11892
+ }
11824
11893
  }
11825
11894
  }
11826
11895
  function validateBarListSpec(spec, errors) {
@@ -17767,11 +17836,11 @@ function compileMap(spec, options) {
17767
17836
  const compileWarnings = [];
17768
17837
  const showLegend = mapSpec.legend?.show !== false;
17769
17838
  const colorEncoding = mapSpec.encoding.color;
17770
- const isQuantitative = colorEncoding.type === "quantitative";
17839
+ const isQuantitative = colorEncoding?.type === "quantitative";
17771
17840
  const legendPosition = mapSpec.legend?.position === "bottom" ? "bottom" : "top";
17772
17841
  let legendBlockHeight = 0;
17773
17842
  let continuousContent = null;
17774
- if (showLegend && isQuantitative) {
17843
+ if (showLegend && colorEncoding && isQuantitative) {
17775
17844
  const colorValues = [];
17776
17845
  for (const row of mapSpec.data) {
17777
17846
  const v = row[colorEncoding.field];
@@ -17794,10 +17863,19 @@ function compileMap(spec, options) {
17794
17863
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17795
17864
  legendBlockHeight = continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
17796
17865
  }
17797
- } else if (showLegend) {
17866
+ } else if (showLegend && colorEncoding) {
17798
17867
  const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17799
17868
  legendBlockHeight = Math.max(10, labelHeight) + 6;
17800
17869
  }
17870
+ if (showLegend && mapSpec.points?.color) {
17871
+ if (mapSpec.points.color.type === "quantitative") {
17872
+ const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17873
+ legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
17874
+ } else {
17875
+ const ptLabelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17876
+ legendBlockHeight += Math.max(10, ptLabelHeight) + 6 + 8;
17877
+ }
17878
+ }
17801
17879
  const legendReserveGap = legendBlockHeight > 0 ? 8 : 0;
17802
17880
  const mapAreaHeight = fullArea.height - legendBlockHeight - legendReserveGap;
17803
17881
  if (mapAreaHeight <= 0) {
@@ -17819,17 +17897,22 @@ function compileMap(spec, options) {
17819
17897
  }
17820
17898
  }
17821
17899
  }
17822
- const featureObjs = geoFeatures.map((f) => ({
17823
- id: f.id,
17824
- properties: f.properties ?? {}
17825
- }));
17826
- const { joined, warnings: joinWarnings } = joinDataToFeatures(
17827
- featureObjs,
17828
- mapSpec.data,
17829
- mapSpec.encoding.key.field,
17830
- mapSpec.geo.idField
17831
- );
17832
- compileWarnings.push(...joinWarnings);
17900
+ let joined = /* @__PURE__ */ new Map();
17901
+ if (mapSpec.encoding.key) {
17902
+ const featureObjs = geoFeatures.map((f) => ({
17903
+ id: f.id,
17904
+ properties: f.properties ?? {}
17905
+ }));
17906
+ const joinResult = joinDataToFeatures(
17907
+ featureObjs,
17908
+ mapSpec.data,
17909
+ mapSpec.encoding.key.field,
17910
+ mapSpec.geo.idField
17911
+ );
17912
+ joined = joinResult.joined;
17913
+ const filteredJoinWarnings = mapSpec.data.length === 0 && mapSpec.points ? joinResult.warnings.filter((w) => w.code !== "UNMATCHED_FEATURES") : joinResult.warnings;
17914
+ compileWarnings.push(...filteredJoinWarnings);
17915
+ }
17833
17916
  const droppedFeatures = [];
17834
17917
  const formatter = buildD3Formatter2(mapSpec.valueFormat) ?? formatNumber3;
17835
17918
  const neutralFill = isDarkMode ? "#2a2a2a" : "#e8e8e8";
@@ -17837,13 +17920,21 @@ function compileMap(spec, options) {
17837
17920
  let featureMarks;
17838
17921
  let continuousLegend = null;
17839
17922
  let categoricalLegend = null;
17840
- if (isQuantitative) {
17923
+ if (!colorEncoding) {
17924
+ featureMarks = buildBasemapMarks({
17925
+ geoFeatures,
17926
+ pathGen,
17927
+ neutralFill,
17928
+ neutralStroke,
17929
+ droppedFeatures
17930
+ });
17931
+ } else if (isQuantitative) {
17841
17932
  const result = buildQuantitativeMarks({
17842
17933
  geoFeatures,
17843
17934
  pathGen,
17844
17935
  joined,
17845
17936
  colorField: colorEncoding.field,
17846
- palette: mapSpec.encoding.color.scale?.scheme ?? "blue",
17937
+ palette: colorEncoding.scale?.scheme ?? "blue",
17847
17938
  isDarkMode: !!isDarkMode,
17848
17939
  neutralFill,
17849
17940
  neutralStroke,
@@ -17893,6 +17984,7 @@ function compileMap(spec, options) {
17893
17984
  pathGen,
17894
17985
  joined,
17895
17986
  colorField: colorEncoding.field,
17987
+ colorScale: colorEncoding.scale,
17896
17988
  isDarkMode: !!isDarkMode,
17897
17989
  neutralFill,
17898
17990
  neutralStroke,
@@ -17928,6 +18020,201 @@ function compileMap(spec, options) {
17928
18020
  const interiorStroke = isDarkMode ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.15)";
17929
18021
  const outlineStroke = isDarkMode ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.3)";
17930
18022
  const borders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
18023
+ const pointMarks = [];
18024
+ let pointCategoricalLegend = null;
18025
+ let pointContinuousLegend = null;
18026
+ if (mapSpec.points) {
18027
+ const pts = mapSpec.points;
18028
+ const lonField = pts.longitude.field;
18029
+ const latField = pts.latitude.field;
18030
+ const keyChannel = pts.key;
18031
+ const sizeScale = buildSizeScale(pts.size, pts.data, SIZE_SCALE_DEFAULTS.mapPoint);
18032
+ let pointColorScale = null;
18033
+ const pointCategories = [];
18034
+ const pointCategoryColors = /* @__PURE__ */ new Map();
18035
+ if (pts.color) {
18036
+ if (pts.color.type === "quantitative") {
18037
+ const colorValues = [];
18038
+ for (const row of pts.data) {
18039
+ const raw = row[pts.color.field];
18040
+ if (raw != null) {
18041
+ const v = Number(raw);
18042
+ if (Number.isFinite(v)) colorValues.push(v);
18043
+ }
18044
+ }
18045
+ if (colorValues.length > 0) {
18046
+ const palette = pts.color.scale?.scheme ?? "blue";
18047
+ const paletteStops = [...SEQUENTIAL_PALETTES[palette] ?? SEQUENTIAL_PALETTES.blue];
18048
+ const qScale = quantile2().domain(colorValues).range(paletteStops);
18049
+ pointColorScale = (val) => {
18050
+ const v = Number(val);
18051
+ return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
18052
+ };
18053
+ }
18054
+ } else {
18055
+ const explicitDomain = pts.color.scale?.domain;
18056
+ const explicitRange = pts.color.scale?.range;
18057
+ if (explicitDomain && Array.isArray(explicitDomain)) {
18058
+ for (const cat of explicitDomain) {
18059
+ pointCategories.push(String(cat));
18060
+ }
18061
+ }
18062
+ const seen = new Set(pointCategories);
18063
+ for (const row of pts.data) {
18064
+ const raw = row[pts.color.field];
18065
+ if (raw != null) {
18066
+ const cat = String(raw);
18067
+ if (!seen.has(cat)) {
18068
+ seen.add(cat);
18069
+ pointCategories.push(cat);
18070
+ }
18071
+ }
18072
+ }
18073
+ const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
18074
+ for (let i = 0; i < pointCategories.length; i++) {
18075
+ pointCategoryColors.set(pointCategories[i], colorSource[i % colorSource.length]);
18076
+ }
18077
+ pointColorScale = (val) => {
18078
+ if (val == null) return colorSource[0];
18079
+ return pointCategoryColors.get(String(val)) ?? colorSource[0];
18080
+ };
18081
+ }
18082
+ }
18083
+ const defaultFill = CATEGORICAL_PALETTE[0];
18084
+ const pointOpacity = pts.opacity ?? 0.65;
18085
+ let animIndex = 0;
18086
+ const droppedPoints = [];
18087
+ for (let i = 0; i < pts.data.length; i++) {
18088
+ const row = pts.data[i];
18089
+ const lon = Number(row[lonField]);
18090
+ const lat = Number(row[latField]);
18091
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
18092
+ const projected = projection2([lon, lat]);
18093
+ if (!projected) {
18094
+ droppedPoints.push([lon, lat]);
18095
+ continue;
18096
+ }
18097
+ const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
18098
+ const fill = pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
18099
+ const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
18100
+ const stroke = isDarkMode ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.8)";
18101
+ pointMarks.push({
18102
+ type: "map-point",
18103
+ cx: projected[0],
18104
+ cy: projected[1],
18105
+ r,
18106
+ fill,
18107
+ stroke,
18108
+ strokeWidth: 1,
18109
+ fillOpacity: pointOpacity,
18110
+ key,
18111
+ data: row,
18112
+ aria: { role: "img", label: key },
18113
+ animationIndex: animIndex++
18114
+ });
18115
+ }
18116
+ if (droppedPoints.length > 0) {
18117
+ const sample = droppedPoints.slice(0, 3).map(([lo, la]) => `[${lo}, ${la}]`).join(", ");
18118
+ compileWarnings.push({
18119
+ code: "POINT_NULL_PROJECTION",
18120
+ message: `${droppedPoints.length} point(s) project to null and were dropped (e.g. ${sample}). This is common with albersUsa which excludes territories. Consider using projection: "mercator" or "equalEarth".`,
18121
+ context: { count: droppedPoints.length, samples: droppedPoints.slice(0, 5) }
18122
+ });
18123
+ }
18124
+ if (pts.color && pts.color.type !== "quantitative" && pointCategories.length > 0 && showLegend) {
18125
+ const labelStyle = {
18126
+ fontFamily: theme.fonts.family,
18127
+ fontSize: theme.fonts.sizes.small,
18128
+ fontWeight: theme.fonts.weights.normal,
18129
+ fill: theme.colors.text,
18130
+ lineHeight: 1.2
18131
+ };
18132
+ const entries = pointCategories.map((cat) => ({
18133
+ label: cat,
18134
+ color: pointCategoryColors.get(cat),
18135
+ shape: "circle"
18136
+ }));
18137
+ const bottomChoroplethHeight = legendPosition === "bottom" ? continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0 : 0;
18138
+ const choroplethLegendGap = bottomChoroplethHeight > 0 ? 8 : 0;
18139
+ const legendX = fullArea.x;
18140
+ const legendY = fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethHeight + choroplethLegendGap;
18141
+ const legendWidth = fullArea.width;
18142
+ const swatchSize = 10;
18143
+ const swatchGap = 6;
18144
+ const entryGap = 16;
18145
+ pointCategoricalLegend = {
18146
+ type: "categorical",
18147
+ position: "bottom",
18148
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
18149
+ labelStyle,
18150
+ entries,
18151
+ swatchSize,
18152
+ swatchGap,
18153
+ entryGap,
18154
+ swatchChipFill: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)"
18155
+ };
18156
+ }
18157
+ if (pts.color && pts.color.type === "quantitative" && showLegend) {
18158
+ const ptColorValues = [];
18159
+ for (const row of pts.data) {
18160
+ const v = row[pts.color.field];
18161
+ if (v != null) {
18162
+ const n = Number(v);
18163
+ if (Number.isFinite(n)) ptColorValues.push(n);
18164
+ }
18165
+ }
18166
+ const ptLegendChannel = {
18167
+ ...pts.color,
18168
+ scale: { ...pts.color.scale, type: "quantile" }
18169
+ };
18170
+ const ptContinuousContent = computeContinuousLegendContentForChannel(
18171
+ ptColorValues,
18172
+ ptLegendChannel,
18173
+ theme,
18174
+ fullArea.width
18175
+ );
18176
+ if (ptContinuousContent) {
18177
+ const labelStyle = {
18178
+ fontFamily: theme.fonts.family,
18179
+ fontSize: theme.fonts.sizes.small,
18180
+ fontWeight: theme.fonts.weights.normal,
18181
+ fill: theme.colors.text,
18182
+ lineHeight: 1.3,
18183
+ fontVariant: "tabular-nums"
18184
+ };
18185
+ const bottomChoroplethH = legendPosition === "bottom" ? continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0 : 0;
18186
+ const choroplethLegendGap = bottomChoroplethH > 0 ? 8 : 0;
18187
+ const legendX = fullArea.x;
18188
+ const legendY = fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
18189
+ const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
18190
+ const ptLegendHeight = ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
18191
+ const bounds = {
18192
+ x: legendX,
18193
+ y: legendY,
18194
+ width: ptContinuousContent.barWidth,
18195
+ height: ptLegendHeight
18196
+ };
18197
+ const bar = {
18198
+ x: bounds.x,
18199
+ y: bounds.y,
18200
+ width: ptContinuousContent.barWidth,
18201
+ height: ptContinuousContent.barHeight
18202
+ };
18203
+ pointContinuousLegend = {
18204
+ type: "continuous",
18205
+ mode: ptContinuousContent.mode,
18206
+ position: "bottom",
18207
+ bounds,
18208
+ labelStyle,
18209
+ bar,
18210
+ colorStops: ptContinuousContent.colorStops,
18211
+ bins: ptContinuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
18212
+ ticks: ptContinuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
18213
+ labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize
18214
+ };
18215
+ }
18216
+ }
18217
+ }
17931
18218
  const tooltipChannels = mapSpec.encoding.tooltip ? Array.isArray(mapSpec.encoding.tooltip) ? mapSpec.encoding.tooltip : [mapSpec.encoding.tooltip] : null;
17932
18219
  const tooltipDescriptors = /* @__PURE__ */ new Map();
17933
18220
  for (const mark of featureMarks) {
@@ -17945,12 +18232,12 @@ function compileMap(spec, options) {
17945
18232
  });
17946
18233
  }
17947
18234
  }
17948
- } else if (isQuantitative && mark.data[colorEncoding.field] != null) {
18235
+ } else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
17949
18236
  fields.push({
17950
18237
  label: colorEncoding.title ?? colorEncoding.field,
17951
18238
  value: formatter(Number(mark.data[colorEncoding.field]))
17952
18239
  });
17953
- } else if (!isQuantitative && mark.data[colorEncoding.field] != null) {
18240
+ } else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
17954
18241
  fields.push({
17955
18242
  label: colorEncoding.title ?? colorEncoding.field,
17956
18243
  value: String(mark.data[colorEncoding.field])
@@ -17958,11 +18245,61 @@ function compileMap(spec, options) {
17958
18245
  }
17959
18246
  tooltipDescriptors.set(String(mark.id), { fields });
17960
18247
  }
18248
+ if (mapSpec.points && pointMarks.length > 0) {
18249
+ const ptTooltipChannels = mapSpec.points.tooltip ? Array.isArray(mapSpec.points.tooltip) ? mapSpec.points.tooltip : [mapSpec.points.tooltip] : null;
18250
+ for (const pm of pointMarks) {
18251
+ const fields = [];
18252
+ if (ptTooltipChannels) {
18253
+ for (const ch of ptTooltipChannels) {
18254
+ const val = pm.data[ch.field];
18255
+ if (val != null) {
18256
+ fields.push({
18257
+ label: ch.title ?? ch.field,
18258
+ value: ch.type === "quantitative" ? formatter(Number(val)) : String(val)
18259
+ });
18260
+ }
18261
+ }
18262
+ } else {
18263
+ const pts = mapSpec.points;
18264
+ if (pts.key) {
18265
+ const val = pm.data[pts.key.field];
18266
+ if (val != null)
18267
+ fields.push({ label: pts.key.title ?? pts.key.field, value: String(val) });
18268
+ }
18269
+ if (pts.color) {
18270
+ const val = pm.data[pts.color.field];
18271
+ if (val != null)
18272
+ fields.push({
18273
+ label: pts.color.title ?? pts.color.field,
18274
+ value: pts.color.type === "quantitative" ? formatter(Number(val)) : String(val)
18275
+ });
18276
+ }
18277
+ if (pts.size) {
18278
+ const val = pm.data[pts.size.field];
18279
+ if (val != null)
18280
+ fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
18281
+ }
18282
+ }
18283
+ if (fields.length > 0) {
18284
+ tooltipDescriptors.set(`point:${pm.key}`, { fields });
18285
+ }
18286
+ }
18287
+ }
18288
+ const a11yDataField = colorEncoding?.field;
17961
18289
  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] ?? "")]),
18290
+ 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`,
18291
+ dataTableFallback: [
18292
+ ...featureMarks.filter((m) => m.data).map((m) => [
18293
+ m.name ?? String(m.id),
18294
+ a11yDataField ? String(m.data?.[a11yDataField] ?? "") : ""
18295
+ ]),
18296
+ ...pointMarks.map((pm) => [
18297
+ pm.key,
18298
+ mapSpec.points?.color ? String(pm.data[mapSpec.points.color.field] ?? "") : ""
18299
+ ])
18300
+ ],
17964
18301
  role: "img",
17965
- keyboardNavigable: featureMarks.length > 0
18302
+ keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0
17966
18303
  };
17967
18304
  const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, compileWarnings);
17968
18305
  const resolvedAnimation = resolveAnimation(mapSpec.animation);
@@ -17987,6 +18324,9 @@ function compileMap(spec, options) {
17987
18324
  borders,
17988
18325
  continuousLegend,
17989
18326
  categoricalLegend,
18327
+ pointMarks,
18328
+ pointCategoricalLegend,
18329
+ pointContinuousLegend,
17990
18330
  tooltipDescriptors,
17991
18331
  a11y,
17992
18332
  theme,
@@ -18000,6 +18340,46 @@ function compileMap(spec, options) {
18000
18340
  focus: resolvedFocus
18001
18341
  };
18002
18342
  }
18343
+ function buildBasemapMarks(opts) {
18344
+ const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
18345
+ const marks = [];
18346
+ let animIndex = 0;
18347
+ for (const feat of geoFeatures) {
18348
+ const pathD = pathGen(feat);
18349
+ if (!pathD) {
18350
+ const name2 = feat.properties?.name ?? String(feat.id);
18351
+ droppedFeatures.push(name2);
18352
+ continue;
18353
+ }
18354
+ const featureId = feat.id;
18355
+ const props = feat.properties;
18356
+ const name = props?.name ?? props?.NAME;
18357
+ const b = pathGen.bounds(feat);
18358
+ const bx = Number.isFinite(b[0][0]) ? b[0][0] : 0;
18359
+ const by = Number.isFinite(b[0][1]) ? b[0][1] : 0;
18360
+ const bw = Number.isFinite(b[1][0]) ? b[1][0] - bx : 0;
18361
+ const bh = Number.isFinite(b[1][1]) ? b[1][1] - by : 0;
18362
+ const bounds = { x: bx, y: by, width: bw, height: bh };
18363
+ const rawCentroid = pathGen.centroid(feat);
18364
+ const cx = Number.isFinite(rawCentroid[0]) ? rawCentroid[0] : bx + bw / 2;
18365
+ const cy = Number.isFinite(rawCentroid[1]) ? rawCentroid[1] : by + bh / 2;
18366
+ marks.push({
18367
+ type: "map-feature",
18368
+ path: pathD,
18369
+ fill: neutralFill,
18370
+ stroke: neutralStroke,
18371
+ strokeWidth: 0.5,
18372
+ id: featureId,
18373
+ name,
18374
+ data: null,
18375
+ aria: { role: "img", label: name ?? String(featureId) },
18376
+ animationIndex: animIndex++,
18377
+ bounds,
18378
+ centroid: [cx, cy]
18379
+ });
18380
+ }
18381
+ return marks;
18382
+ }
18003
18383
  function buildQuantitativeMarks(opts) {
18004
18384
  const {
18005
18385
  geoFeatures,
@@ -18084,6 +18464,7 @@ function buildCategoricalMarks(opts) {
18084
18464
  pathGen,
18085
18465
  joined,
18086
18466
  colorField,
18467
+ colorScale: scaleConfig,
18087
18468
  isDarkMode,
18088
18469
  neutralFill,
18089
18470
  neutralStroke,
@@ -18095,7 +18476,13 @@ function buildCategoricalMarks(opts) {
18095
18476
  droppedFeatures
18096
18477
  } = opts;
18097
18478
  const categories = [];
18098
- const seen = /* @__PURE__ */ new Set();
18479
+ const explicitDomain = scaleConfig?.domain;
18480
+ if (explicitDomain && Array.isArray(explicitDomain)) {
18481
+ for (const cat of explicitDomain) {
18482
+ categories.push(String(cat));
18483
+ }
18484
+ }
18485
+ const seen = new Set(categories);
18099
18486
  for (const row of joined.values()) {
18100
18487
  const raw = row[colorField];
18101
18488
  if (raw != null) {
@@ -18106,9 +18493,11 @@ function buildCategoricalMarks(opts) {
18106
18493
  }
18107
18494
  }
18108
18495
  }
18496
+ const explicitRange = scaleConfig?.range;
18497
+ const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
18109
18498
  const categoryColors = /* @__PURE__ */ new Map();
18110
18499
  for (let i = 0; i < categories.length; i++) {
18111
- categoryColors.set(categories[i], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
18500
+ categoryColors.set(categories[i], colorSource[i % colorSource.length]);
18112
18501
  }
18113
18502
  const marks = [];
18114
18503
  let animIndex = 0;
@@ -18256,6 +18645,9 @@ function emptyLayout3(chrome, theme, options, watermark) {
18256
18645
  },
18257
18646
  continuousLegend: null,
18258
18647
  categoricalLegend: null,
18648
+ pointMarks: [],
18649
+ pointCategoricalLegend: null,
18650
+ pointContinuousLegend: null,
18259
18651
  tooltipDescriptors: /* @__PURE__ */ new Map(),
18260
18652
  a11y: {
18261
18653
  altText: "Empty map",