@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 +2 -1
- package/dist/index.js +428 -36
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compile/size-scale.ts +2 -0
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/validate.ts +87 -11
- package/src/geo/__tests__/compile-map.test.ts +211 -0
- package/src/geo/compile-map.ts +453 -29
- package/src/geo/types.ts +2 -0
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
|
-
|
|
11806
|
-
|
|
11807
|
-
|
|
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:
|
|
11810
|
-
path:
|
|
11815
|
+
message: 'Spec error: map encoding requires "key" channel',
|
|
11816
|
+
path: "encoding.key",
|
|
11811
11817
|
code: "MISSING_FIELD",
|
|
11812
|
-
suggestion:
|
|
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
|
-
|
|
11828
|
+
}
|
|
11829
|
+
const colorCh = encoding.color;
|
|
11830
|
+
if (!hasPoints) {
|
|
11831
|
+
if (!colorCh || typeof colorCh !== "object") {
|
|
11817
11832
|
errors.push({
|
|
11818
|
-
message:
|
|
11819
|
-
path:
|
|
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:
|
|
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
|
|
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
|
-
|
|
17823
|
-
|
|
17824
|
-
|
|
17825
|
-
|
|
17826
|
-
|
|
17827
|
-
|
|
17828
|
-
|
|
17829
|
-
|
|
17830
|
-
|
|
17831
|
-
|
|
17832
|
-
|
|
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 (
|
|
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:
|
|
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:
|
|
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
|
|
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],
|
|
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",
|