@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 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 (!("features" in focusObj) || focusObj.features == null) {
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. geo: { focus: { features: "36", padding: 32 } }'
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, or { features: "36", padding: 16 }. Pass null to clear focus.'
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 (!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
- });
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
- const featureObjs = geoFeatures.map((f) => ({
17886
- id: f.id,
17887
- properties: f.properties ?? {}
17888
- }));
17889
- const { joined, warnings: joinWarnings } = joinDataToFeatures(
17890
- featureObjs,
17891
- mapSpec.data,
17892
- mapSpec.encoding.key.field,
17893
- mapSpec.geo.idField
17894
- );
17895
- const filteredJoinWarnings = mapSpec.data.length === 0 && mapSpec.points ? joinWarnings.filter((w) => w.code !== "UNMATCHED_FEATURES") : joinWarnings;
17896
- compileWarnings.push(...filteredJoinWarnings);
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 seen = /* @__PURE__ */ new Set();
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 CATEGORICAL_PALETTE[0];
18052
- return pointCategoryColors.get(String(val)) ?? CATEGORICAL_PALETTE[0];
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
- compileWarnings.push({
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 choroplethLegendHeight = continuousLegend ? continuousLegend.bounds.height : categoricalLegend ? categoricalLegend.bounds.height : 0;
18104
- const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
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 + choroplethLegendHeight + choroplethLegendGap;
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 choroplethLegendHeight = continuousLegend ? continuousLegend.bounds.height : categoricalLegend ? categoricalLegend.bounds.height : 0;
18152
- const choroplethLegendGap = choroplethLegendHeight > 0 ? 8 : 0;
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 + choroplethLegendHeight + choroplethLegendGap;
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 seen = /* @__PURE__ */ new Set();
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], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
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") {