@opendata-ai/openchart-engine 8.0.0-rc.3 → 8.0.0-rc.5
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.js +145 -53
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compiler/validate.ts +62 -20
- package/src/geo/__tests__/compile-map.test.ts +123 -0
- package/src/geo/compile-map.ts +132 -51
package/dist/index.js
CHANGED
|
@@ -11758,12 +11758,31 @@ function validateMapSpec(spec, errors) {
|
|
|
11758
11758
|
}
|
|
11759
11759
|
} else if (typeof focus === "object") {
|
|
11760
11760
|
const focusObj = focus;
|
|
11761
|
-
if (
|
|
11761
|
+
if ("points" in focusObj) {
|
|
11762
|
+
const pts = focusObj.points;
|
|
11763
|
+
const isSubset = typeof pts === "object" && pts !== null && typeof pts.field === "string" && "value" in pts;
|
|
11764
|
+
if (pts !== true && !isSubset) {
|
|
11765
|
+
errors.push({
|
|
11766
|
+
message: "Spec error: geo.focus.points must be true or { field: string, value: string | number }",
|
|
11767
|
+
path: "geo.focus.points",
|
|
11768
|
+
code: "INVALID_TYPE",
|
|
11769
|
+
suggestion: 'Use { points: true } to fit all points, or { points: { field: "rating", value: "F" } } to fit a subset.'
|
|
11770
|
+
});
|
|
11771
|
+
}
|
|
11772
|
+
if (focusObj.padding !== void 0 && typeof focusObj.padding !== "number") {
|
|
11773
|
+
errors.push({
|
|
11774
|
+
message: "Spec error: geo.focus.padding must be a number",
|
|
11775
|
+
path: "geo.focus.padding",
|
|
11776
|
+
code: "INVALID_TYPE",
|
|
11777
|
+
suggestion: "Provide padding as a number in map-local units, e.g. padding: 16"
|
|
11778
|
+
});
|
|
11779
|
+
}
|
|
11780
|
+
} else if (!("features" in focusObj) || focusObj.features == null) {
|
|
11762
11781
|
errors.push({
|
|
11763
|
-
message: 'Spec error: geo.focus object must have a "features" property',
|
|
11782
|
+
message: 'Spec error: geo.focus object must have a "features" or "points" property',
|
|
11764
11783
|
path: "geo.focus.features",
|
|
11765
11784
|
code: "MISSING_FIELD",
|
|
11766
|
-
suggestion: 'Provide feature id(s) to focus on, e.g.
|
|
11785
|
+
suggestion: 'Provide feature id(s) to focus on, e.g. { features: "36", padding: 32 }, or { points: true } to fit the point cluster.'
|
|
11767
11786
|
});
|
|
11768
11787
|
} else {
|
|
11769
11788
|
const feats = focusObj.features;
|
|
@@ -11797,30 +11816,34 @@ function validateMapSpec(spec, errors) {
|
|
|
11797
11816
|
}
|
|
11798
11817
|
} else {
|
|
11799
11818
|
errors.push({
|
|
11800
|
-
message: "Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? }",
|
|
11819
|
+
message: "Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? } or { points: true, padding? }",
|
|
11801
11820
|
path: "geo.focus",
|
|
11802
11821
|
code: "INVALID_TYPE",
|
|
11803
|
-
suggestion: 'Use a feature id, array of ids,
|
|
11822
|
+
suggestion: 'Use a feature id, array of ids, { features: "36", padding: 16 }, or { points: true }. Pass null to clear focus.'
|
|
11804
11823
|
});
|
|
11805
11824
|
}
|
|
11806
11825
|
}
|
|
11807
11826
|
const encoding = spec.encoding;
|
|
11808
11827
|
const hasPoints = spec.points && typeof spec.points === "object" && !Array.isArray(spec.points);
|
|
11828
|
+
const specData = spec.data;
|
|
11829
|
+
const isBasemapOnly = hasPoints && (!specData || specData.length === 0);
|
|
11809
11830
|
const keyCh = encoding.key;
|
|
11810
|
-
if (!
|
|
11811
|
-
|
|
11812
|
-
|
|
11813
|
-
|
|
11814
|
-
|
|
11815
|
-
|
|
11816
|
-
|
|
11817
|
-
|
|
11818
|
-
|
|
11819
|
-
|
|
11820
|
-
|
|
11821
|
-
|
|
11822
|
-
|
|
11823
|
-
|
|
11831
|
+
if (!isBasemapOnly) {
|
|
11832
|
+
if (!keyCh || typeof keyCh !== "object") {
|
|
11833
|
+
errors.push({
|
|
11834
|
+
message: 'Spec error: map encoding requires "key" channel',
|
|
11835
|
+
path: "encoding.key",
|
|
11836
|
+
code: "MISSING_FIELD",
|
|
11837
|
+
suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }'
|
|
11838
|
+
});
|
|
11839
|
+
} else if (!keyCh.field || typeof keyCh.field !== "string") {
|
|
11840
|
+
errors.push({
|
|
11841
|
+
message: 'Spec error: encoding.key must have a "field" string',
|
|
11842
|
+
path: "encoding.key.field",
|
|
11843
|
+
code: "MISSING_FIELD",
|
|
11844
|
+
suggestion: 'Add a field name, e.g. key: { field: "id" }'
|
|
11845
|
+
});
|
|
11846
|
+
}
|
|
11824
11847
|
}
|
|
11825
11848
|
const colorCh = encoding.color;
|
|
11826
11849
|
if (!hasPoints) {
|
|
@@ -11875,6 +11898,17 @@ function validateMapSpec(spec, errors) {
|
|
|
11875
11898
|
});
|
|
11876
11899
|
}
|
|
11877
11900
|
}
|
|
11901
|
+
for (const ch of ["size", "color", "key"]) {
|
|
11902
|
+
const enc = points[ch];
|
|
11903
|
+
if (enc && typeof enc === "object" && (!enc.field || typeof enc.field !== "string")) {
|
|
11904
|
+
errors.push({
|
|
11905
|
+
message: `Spec error: points.${ch} must have a "field" string when provided`,
|
|
11906
|
+
path: `points.${ch}.field`,
|
|
11907
|
+
code: "MISSING_FIELD",
|
|
11908
|
+
suggestion: `Add a field name, e.g. ${ch}: { field: "myField" }`
|
|
11909
|
+
});
|
|
11910
|
+
}
|
|
11911
|
+
}
|
|
11878
11912
|
}
|
|
11879
11913
|
}
|
|
11880
11914
|
function validateBarListSpec(spec, errors) {
|
|
@@ -17882,18 +17916,22 @@ function compileMap(spec, options) {
|
|
|
17882
17916
|
}
|
|
17883
17917
|
}
|
|
17884
17918
|
}
|
|
17885
|
-
|
|
17886
|
-
|
|
17887
|
-
|
|
17888
|
-
|
|
17889
|
-
|
|
17890
|
-
|
|
17891
|
-
|
|
17892
|
-
|
|
17893
|
-
|
|
17894
|
-
|
|
17895
|
-
|
|
17896
|
-
|
|
17919
|
+
let joined = /* @__PURE__ */ new Map();
|
|
17920
|
+
if (mapSpec.encoding.key) {
|
|
17921
|
+
const featureObjs = geoFeatures.map((f) => ({
|
|
17922
|
+
id: f.id,
|
|
17923
|
+
properties: f.properties ?? {}
|
|
17924
|
+
}));
|
|
17925
|
+
const joinResult = joinDataToFeatures(
|
|
17926
|
+
featureObjs,
|
|
17927
|
+
mapSpec.data,
|
|
17928
|
+
mapSpec.encoding.key.field,
|
|
17929
|
+
mapSpec.geo.idField
|
|
17930
|
+
);
|
|
17931
|
+
joined = joinResult.joined;
|
|
17932
|
+
const filteredJoinWarnings = mapSpec.data.length === 0 && mapSpec.points ? joinResult.warnings.filter((w) => w.code !== "UNMATCHED_FEATURES") : joinResult.warnings;
|
|
17933
|
+
compileWarnings.push(...filteredJoinWarnings);
|
|
17934
|
+
}
|
|
17897
17935
|
const droppedFeatures = [];
|
|
17898
17936
|
const formatter = buildD3Formatter2(mapSpec.valueFormat) ?? formatNumber3;
|
|
17899
17937
|
const neutralFill = isDarkMode ? "#2a2a2a" : "#e8e8e8";
|
|
@@ -17965,6 +18003,7 @@ function compileMap(spec, options) {
|
|
|
17965
18003
|
pathGen,
|
|
17966
18004
|
joined,
|
|
17967
18005
|
colorField: colorEncoding.field,
|
|
18006
|
+
colorScale: colorEncoding.scale,
|
|
17968
18007
|
isDarkMode: !!isDarkMode,
|
|
17969
18008
|
neutralFill,
|
|
17970
18009
|
neutralStroke,
|
|
@@ -18032,7 +18071,14 @@ function compileMap(spec, options) {
|
|
|
18032
18071
|
};
|
|
18033
18072
|
}
|
|
18034
18073
|
} else {
|
|
18035
|
-
const
|
|
18074
|
+
const explicitDomain = pts.color.scale?.domain;
|
|
18075
|
+
const explicitRange = pts.color.scale?.range;
|
|
18076
|
+
if (explicitDomain && Array.isArray(explicitDomain)) {
|
|
18077
|
+
for (const cat of explicitDomain) {
|
|
18078
|
+
pointCategories.push(String(cat));
|
|
18079
|
+
}
|
|
18080
|
+
}
|
|
18081
|
+
const seen = new Set(pointCategories);
|
|
18036
18082
|
for (const row of pts.data) {
|
|
18037
18083
|
const raw = row[pts.color.field];
|
|
18038
18084
|
if (raw != null) {
|
|
@@ -18040,21 +18086,23 @@ function compileMap(spec, options) {
|
|
|
18040
18086
|
if (!seen.has(cat)) {
|
|
18041
18087
|
seen.add(cat);
|
|
18042
18088
|
pointCategories.push(cat);
|
|
18043
|
-
pointCategoryColors.set(
|
|
18044
|
-
cat,
|
|
18045
|
-
CATEGORICAL_PALETTE[(pointCategories.length - 1) % CATEGORICAL_PALETTE.length]
|
|
18046
|
-
);
|
|
18047
18089
|
}
|
|
18048
18090
|
}
|
|
18049
18091
|
}
|
|
18092
|
+
const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
|
|
18093
|
+
for (let i = 0; i < pointCategories.length; i++) {
|
|
18094
|
+
pointCategoryColors.set(pointCategories[i], colorSource[i % colorSource.length]);
|
|
18095
|
+
}
|
|
18050
18096
|
pointColorScale = (val) => {
|
|
18051
|
-
if (val == null) return
|
|
18052
|
-
return pointCategoryColors.get(String(val)) ??
|
|
18097
|
+
if (val == null) return colorSource[0];
|
|
18098
|
+
return pointCategoryColors.get(String(val)) ?? colorSource[0];
|
|
18053
18099
|
};
|
|
18054
18100
|
}
|
|
18055
18101
|
}
|
|
18056
18102
|
const defaultFill = CATEGORICAL_PALETTE[0];
|
|
18103
|
+
const pointOpacity = pts.opacity ?? 0.65;
|
|
18057
18104
|
let animIndex = 0;
|
|
18105
|
+
const droppedPoints = [];
|
|
18058
18106
|
for (let i = 0; i < pts.data.length; i++) {
|
|
18059
18107
|
const row = pts.data[i];
|
|
18060
18108
|
const lon = Number(row[lonField]);
|
|
@@ -18062,11 +18110,7 @@ function compileMap(spec, options) {
|
|
|
18062
18110
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
|
18063
18111
|
const projected = projection2([lon, lat]);
|
|
18064
18112
|
if (!projected) {
|
|
18065
|
-
|
|
18066
|
-
code: "POINT_NULL_PROJECTION",
|
|
18067
|
-
message: `Point at [${lon}, ${lat}] projects to null and was dropped.`,
|
|
18068
|
-
context: { lon, lat }
|
|
18069
|
-
});
|
|
18113
|
+
droppedPoints.push([lon, lat]);
|
|
18070
18114
|
continue;
|
|
18071
18115
|
}
|
|
18072
18116
|
const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
|
|
@@ -18081,12 +18125,21 @@ function compileMap(spec, options) {
|
|
|
18081
18125
|
fill,
|
|
18082
18126
|
stroke,
|
|
18083
18127
|
strokeWidth: 1,
|
|
18128
|
+
fillOpacity: pointOpacity,
|
|
18084
18129
|
key,
|
|
18085
18130
|
data: row,
|
|
18086
18131
|
aria: { role: "img", label: key },
|
|
18087
18132
|
animationIndex: animIndex++
|
|
18088
18133
|
});
|
|
18089
18134
|
}
|
|
18135
|
+
if (droppedPoints.length > 0) {
|
|
18136
|
+
const sample = droppedPoints.slice(0, 3).map(([lo, la]) => `[${lo}, ${la}]`).join(", ");
|
|
18137
|
+
compileWarnings.push({
|
|
18138
|
+
code: "POINT_NULL_PROJECTION",
|
|
18139
|
+
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".`,
|
|
18140
|
+
context: { count: droppedPoints.length, samples: droppedPoints.slice(0, 5) }
|
|
18141
|
+
});
|
|
18142
|
+
}
|
|
18090
18143
|
if (pts.color && pts.color.type !== "quantitative" && pointCategories.length > 0 && showLegend) {
|
|
18091
18144
|
const labelStyle = {
|
|
18092
18145
|
fontFamily: theme.fonts.family,
|
|
@@ -18100,10 +18153,10 @@ function compileMap(spec, options) {
|
|
|
18100
18153
|
color: pointCategoryColors.get(cat),
|
|
18101
18154
|
shape: "circle"
|
|
18102
18155
|
}));
|
|
18103
|
-
const
|
|
18104
|
-
const choroplethLegendGap =
|
|
18156
|
+
const bottomChoroplethHeight = legendPosition === "bottom" ? continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0 : 0;
|
|
18157
|
+
const choroplethLegendGap = bottomChoroplethHeight > 0 ? 8 : 0;
|
|
18105
18158
|
const legendX = fullArea.x;
|
|
18106
|
-
const legendY = fullArea.y + mapAreaHeight + legendReserveGap +
|
|
18159
|
+
const legendY = fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethHeight + choroplethLegendGap;
|
|
18107
18160
|
const legendWidth = fullArea.width;
|
|
18108
18161
|
const swatchSize = 10;
|
|
18109
18162
|
const swatchGap = 6;
|
|
@@ -18148,10 +18201,10 @@ function compileMap(spec, options) {
|
|
|
18148
18201
|
lineHeight: 1.3,
|
|
18149
18202
|
fontVariant: "tabular-nums"
|
|
18150
18203
|
};
|
|
18151
|
-
const
|
|
18152
|
-
const choroplethLegendGap =
|
|
18204
|
+
const bottomChoroplethH = legendPosition === "bottom" ? continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0 : 0;
|
|
18205
|
+
const choroplethLegendGap = bottomChoroplethH > 0 ? 8 : 0;
|
|
18153
18206
|
const legendX = fullArea.x;
|
|
18154
|
-
const legendY = fullArea.y + mapAreaHeight + legendReserveGap +
|
|
18207
|
+
const legendY = fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
|
|
18155
18208
|
const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
|
|
18156
18209
|
const ptLegendHeight = ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
|
|
18157
18210
|
const bounds = {
|
|
@@ -18267,7 +18320,7 @@ function compileMap(spec, options) {
|
|
|
18267
18320
|
role: "img",
|
|
18268
18321
|
keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0
|
|
18269
18322
|
};
|
|
18270
|
-
const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, compileWarnings);
|
|
18323
|
+
const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, pointMarks, compileWarnings);
|
|
18271
18324
|
const resolvedAnimation = resolveAnimation(mapSpec.animation);
|
|
18272
18325
|
const mapAreaY = legendPosition === "top" && legendBlockHeight > 0 ? fullArea.y + legendBlockHeight + legendReserveGap : fullArea.y;
|
|
18273
18326
|
const mapArea = {
|
|
@@ -18430,6 +18483,7 @@ function buildCategoricalMarks(opts) {
|
|
|
18430
18483
|
pathGen,
|
|
18431
18484
|
joined,
|
|
18432
18485
|
colorField,
|
|
18486
|
+
colorScale: scaleConfig,
|
|
18433
18487
|
isDarkMode,
|
|
18434
18488
|
neutralFill,
|
|
18435
18489
|
neutralStroke,
|
|
@@ -18441,7 +18495,13 @@ function buildCategoricalMarks(opts) {
|
|
|
18441
18495
|
droppedFeatures
|
|
18442
18496
|
} = opts;
|
|
18443
18497
|
const categories = [];
|
|
18444
|
-
const
|
|
18498
|
+
const explicitDomain = scaleConfig?.domain;
|
|
18499
|
+
if (explicitDomain && Array.isArray(explicitDomain)) {
|
|
18500
|
+
for (const cat of explicitDomain) {
|
|
18501
|
+
categories.push(String(cat));
|
|
18502
|
+
}
|
|
18503
|
+
}
|
|
18504
|
+
const seen = new Set(categories);
|
|
18445
18505
|
for (const row of joined.values()) {
|
|
18446
18506
|
const raw = row[colorField];
|
|
18447
18507
|
if (raw != null) {
|
|
@@ -18452,9 +18512,11 @@ function buildCategoricalMarks(opts) {
|
|
|
18452
18512
|
}
|
|
18453
18513
|
}
|
|
18454
18514
|
}
|
|
18515
|
+
const explicitRange = scaleConfig?.range;
|
|
18516
|
+
const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
|
|
18455
18517
|
const categoryColors = /* @__PURE__ */ new Map();
|
|
18456
18518
|
for (let i = 0; i < categories.length; i++) {
|
|
18457
|
-
categoryColors.set(categories[i],
|
|
18519
|
+
categoryColors.set(categories[i], colorSource[i % colorSource.length]);
|
|
18458
18520
|
}
|
|
18459
18521
|
const marks = [];
|
|
18460
18522
|
let animIndex = 0;
|
|
@@ -18541,8 +18603,38 @@ function buildCategoricalMarks(opts) {
|
|
|
18541
18603
|
}
|
|
18542
18604
|
return { marks, legend };
|
|
18543
18605
|
}
|
|
18544
|
-
function resolveFocus(focus, features, warnings) {
|
|
18606
|
+
function resolveFocus(focus, features, pointMarks, warnings) {
|
|
18545
18607
|
if (focus === null || focus === void 0) return null;
|
|
18608
|
+
if (typeof focus === "object" && !Array.isArray(focus) && "points" in focus) {
|
|
18609
|
+
const filter = focus.points;
|
|
18610
|
+
const matched = filter === true ? pointMarks : pointMarks.filter((p) => p.data[filter.field] === filter.value);
|
|
18611
|
+
if (matched.length === 0) {
|
|
18612
|
+
const detail = filter === true ? "the map has no points to fit" : `no points match { field: "${filter.field}", value: ${JSON.stringify(filter.value)} }`;
|
|
18613
|
+
warnings.push({
|
|
18614
|
+
code: "FOCUS_UNMATCHED",
|
|
18615
|
+
message: `geo.focus.points is set but ${detail}`,
|
|
18616
|
+
context: {}
|
|
18617
|
+
});
|
|
18618
|
+
return null;
|
|
18619
|
+
}
|
|
18620
|
+
const padding2 = focus.padding ?? 16;
|
|
18621
|
+
let minX2 = Infinity;
|
|
18622
|
+
let minY2 = Infinity;
|
|
18623
|
+
let maxX2 = -Infinity;
|
|
18624
|
+
let maxY2 = -Infinity;
|
|
18625
|
+
for (const p of matched) {
|
|
18626
|
+
minX2 = Math.min(minX2, p.cx - p.r);
|
|
18627
|
+
minY2 = Math.min(minY2, p.cy - p.r);
|
|
18628
|
+
maxX2 = Math.max(maxX2, p.cx + p.r);
|
|
18629
|
+
maxY2 = Math.max(maxY2, p.cy + p.r);
|
|
18630
|
+
}
|
|
18631
|
+
return {
|
|
18632
|
+
target: { x: minX2, y: minY2, width: maxX2 - minX2, height: maxY2 - minY2, padding: padding2 },
|
|
18633
|
+
// No feature ids: focus-dim is a feature concept, so a points focus
|
|
18634
|
+
// dims no features (there are no "other" features to mute).
|
|
18635
|
+
ids: []
|
|
18636
|
+
};
|
|
18637
|
+
}
|
|
18546
18638
|
let ids;
|
|
18547
18639
|
let padding = 16;
|
|
18548
18640
|
if (typeof focus === "string" || typeof focus === "number") {
|