@opendata-ai/openchart-engine 8.0.0-rc.1 → 8.0.0-rc.10

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
@@ -244,7 +244,10 @@ function refreshConnector(connector, bounds, markerRadius) {
244
244
  }
245
245
  const exit = connectorExit(bounds, endpoint.x, endpoint.y);
246
246
  if (!exit) return void 0;
247
- const from = { x: exit.x, y: exit.y };
247
+ const from = {
248
+ x: exit.x + (connector.fromOffset?.dx ?? 0),
249
+ y: exit.y + (connector.fromOffset?.dy ?? 0)
250
+ };
248
251
  const gap = connectorPullbackGap(markerRadius, connector.arrow);
249
252
  const cdx = endpoint.x - from.x;
250
253
  const cdy = endpoint.y - from.y;
@@ -506,6 +509,10 @@ function resolveTextAnnotation(annotation, scales, chartArea, isDark, measure =
506
509
  from,
507
510
  to,
508
511
  endpoint,
512
+ fromOffset: annotation.connectorOffset?.from ? {
513
+ dx: annotation.connectorOffset.from.dx ?? 0,
514
+ dy: annotation.connectorOffset.from.dy ?? 0
515
+ } : void 0,
509
516
  stroke: connectorStroke,
510
517
  style: connectorStyle,
511
518
  arrow: connectorArrow,
@@ -2160,6 +2167,7 @@ function serializeKeyValue(v) {
2160
2167
  if (v instanceof Date) return String(v.getTime());
2161
2168
  return String(v);
2162
2169
  }
2170
+ var KEY_DEDUP_SEP = "\u2E31";
2163
2171
  function dedupeKeys(keys) {
2164
2172
  const counts = /* @__PURE__ */ new Map();
2165
2173
  for (const k of keys) {
@@ -2170,7 +2178,7 @@ function dedupeKeys(keys) {
2170
2178
  for (const k of keys) {
2171
2179
  if (counts.get(k) > 1) {
2172
2180
  const idx = seen.get(k) ?? 0;
2173
- result.push(`${k}${idx}`);
2181
+ result.push(`${k}${KEY_DEDUP_SEP}${idx}`);
2174
2182
  seen.set(k, idx + 1);
2175
2183
  } else {
2176
2184
  result.push(k);
@@ -5544,7 +5552,9 @@ var SIZE_SCALE_DEFAULTS = {
5544
5552
  * Text glyphs. Linear, not sqrt -- a word reads by its height, which is
5545
5553
  * linear in font size, where a disc reads by its area.
5546
5554
  */
5547
- text: { curve: "linear", range: [11, 32] }
5555
+ text: { curve: "linear", range: [11, 32] },
5556
+ /** Map point dots. sqrt for area-proportional, tighter range than scatter. */
5557
+ mapPoint: { curve: "sqrt", range: [3, 20] }
5548
5558
  };
5549
5559
  function sizeScaleDefaultsFor(markType) {
5550
5560
  const found = SIZE_SCALE_DEFAULTS[markType];
@@ -9836,7 +9846,16 @@ function expandChannelSugar(spec, warnings) {
9836
9846
  }
9837
9847
  return result;
9838
9848
  }
9839
- var COUNT_GROUP_CHANNELS = ["x", "y", "color", "detail", "strokeDash", "facet"];
9849
+ var COUNT_GROUP_CHANNELS = [
9850
+ "x",
9851
+ "y",
9852
+ "color",
9853
+ "detail",
9854
+ "strokeDash",
9855
+ "facet",
9856
+ "row",
9857
+ "column"
9858
+ ];
9840
9859
  function expandCountAggregate(spec) {
9841
9860
  const encoding = spec.encoding;
9842
9861
  if (!encoding || typeof encoding !== "object") return spec;
@@ -10630,7 +10649,8 @@ function normalizeMapSpec(spec, _warnings) {
10630
10649
  darkMode: spec.darkMode ?? "off",
10631
10650
  watermark: spec.watermark ?? true,
10632
10651
  animation: spec.animation,
10633
- valueFormat: spec.valueFormat
10652
+ valueFormat: spec.valueFormat,
10653
+ points: spec.points
10634
10654
  };
10635
10655
  }
10636
10656
  function normalizeBarListSpec(spec, _warnings) {
@@ -10725,6 +10745,7 @@ import {
10725
10745
  MARK_ENCODING_RULES,
10726
10746
  MARK_TYPES,
10727
10747
  resolveSchemeName as resolveSchemeName2,
10748
+ SEQUENTIAL_PALETTES,
10728
10749
  SUPPORTED_SCHEME_NAMES
10729
10750
  } from "@opendata-ai/openchart-core";
10730
10751
  var VALID_FIELD_TYPES = /* @__PURE__ */ new Set(["quantitative", "temporal", "nominal", "ordinal"]);
@@ -10783,6 +10804,53 @@ function didYouMean(field, columns) {
10783
10804
  const match = nearestColumn(field, columns);
10784
10805
  return match ? ` Did you mean "${match}"?` : "";
10785
10806
  }
10807
+ function checkSchemeName(channelObj, channelPath, errors) {
10808
+ const scale = channelObj.scale;
10809
+ if (scale && typeof scale.scheme === "string" && !resolveSchemeName2(scale.scheme)) {
10810
+ errors.push({
10811
+ message: `Spec error: ${channelPath}.scale.scheme "${scale.scheme}" is not a supported scheme name`,
10812
+ path: `${channelPath}.scale.scheme`,
10813
+ code: "INVALID_VALUE",
10814
+ suggestion: `Use one of the supported scheme names: ${[...SUPPORTED_SCHEME_NAMES].join(", ")}. Or provide explicit colors via scale.range.`
10815
+ });
10816
+ }
10817
+ }
10818
+ function checkSchemeUnused(encoding, errors, familyNote, suggestion) {
10819
+ for (const [channel, ch] of Object.entries(encoding)) {
10820
+ if (!ch || typeof ch !== "object" || Array.isArray(ch)) continue;
10821
+ const scale = ch.scale;
10822
+ if (scale && typeof scale.scheme === "string") {
10823
+ errors.push({
10824
+ message: `Spec error: encoding.${channel}.scale.scheme has no effect \u2014 ${familyNote}`,
10825
+ path: `encoding.${channel}.scale.scheme`,
10826
+ code: "INVALID_VALUE",
10827
+ suggestion
10828
+ });
10829
+ }
10830
+ }
10831
+ }
10832
+ var MAP_PALETTE_NAMES = Object.keys(SEQUENTIAL_PALETTES).join(", ");
10833
+ function checkMapSchemeName(channelObj, channelPath, errors) {
10834
+ const scale = channelObj.scale;
10835
+ if (!scale || typeof scale.scheme !== "string") return;
10836
+ if (channelObj.type === "nominal" || channelObj.type === "ordinal") {
10837
+ errors.push({
10838
+ message: `Spec error: ${channelPath}.scale.scheme has no effect \u2014 categorical map colors come from scale.range and scale.domain`,
10839
+ path: `${channelPath}.scale.scheme`,
10840
+ code: "INVALID_VALUE",
10841
+ suggestion: "Remove scale.scheme. To customize categorical map colors, provide explicit colors via scale.range (with scale.domain for category order)."
10842
+ });
10843
+ return;
10844
+ }
10845
+ if (!(scale.scheme in SEQUENTIAL_PALETTES)) {
10846
+ errors.push({
10847
+ message: `Spec error: ${channelPath}.scale.scheme "${scale.scheme}" is not a supported map palette`,
10848
+ path: `${channelPath}.scale.scheme`,
10849
+ code: "INVALID_VALUE",
10850
+ suggestion: `Maps support the sequential palettes: ${MAP_PALETTE_NAMES}. The default is "blue".`
10851
+ });
10852
+ }
10853
+ }
10786
10854
  var VALID_RANGE_STYLES = /* @__PURE__ */ new Set(["dumbbell", "arrow", "bar"]);
10787
10855
  function validateRangeSpec(spec, errors) {
10788
10856
  const encoding = spec.encoding;
@@ -11144,15 +11212,7 @@ function validateChartSpec(spec, errors) {
11144
11212
  suggestion: `Use one of: ${[...VALID_FIELD_TYPES].join(", ")}`
11145
11213
  });
11146
11214
  }
11147
- const channelScale = channelObj.scale;
11148
- if (channelScale && typeof channelScale.scheme === "string" && !resolveSchemeName2(channelScale.scheme)) {
11149
- errors.push({
11150
- message: `Spec error: encoding.${channel}.scale.scheme "${channelScale.scheme}" is not a supported scheme name`,
11151
- path: `encoding.${channel}.scale.scheme`,
11152
- code: "INVALID_VALUE",
11153
- suggestion: `Use one of the supported scheme names: ${[...SUPPORTED_SCHEME_NAMES].join(", ")}. Or provide explicit colors via scale.range.`
11154
- });
11155
- }
11215
+ checkSchemeName(channelObj, `encoding.${channel}`, errors);
11156
11216
  if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
11157
11217
  if (!channelRule.allowedTypes.includes(channelObj.type)) {
11158
11218
  errors.push({
@@ -11266,6 +11326,52 @@ function validateChartSpec(spec, errors) {
11266
11326
  }
11267
11327
  }
11268
11328
  }
11329
+ for (const channel of ["row", "column"]) {
11330
+ const ch = encoding[channel];
11331
+ if (ch && typeof ch === "object") {
11332
+ const facetCh = ch;
11333
+ if (!facetCh.field || typeof facetCh.field !== "string") {
11334
+ errors.push({
11335
+ message: `Spec error: encoding.${channel}.field is required and must be a non-empty string`,
11336
+ path: `encoding.${channel}.field`,
11337
+ code: "MISSING_FIELD",
11338
+ suggestion: `Add a field property to encoding.${channel} (e.g. { field: "category", type: "nominal" })`
11339
+ });
11340
+ } else if (!dataColumns.has(facetCh.field) && !transformFields.has(facetCh.field)) {
11341
+ errors.push({
11342
+ message: `Spec error: encoding.${channel}.field "${facetCh.field}" does not exist in data. Available columns: ${availableColumns}`,
11343
+ path: `encoding.${channel}.field`,
11344
+ code: "DATA_FIELD_MISSING",
11345
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(facetCh.field, columnList)}`
11346
+ });
11347
+ }
11348
+ if (!facetCh.type || facetCh.type !== "nominal" && facetCh.type !== "ordinal") {
11349
+ errors.push({
11350
+ message: `Spec error: encoding.${channel}.type must be "nominal" or "ordinal"${facetCh.type ? `, got "${facetCh.type}"` : ""}`,
11351
+ path: `encoding.${channel}.type`,
11352
+ code: facetCh.type ? "INVALID_VALUE" : "MISSING_FIELD",
11353
+ suggestion: `Set encoding.${channel}.type to "nominal" or "ordinal"`
11354
+ });
11355
+ }
11356
+ if (encoding.facet && typeof encoding.facet === "object") {
11357
+ errors.push({
11358
+ message: `Spec error: encoding.${channel} and encoding.facet cannot be used together`,
11359
+ path: `encoding.${channel}`,
11360
+ code: "INVALID_VALUE",
11361
+ suggestion: `Use either encoding.${channel} or encoding.facet, not both`
11362
+ });
11363
+ }
11364
+ const otherChannel = channel === "row" ? "column" : "row";
11365
+ if (encoding[otherChannel]) {
11366
+ errors.push({
11367
+ message: "Spec error: encoding.row and encoding.column cannot be used together (cross-product faceting is not yet supported)",
11368
+ path: `encoding.${channel}`,
11369
+ code: "INVALID_VALUE",
11370
+ suggestion: "Use encoding.facet with columns for grid layout, or use only row or column"
11371
+ });
11372
+ }
11373
+ }
11374
+ }
11269
11375
  if (spec.darkMode !== void 0 && !VALID_DARK_MODES.has(spec.darkMode)) {
11270
11376
  errors.push({
11271
11377
  message: `Spec error: darkMode must be "auto", "force", or "off"`,
@@ -11478,7 +11584,7 @@ function validateGraphSpec(spec, errors) {
11478
11584
  message: `Spec error: edges[${i}].source "${edge.source}" does not reference an existing node id`,
11479
11585
  path: `edges[${i}].source`,
11480
11586
  code: "DATA_FIELD_MISSING",
11481
- suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(", ")}${nodeIds.size > 5 ? "..." : ""}`
11587
+ suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(", ")}${nodeIds.size > 5 ? "..." : ""}.${didYouMean(edge.source, [...nodeIds])}`
11482
11588
  });
11483
11589
  }
11484
11590
  if (typeof edge.target !== "string" || edge.target === "") {
@@ -11493,7 +11599,7 @@ function validateGraphSpec(spec, errors) {
11493
11599
  message: `Spec error: edges[${i}].target "${edge.target}" does not reference an existing node id`,
11494
11600
  path: `edges[${i}].target`,
11495
11601
  code: "DATA_FIELD_MISSING",
11496
- suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(", ")}${nodeIds.size > 5 ? "..." : ""}`
11602
+ suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(", ")}${nodeIds.size > 5 ? "..." : ""}.${didYouMean(edge.target, [...nodeIds])}`
11497
11603
  });
11498
11604
  }
11499
11605
  }
@@ -11511,7 +11617,7 @@ function validateGraphSpec(spec, errors) {
11511
11617
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on nodes. Available fields: ${[...nodeFields].join(", ")}`,
11512
11618
  path: `encoding.${channel}.field`,
11513
11619
  code: "DATA_FIELD_MISSING",
11514
- suggestion: `Use one of the node fields: ${[...nodeFields].join(", ")}`
11620
+ suggestion: `Use one of the node fields: ${[...nodeFields].join(", ")}.${didYouMean(ch.field, [...nodeFields])}`
11515
11621
  });
11516
11622
  }
11517
11623
  }
@@ -11523,10 +11629,16 @@ function validateGraphSpec(spec, errors) {
11523
11629
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on edges. Available fields: ${[...edgeFields].join(", ")}`,
11524
11630
  path: `encoding.${channel}.field`,
11525
11631
  code: "DATA_FIELD_MISSING",
11526
- suggestion: `Use one of the edge fields: ${[...edgeFields].join(", ")}`
11632
+ suggestion: `Use one of the edge fields: ${[...edgeFields].join(", ")}.${didYouMean(ch.field, [...edgeFields])}`
11527
11633
  });
11528
11634
  }
11529
11635
  }
11636
+ checkSchemeUnused(
11637
+ encoding,
11638
+ errors,
11639
+ "graph colors come from scale.range and scale.domain",
11640
+ "Remove scale.scheme. To customize graph colors, provide explicit colors via scale.range."
11641
+ );
11530
11642
  }
11531
11643
  if (spec.layout && typeof spec.layout === "object") {
11532
11644
  const layout = spec.layout;
@@ -11580,7 +11692,8 @@ function validateSankeySpec(spec, errors) {
11580
11692
  }
11581
11693
  const encoding = spec.encoding;
11582
11694
  const dataColumns = new Set(Object.keys(firstRow));
11583
- const availableColumns = [...dataColumns].join(", ");
11695
+ const columnList = [...dataColumns];
11696
+ const availableColumns = columnList.join(", ");
11584
11697
  for (const channel of ["source", "target", "value"]) {
11585
11698
  const ch = encoding[channel];
11586
11699
  if (!ch || typeof ch !== "object") {
@@ -11588,7 +11701,7 @@ function validateSankeySpec(spec, errors) {
11588
11701
  message: `Spec error: sankey encoding requires "${channel}" channel`,
11589
11702
  path: `encoding.${channel}`,
11590
11703
  code: "MISSING_FIELD",
11591
- suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? "myField"}", type: "${channel === "value" ? "quantitative" : "nominal"}" }`
11704
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? "myField"}", type: "${channel === "value" ? "quantitative" : "nominal"}" }`
11592
11705
  });
11593
11706
  continue;
11594
11707
  }
@@ -11606,10 +11719,16 @@ function validateSankeySpec(spec, errors) {
11606
11719
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
11607
11720
  path: `encoding.${channel}.field`,
11608
11721
  code: "DATA_FIELD_MISSING",
11609
- suggestion: `Use one of the available data columns: ${availableColumns}`
11722
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field, columnList)}`
11610
11723
  });
11611
11724
  }
11612
11725
  }
11726
+ checkSchemeUnused(
11727
+ encoding,
11728
+ errors,
11729
+ "sankey node colors cycle the theme categorical palette",
11730
+ "Remove scale.scheme. To customize sankey colors, set theme.colors.categorical."
11731
+ );
11613
11732
  if (spec.darkMode !== void 0 && !VALID_DARK_MODES.has(spec.darkMode)) {
11614
11733
  errors.push({
11615
11734
  message: 'Spec error: darkMode must be "auto", "force", or "off"',
@@ -11669,7 +11788,8 @@ function validateTileMapSpec(spec, errors) {
11669
11788
  }
11670
11789
  const encoding = spec.encoding;
11671
11790
  const dataColumns = new Set(Object.keys(firstRow));
11672
- const availableColumns = [...dataColumns].join(", ");
11791
+ const columnList = [...dataColumns];
11792
+ const availableColumns = columnList.join(", ");
11673
11793
  for (const channel of ["state", "value"]) {
11674
11794
  const ch = encoding[channel];
11675
11795
  if (!ch || typeof ch !== "object") {
@@ -11677,7 +11797,7 @@ function validateTileMapSpec(spec, errors) {
11677
11797
  message: `Spec error: tilemap encoding requires "${channel}" channel`,
11678
11798
  path: `encoding.${channel}`,
11679
11799
  code: "MISSING_FIELD",
11680
- suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? "myField"}", type: "${channel === "value" ? "quantitative" : "nominal"}" }`
11800
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? "myField"}", type: "${channel === "value" ? "quantitative" : "nominal"}" }`
11681
11801
  });
11682
11802
  continue;
11683
11803
  }
@@ -11695,11 +11815,19 @@ function validateTileMapSpec(spec, errors) {
11695
11815
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
11696
11816
  path: `encoding.${channel}.field`,
11697
11817
  code: "DATA_FIELD_MISSING",
11698
- suggestion: `Use one of the available data columns: ${availableColumns}`
11818
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field, columnList)}`
11699
11819
  });
11700
11820
  }
11701
11821
  }
11702
11822
  }
11823
+ if (spec.encoding && typeof spec.encoding === "object") {
11824
+ checkSchemeUnused(
11825
+ spec.encoding,
11826
+ errors,
11827
+ "tilemap fills come from the top-level palette property",
11828
+ `Remove scale.scheme and set the top-level palette property instead, e.g. palette: "green". Supported: ${MAP_PALETTE_NAMES}.`
11829
+ );
11830
+ }
11703
11831
  if (spec.darkMode !== void 0 && !VALID_DARK_MODES.has(spec.darkMode)) {
11704
11832
  errors.push({
11705
11833
  message: 'Spec error: darkMode must be "auto", "force", or "off"',
@@ -11738,6 +11866,16 @@ function validateMapSpec(spec, errors) {
11738
11866
  });
11739
11867
  return;
11740
11868
  }
11869
+ const mapEncoding = spec.encoding;
11870
+ if (mapEncoding.color && typeof mapEncoding.color === "object") {
11871
+ checkMapSchemeName(mapEncoding.color, "encoding.color", errors);
11872
+ }
11873
+ if (spec.points && typeof spec.points === "object") {
11874
+ const pointsLayer = spec.points;
11875
+ if (pointsLayer.color && typeof pointsLayer.color === "object") {
11876
+ checkMapSchemeName(pointsLayer.color, "points.color", errors);
11877
+ }
11878
+ }
11741
11879
  if ("focus" in geo && geo.focus !== void 0 && geo.focus !== null) {
11742
11880
  const focus = geo.focus;
11743
11881
  if (typeof focus === "string" || typeof focus === "number") {
@@ -11754,12 +11892,31 @@ function validateMapSpec(spec, errors) {
11754
11892
  }
11755
11893
  } else if (typeof focus === "object") {
11756
11894
  const focusObj = focus;
11757
- if (!("features" in focusObj) || focusObj.features == null) {
11895
+ if ("points" in focusObj) {
11896
+ const pts = focusObj.points;
11897
+ const isSubset = typeof pts === "object" && pts !== null && typeof pts.field === "string" && "value" in pts;
11898
+ if (pts !== true && !isSubset) {
11899
+ errors.push({
11900
+ message: "Spec error: geo.focus.points must be true or { field: string, value: string | number }",
11901
+ path: "geo.focus.points",
11902
+ code: "INVALID_TYPE",
11903
+ suggestion: 'Use { points: true } to fit all points, or { points: { field: "rating", value: "F" } } to fit a subset.'
11904
+ });
11905
+ }
11906
+ if (focusObj.padding !== void 0 && typeof focusObj.padding !== "number") {
11907
+ errors.push({
11908
+ message: "Spec error: geo.focus.padding must be a number",
11909
+ path: "geo.focus.padding",
11910
+ code: "INVALID_TYPE",
11911
+ suggestion: "Provide padding as a number in map-local units, e.g. padding: 16"
11912
+ });
11913
+ }
11914
+ } else if (!("features" in focusObj) || focusObj.features == null) {
11758
11915
  errors.push({
11759
- message: 'Spec error: geo.focus object must have a "features" property',
11916
+ message: 'Spec error: geo.focus object must have a "features" or "points" property',
11760
11917
  path: "geo.focus.features",
11761
11918
  code: "MISSING_FIELD",
11762
- suggestion: 'Provide feature id(s) to focus on, e.g. geo: { focus: { features: "36", padding: 32 } }'
11919
+ suggestion: 'Provide feature id(s) to focus on, e.g. { features: "36", padding: 32 }, or { points: true } to fit the point cluster.'
11763
11920
  });
11764
11921
  } else {
11765
11922
  const feats = focusObj.features;
@@ -11793,33 +11950,99 @@ function validateMapSpec(spec, errors) {
11793
11950
  }
11794
11951
  } else {
11795
11952
  errors.push({
11796
- message: "Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? }",
11953
+ message: "Spec error: geo.focus must be a string, number, array of ids, or object with { features, padding? } or { points: true, padding? }",
11797
11954
  path: "geo.focus",
11798
11955
  code: "INVALID_TYPE",
11799
- suggestion: 'Use a feature id, array of ids, or { features: "36", padding: 16 }. Pass null to clear focus.'
11956
+ suggestion: 'Use a feature id, array of ids, { features: "36", padding: 16 }, or { points: true }. Pass null to clear focus.'
11800
11957
  });
11801
11958
  }
11802
11959
  }
11803
11960
  const encoding = spec.encoding;
11804
- for (const channel of ["key", "color"]) {
11805
- const ch = encoding[channel];
11806
- if (!ch || typeof ch !== "object") {
11961
+ const hasPoints = spec.points && typeof spec.points === "object" && !Array.isArray(spec.points);
11962
+ const specData = spec.data;
11963
+ const isBasemapOnly = hasPoints && (!specData || specData.length === 0);
11964
+ const keyCh = encoding.key;
11965
+ if (!isBasemapOnly) {
11966
+ if (!keyCh || typeof keyCh !== "object") {
11807
11967
  errors.push({
11808
- message: `Spec error: map encoding requires "${channel}" channel`,
11809
- path: `encoding.${channel}`,
11968
+ message: 'Spec error: map encoding requires "key" channel',
11969
+ path: "encoding.key",
11810
11970
  code: "MISSING_FIELD",
11811
- suggestion: `Add encoding.${channel}, e.g. ${channel}: { field: "${channel === "key" ? "id" : "value"}", type: "${channel === "key" ? "nominal" : "quantitative"}" }`
11971
+ suggestion: 'Add encoding.key, e.g. key: { field: "id", type: "nominal" }'
11972
+ });
11973
+ } else if (!keyCh.field || typeof keyCh.field !== "string") {
11974
+ errors.push({
11975
+ message: 'Spec error: encoding.key must have a "field" string',
11976
+ path: "encoding.key.field",
11977
+ code: "MISSING_FIELD",
11978
+ suggestion: 'Add a field name, e.g. key: { field: "id" }'
11812
11979
  });
11813
- continue;
11814
11980
  }
11815
- if (!ch.field || typeof ch.field !== "string") {
11981
+ }
11982
+ const colorCh = encoding.color;
11983
+ if (!hasPoints) {
11984
+ if (!colorCh || typeof colorCh !== "object") {
11816
11985
  errors.push({
11817
- message: `Spec error: encoding.${channel} must have a "field" string`,
11818
- path: `encoding.${channel}.field`,
11986
+ message: 'Spec error: map encoding requires "color" channel (or add a "points" layer for basemap-only mode)',
11987
+ path: "encoding.color",
11819
11988
  code: "MISSING_FIELD",
11820
- suggestion: `Add a field name, e.g. ${channel}: { field: "${channel === "key" ? "id" : "value"}" }`
11989
+ suggestion: 'Add encoding.color, e.g. color: { field: "value", type: "quantitative" }'
11990
+ });
11991
+ } else if (!colorCh.field || typeof colorCh.field !== "string") {
11992
+ errors.push({
11993
+ message: 'Spec error: encoding.color must have a "field" string',
11994
+ path: "encoding.color.field",
11995
+ code: "MISSING_FIELD",
11996
+ suggestion: 'Add a field name, e.g. color: { field: "value" }'
11821
11997
  });
11822
11998
  }
11999
+ } else if (colorCh && typeof colorCh === "object" && (!colorCh.field || typeof colorCh.field !== "string")) {
12000
+ errors.push({
12001
+ message: 'Spec error: encoding.color must have a "field" string when provided',
12002
+ path: "encoding.color.field",
12003
+ code: "MISSING_FIELD",
12004
+ suggestion: 'Add a field name, e.g. color: { field: "value" }'
12005
+ });
12006
+ }
12007
+ if (hasPoints) {
12008
+ const points = spec.points;
12009
+ if (!Array.isArray(points.data)) {
12010
+ errors.push({
12011
+ message: "Spec error: points.data must be an array of data rows",
12012
+ path: "points.data",
12013
+ code: "INVALID_TYPE",
12014
+ suggestion: "Provide data as an array of objects with lat/lng fields"
12015
+ });
12016
+ }
12017
+ for (const ch of ["longitude", "latitude"]) {
12018
+ const enc = points[ch];
12019
+ if (!enc || typeof enc !== "object") {
12020
+ errors.push({
12021
+ message: `Spec error: points requires "${ch}" encoding channel`,
12022
+ path: `points.${ch}`,
12023
+ code: "MISSING_FIELD",
12024
+ suggestion: `Add points.${ch}, e.g. ${ch}: { field: "${ch === "longitude" ? "lon" : "lat"}", type: "quantitative" }`
12025
+ });
12026
+ } else if (!enc.field || typeof enc.field !== "string") {
12027
+ errors.push({
12028
+ message: `Spec error: points.${ch} must have a "field" string`,
12029
+ path: `points.${ch}.field`,
12030
+ code: "MISSING_FIELD",
12031
+ suggestion: `Add a field name, e.g. ${ch}: { field: "${ch === "longitude" ? "lon" : "lat"}" }`
12032
+ });
12033
+ }
12034
+ }
12035
+ for (const ch of ["size", "color", "key"]) {
12036
+ const enc = points[ch];
12037
+ if (enc && typeof enc === "object" && (!enc.field || typeof enc.field !== "string")) {
12038
+ errors.push({
12039
+ message: `Spec error: points.${ch} must have a "field" string when provided`,
12040
+ path: `points.${ch}.field`,
12041
+ code: "MISSING_FIELD",
12042
+ suggestion: `Add a field name, e.g. ${ch}: { field: "myField" }`
12043
+ });
12044
+ }
12045
+ }
11823
12046
  }
11824
12047
  }
11825
12048
  function validateBarListSpec(spec, errors) {
@@ -11862,7 +12085,8 @@ function validateBarListSpec(spec, errors) {
11862
12085
  }
11863
12086
  const encoding = spec.encoding;
11864
12087
  const dataColumns = new Set(Object.keys(firstRow));
11865
- const availableColumns = [...dataColumns].join(", ");
12088
+ const columnList = [...dataColumns];
12089
+ const availableColumns = columnList.join(", ");
11866
12090
  for (const channel of ["label", "value"]) {
11867
12091
  const ch = encoding[channel];
11868
12092
  if (!ch || typeof ch !== "object") {
@@ -11870,7 +12094,7 @@ function validateBarListSpec(spec, errors) {
11870
12094
  message: `Spec error: barlist encoding requires "${channel}" channel`,
11871
12095
  path: `encoding.${channel}`,
11872
12096
  code: "MISSING_FIELD",
11873
- suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? "myField"}", type: "${channel === "value" ? "quantitative" : "nominal"}" }`
12097
+ suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${columnList[0] ?? "myField"}", type: "${channel === "value" ? "quantitative" : "nominal"}" }`
11874
12098
  });
11875
12099
  continue;
11876
12100
  }
@@ -11888,7 +12112,7 @@ function validateBarListSpec(spec, errors) {
11888
12112
  message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
11889
12113
  path: `encoding.${channel}.field`,
11890
12114
  code: "DATA_FIELD_MISSING",
11891
- suggestion: `Use one of the available data columns: ${availableColumns}`
12115
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(ch.field, columnList)}`
11892
12116
  });
11893
12117
  }
11894
12118
  }
@@ -11901,10 +12125,16 @@ function validateBarListSpec(spec, errors) {
11901
12125
  message: `Spec error: encoding.${channel}.field "${field}" does not exist in data. Available columns: ${availableColumns}`,
11902
12126
  path: `encoding.${channel}.field`,
11903
12127
  code: "DATA_FIELD_MISSING",
11904
- suggestion: `Use one of the available data columns: ${availableColumns}`
12128
+ suggestion: `Use one of the available data columns: ${availableColumns}.${didYouMean(field, columnList)}`
11905
12129
  });
11906
12130
  }
11907
12131
  }
12132
+ checkSchemeUnused(
12133
+ encoding,
12134
+ errors,
12135
+ "bar list colors cycle the built-in barlist palette",
12136
+ "Remove scale.scheme. Bar list colors are not configurable via encoding; they cycle the built-in palette."
12137
+ );
11908
12138
  if (spec.darkMode !== void 0 && !VALID_DARK_MODES.has(spec.darkMode)) {
11909
12139
  errors.push({
11910
12140
  message: 'Spec error: darkMode must be "auto", "force", or "off"',
@@ -13649,7 +13879,8 @@ function computeLegendContent(spec, strategy, theme, availableWidth, availableHe
13649
13879
  fill: theme.colors.text,
13650
13880
  lineHeight: 1.3
13651
13881
  };
13652
- const resolvedPosition = spec.legend?.position ?? (strategy.legendPosition === "right" ? "right" : "top");
13882
+ const specPosition = spec.legend?.position === "top-left" ? "top" : spec.legend?.position;
13883
+ const resolvedPosition = specPosition ?? (strategy.legendPosition === "right" ? "right" : "top");
13653
13884
  if (entries.length === 0) {
13654
13885
  return emptyContent(resolvedPosition, theme);
13655
13886
  }
@@ -13994,6 +14225,7 @@ import {
13994
14225
  estimateTextWidth as estimateTextWidth15,
13995
14226
  getAxisTitleOffset,
13996
14227
  truncateRotatedLabel,
14228
+ X_AXIS_TITLE_BAND,
13997
14229
  X_AXIS_TITLE_BAND_ROTATED
13998
14230
  } from "@opendata-ai/openchart-core";
13999
14231
 
@@ -14606,10 +14838,12 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
14606
14838
  measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText)
14607
14839
  });
14608
14840
  if (result.x.label) {
14609
- const rotatedBand = isRotated ? result.x.extent - X_AXIS_TITLE_BAND_ROTATED + 14 : 35;
14841
+ const titleBand = isRotated ? X_AXIS_TITLE_BAND_ROTATED : X_AXIS_TITLE_BAND;
14842
+ const tickBand = result.x.extent - titleBand;
14843
+ const titleY = isRotated ? tickBand + 14 : tickBand + 9;
14610
14844
  result.x.titlePosition = {
14611
14845
  x: chartArea.x + chartArea.width / 2,
14612
- y: chartArea.y + chartArea.height + rotatedBand
14846
+ y: chartArea.y + chartArea.height + titleY
14613
14847
  };
14614
14848
  }
14615
14849
  }
@@ -15453,6 +15687,10 @@ function buildPrimarySpec(leaves, layerSpec) {
15453
15687
  ...leaves[0],
15454
15688
  data: allData,
15455
15689
  chrome: layerSpec.chrome ?? leaves[0].chrome,
15690
+ // Annotations come from the parent LayerSpec when present, otherwise
15691
+ // the first flattened leaf's. Later leaves' annotations are silently
15692
+ // dropped. The dual-axis path (compileLayerIndependent) explicitly
15693
+ // clears the secondary axis spec's annotations.
15456
15694
  annotations: layerSpec.annotations ?? leaves[0].annotations,
15457
15695
  labels: layerSpec.labels ?? leaves[0].labels,
15458
15696
  legend: layerSpec.legend ?? leaves[0].legend,
@@ -15687,7 +15925,7 @@ import {
15687
15925
  HPAD_COMPACT_FRACTION as HPAD_COMPACT_FRACTION2,
15688
15926
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN2,
15689
15927
  resolveTheme as resolveTheme3,
15690
- SEQUENTIAL_PALETTES
15928
+ SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES2
15691
15929
  } from "@opendata-ai/openchart-core";
15692
15930
 
15693
15931
  // ../../node_modules/.bun/d3-geo@3.1.1/node_modules/d3-geo/src/math.js
@@ -17665,13 +17903,32 @@ function joinDataToFeatures(features, data, dataKeyField, featureIdField) {
17665
17903
 
17666
17904
  // src/geo/projections.ts
17667
17905
  var MERCATOR_FIT_MIN_LAT = -60;
17668
- function createProjection(type, width, height, geojson) {
17906
+ function createProjection(type, width, height, geojson, inset = 0) {
17669
17907
  if (type === "identity") {
17908
+ if (inset > 0) {
17909
+ return identity_default4().fitExtent(
17910
+ [
17911
+ [inset, inset],
17912
+ [width - inset, height - inset]
17913
+ ],
17914
+ geojson
17915
+ );
17916
+ }
17670
17917
  return identity_default4().fitSize([width, height], geojson);
17671
17918
  }
17672
17919
  const projection2 = type === "albersUsa" ? albersUsa_default() : type === "equalEarth" ? equalEarth_default() : mercator_default();
17673
17920
  const fitGeometry = type === "mercator" ? excludePolarFeatures(geojson) : geojson;
17674
- projection2.fitSize([width, height], fitGeometry);
17921
+ if (inset > 0) {
17922
+ projection2.fitExtent(
17923
+ [
17924
+ [inset, inset],
17925
+ [width - inset, height - inset]
17926
+ ],
17927
+ fitGeometry
17928
+ );
17929
+ } else {
17930
+ projection2.fitSize([width, height], fitGeometry);
17931
+ }
17675
17932
  return projection2;
17676
17933
  }
17677
17934
  function excludePolarFeatures(geojson) {
@@ -17691,6 +17948,7 @@ function excludePolarFeatures(geojson) {
17691
17948
  }
17692
17949
 
17693
17950
  // src/geo/compile-map.ts
17951
+ var OVERLAY_LEGEND_INSET = 18;
17694
17952
  function validateGeoFeatures(geo) {
17695
17953
  if (!geo.features) {
17696
17954
  throw new Error(
@@ -17763,11 +18021,13 @@ function compileMap(spec, options) {
17763
18021
  const compileWarnings = [];
17764
18022
  const showLegend = mapSpec.legend?.show !== false;
17765
18023
  const colorEncoding = mapSpec.encoding.color;
17766
- const isQuantitative = colorEncoding.type === "quantitative";
18024
+ const showChoroplethLegend = showLegend && colorEncoding?.legend !== null;
18025
+ const isQuantitative = colorEncoding?.type === "quantitative";
17767
18026
  const legendPosition = mapSpec.legend?.position === "bottom" ? "bottom" : "top";
18027
+ const pointLegendOverlay = mapSpec.legend?.position === "top-left";
17768
18028
  let legendBlockHeight = 0;
17769
18029
  let continuousContent = null;
17770
- if (showLegend && isQuantitative) {
18030
+ if (showChoroplethLegend && colorEncoding && isQuantitative) {
17771
18031
  const colorValues = [];
17772
18032
  for (const row of mapSpec.data) {
17773
18033
  const v = row[colorEncoding.field];
@@ -17790,17 +18050,37 @@ function compileMap(spec, options) {
17790
18050
  const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17791
18051
  legendBlockHeight = continuousContent.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
17792
18052
  }
17793
- } else if (showLegend) {
18053
+ } else if (showChoroplethLegend && colorEncoding) {
17794
18054
  const labelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
17795
18055
  legendBlockHeight = Math.max(10, labelHeight) + 6;
17796
18056
  }
18057
+ if (showLegend && !pointLegendOverlay && mapSpec.points?.color) {
18058
+ if (mapSpec.points.color.type === "quantitative") {
18059
+ const labelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
18060
+ legendBlockHeight += CONTINUOUS_BAR_HEIGHT + CONTINUOUS_LABEL_GAP + labelRowHeight + 8;
18061
+ } else {
18062
+ const ptLabelHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
18063
+ legendBlockHeight += Math.max(10, ptLabelHeight) + 6 + 8;
18064
+ }
18065
+ }
17797
18066
  const legendReserveGap = legendBlockHeight > 0 ? 8 : 0;
17798
18067
  const mapAreaHeight = fullArea.height - legendBlockHeight - legendReserveGap;
17799
18068
  if (mapAreaHeight <= 0) {
17800
18069
  return emptyLayout3(chrome, theme, options, watermark);
17801
18070
  }
17802
18071
  const projectionType = mapSpec.geo.projection;
17803
- const projection2 = createProjection(projectionType, fullArea.width, mapAreaHeight, geoCollection);
18072
+ let pointInset = 0;
18073
+ if (mapSpec.points) {
18074
+ const sizeRange = mapSpec.points.size?.scale?.range;
18075
+ pointInset = sizeRange ? sizeRange[1] : SIZE_SCALE_DEFAULTS.mapPoint.range[1];
18076
+ }
18077
+ const projection2 = createProjection(
18078
+ projectionType,
18079
+ fullArea.width,
18080
+ mapAreaHeight,
18081
+ geoCollection,
18082
+ pointInset
18083
+ );
17804
18084
  const pathGen = path_default(projection2);
17805
18085
  if (projectionType !== "identity") {
17806
18086
  for (const feat of geoFeatures) {
@@ -17815,17 +18095,22 @@ function compileMap(spec, options) {
17815
18095
  }
17816
18096
  }
17817
18097
  }
17818
- const featureObjs = geoFeatures.map((f) => ({
17819
- id: f.id,
17820
- properties: f.properties ?? {}
17821
- }));
17822
- const { joined, warnings: joinWarnings } = joinDataToFeatures(
17823
- featureObjs,
17824
- mapSpec.data,
17825
- mapSpec.encoding.key.field,
17826
- mapSpec.geo.idField
17827
- );
17828
- compileWarnings.push(...joinWarnings);
18098
+ let joined = /* @__PURE__ */ new Map();
18099
+ if (mapSpec.encoding.key) {
18100
+ const featureObjs = geoFeatures.map((f) => ({
18101
+ id: f.id,
18102
+ properties: f.properties ?? {}
18103
+ }));
18104
+ const joinResult = joinDataToFeatures(
18105
+ featureObjs,
18106
+ mapSpec.data,
18107
+ mapSpec.encoding.key.field,
18108
+ mapSpec.geo.idField
18109
+ );
18110
+ joined = joinResult.joined;
18111
+ const filteredJoinWarnings = mapSpec.data.length === 0 && mapSpec.points ? joinResult.warnings.filter((w) => w.code !== "UNMATCHED_FEATURES") : joinResult.warnings;
18112
+ compileWarnings.push(...filteredJoinWarnings);
18113
+ }
17829
18114
  const droppedFeatures = [];
17830
18115
  const formatter = buildD3Formatter2(mapSpec.valueFormat) ?? formatNumber3;
17831
18116
  const neutralFill = isDarkMode ? "#2a2a2a" : "#e8e8e8";
@@ -17833,13 +18118,21 @@ function compileMap(spec, options) {
17833
18118
  let featureMarks;
17834
18119
  let continuousLegend = null;
17835
18120
  let categoricalLegend = null;
17836
- if (isQuantitative) {
18121
+ if (!colorEncoding) {
18122
+ featureMarks = buildBasemapMarks({
18123
+ geoFeatures,
18124
+ pathGen,
18125
+ neutralFill,
18126
+ neutralStroke,
18127
+ droppedFeatures
18128
+ });
18129
+ } else if (isQuantitative) {
17837
18130
  const result = buildQuantitativeMarks({
17838
18131
  geoFeatures,
17839
18132
  pathGen,
17840
18133
  joined,
17841
18134
  colorField: colorEncoding.field,
17842
- palette: mapSpec.encoding.color.scale?.scheme ?? "blue",
18135
+ palette: colorEncoding.scale?.scheme ?? "blue",
17843
18136
  isDarkMode: !!isDarkMode,
17844
18137
  neutralFill,
17845
18138
  neutralStroke,
@@ -17847,7 +18140,7 @@ function compileMap(spec, options) {
17847
18140
  droppedFeatures
17848
18141
  });
17849
18142
  featureMarks = result.marks;
17850
- if (showLegend && continuousContent) {
18143
+ if (showChoroplethLegend && continuousContent) {
17851
18144
  const labelStyle = {
17852
18145
  fontFamily: theme.fonts.family,
17853
18146
  fontSize: theme.fonts.sizes.small,
@@ -17889,6 +18182,7 @@ function compileMap(spec, options) {
17889
18182
  pathGen,
17890
18183
  joined,
17891
18184
  colorField: colorEncoding.field,
18185
+ colorScale: colorEncoding.scale,
17892
18186
  isDarkMode: !!isDarkMode,
17893
18187
  neutralFill,
17894
18188
  neutralStroke,
@@ -17896,7 +18190,8 @@ function compileMap(spec, options) {
17896
18190
  fullArea,
17897
18191
  mapAreaHeight,
17898
18192
  legendReserveGap,
17899
- showLegend,
18193
+ legendPosition,
18194
+ showLegend: showChoroplethLegend,
17900
18195
  droppedFeatures
17901
18196
  });
17902
18197
  featureMarks = result.marks;
@@ -17924,6 +18219,201 @@ function compileMap(spec, options) {
17924
18219
  const interiorStroke = isDarkMode ? "rgba(255,255,255,0.3)" : "rgba(0,0,0,0.15)";
17925
18220
  const outlineStroke = isDarkMode ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.3)";
17926
18221
  const borders = { interiorPath, outlinePath, interiorStroke, outlineStroke };
18222
+ const pointMarks = [];
18223
+ let pointCategoricalLegend = null;
18224
+ let pointContinuousLegend = null;
18225
+ if (mapSpec.points) {
18226
+ const pts = mapSpec.points;
18227
+ const lonField = pts.longitude.field;
18228
+ const latField = pts.latitude.field;
18229
+ const keyChannel = pts.key;
18230
+ const sizeScale = buildSizeScale(pts.size, pts.data, SIZE_SCALE_DEFAULTS.mapPoint);
18231
+ let pointColorScale = null;
18232
+ const pointCategories = [];
18233
+ const pointCategoryColors = /* @__PURE__ */ new Map();
18234
+ if (pts.color) {
18235
+ if (pts.color.type === "quantitative") {
18236
+ const colorValues = [];
18237
+ for (const row of pts.data) {
18238
+ const raw = row[pts.color.field];
18239
+ if (raw != null) {
18240
+ const v = Number(raw);
18241
+ if (Number.isFinite(v)) colorValues.push(v);
18242
+ }
18243
+ }
18244
+ if (colorValues.length > 0) {
18245
+ const palette = pts.color.scale?.scheme ?? "blue";
18246
+ const paletteStops = [...SEQUENTIAL_PALETTES2[palette] ?? SEQUENTIAL_PALETTES2.blue];
18247
+ const qScale = quantile2().domain(colorValues).range(paletteStops);
18248
+ pointColorScale = (val) => {
18249
+ const v = Number(val);
18250
+ return Number.isFinite(v) ? qScale(v) : CATEGORICAL_PALETTE[0];
18251
+ };
18252
+ }
18253
+ } else {
18254
+ const explicitDomain = pts.color.scale?.domain;
18255
+ const explicitRange = pts.color.scale?.range;
18256
+ if (explicitDomain && Array.isArray(explicitDomain)) {
18257
+ for (const cat of explicitDomain) {
18258
+ pointCategories.push(String(cat));
18259
+ }
18260
+ }
18261
+ const seen = new Set(pointCategories);
18262
+ for (const row of pts.data) {
18263
+ const raw = row[pts.color.field];
18264
+ if (raw != null) {
18265
+ const cat = String(raw);
18266
+ if (!seen.has(cat)) {
18267
+ seen.add(cat);
18268
+ pointCategories.push(cat);
18269
+ }
18270
+ }
18271
+ }
18272
+ const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
18273
+ for (let i = 0; i < pointCategories.length; i++) {
18274
+ pointCategoryColors.set(pointCategories[i], colorSource[i % colorSource.length]);
18275
+ }
18276
+ pointColorScale = (val) => {
18277
+ if (val == null) return colorSource[0];
18278
+ return pointCategoryColors.get(String(val)) ?? colorSource[0];
18279
+ };
18280
+ }
18281
+ }
18282
+ const defaultFill = CATEGORICAL_PALETTE[0];
18283
+ const pointOpacity = pts.opacity ?? 0.65;
18284
+ let animIndex = 0;
18285
+ const droppedPoints = [];
18286
+ for (let i = 0; i < pts.data.length; i++) {
18287
+ const row = pts.data[i];
18288
+ const lon = Number(row[lonField]);
18289
+ const lat = Number(row[latField]);
18290
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
18291
+ const projected = projection2([lon, lat]);
18292
+ if (!projected) {
18293
+ droppedPoints.push([lon, lat]);
18294
+ continue;
18295
+ }
18296
+ const r = sizeScale ? sizeScale.scale(Number(row[sizeScale.field])) : 5;
18297
+ const fill = pointColorScale && pts.color ? pointColorScale(row[pts.color.field]) : defaultFill;
18298
+ const key = keyChannel ? String(row[keyChannel.field] ?? i) : String(i);
18299
+ const stroke = isDarkMode ? "rgba(0,0,0,0.5)" : "rgba(255,255,255,0.8)";
18300
+ pointMarks.push({
18301
+ type: "map-point",
18302
+ cx: projected[0],
18303
+ cy: projected[1],
18304
+ r,
18305
+ fill,
18306
+ stroke,
18307
+ strokeWidth: 1,
18308
+ fillOpacity: pointOpacity,
18309
+ key,
18310
+ data: row,
18311
+ aria: { role: "img", label: key },
18312
+ animationIndex: animIndex++
18313
+ });
18314
+ }
18315
+ if (droppedPoints.length > 0) {
18316
+ const sample = droppedPoints.slice(0, 3).map(([lo, la]) => `[${lo}, ${la}]`).join(", ");
18317
+ compileWarnings.push({
18318
+ code: "POINT_NULL_PROJECTION",
18319
+ 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".`,
18320
+ context: { count: droppedPoints.length, samples: droppedPoints.slice(0, 5) }
18321
+ });
18322
+ }
18323
+ if (pts.color && pts.color.type !== "quantitative" && pointCategories.length > 0 && showLegend) {
18324
+ const labelStyle = {
18325
+ fontFamily: theme.fonts.family,
18326
+ fontSize: theme.fonts.sizes.small,
18327
+ fontWeight: theme.fonts.weights.normal,
18328
+ fill: theme.colors.text,
18329
+ lineHeight: 1.2
18330
+ };
18331
+ const entries = pointCategories.map((cat) => ({
18332
+ label: cat,
18333
+ color: pointCategoryColors.get(cat),
18334
+ shape: "circle"
18335
+ }));
18336
+ const bottomChoroplethHeight = legendPosition === "bottom" ? continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0 : 0;
18337
+ const choroplethLegendGap = bottomChoroplethHeight > 0 ? 8 : 0;
18338
+ const legendX = pointLegendOverlay ? fullArea.x + OVERLAY_LEGEND_INSET : fullArea.x;
18339
+ const legendY = pointLegendOverlay ? fullArea.y + OVERLAY_LEGEND_INSET : fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethHeight + choroplethLegendGap;
18340
+ const legendWidth = pointLegendOverlay ? fullArea.width - OVERLAY_LEGEND_INSET * 2 : fullArea.width;
18341
+ const swatchSize = 10;
18342
+ const swatchGap = 6;
18343
+ const entryGap = 16;
18344
+ pointCategoricalLegend = {
18345
+ type: "categorical",
18346
+ position: pointLegendOverlay ? "top-left" : "bottom",
18347
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
18348
+ labelStyle,
18349
+ entries,
18350
+ swatchSize,
18351
+ swatchGap,
18352
+ entryGap,
18353
+ swatchChipFill: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.04)"
18354
+ };
18355
+ }
18356
+ if (pts.color && pts.color.type === "quantitative" && showLegend) {
18357
+ const ptColorValues = [];
18358
+ for (const row of pts.data) {
18359
+ const v = row[pts.color.field];
18360
+ if (v != null) {
18361
+ const n = Number(v);
18362
+ if (Number.isFinite(n)) ptColorValues.push(n);
18363
+ }
18364
+ }
18365
+ const ptLegendChannel = {
18366
+ ...pts.color,
18367
+ scale: { ...pts.color.scale, type: "quantile" }
18368
+ };
18369
+ const ptContinuousContent = computeContinuousLegendContentForChannel(
18370
+ ptColorValues,
18371
+ ptLegendChannel,
18372
+ theme,
18373
+ fullArea.width
18374
+ );
18375
+ if (ptContinuousContent) {
18376
+ const labelStyle = {
18377
+ fontFamily: theme.fonts.family,
18378
+ fontSize: theme.fonts.sizes.small,
18379
+ fontWeight: theme.fonts.weights.normal,
18380
+ fill: theme.colors.text,
18381
+ lineHeight: 1.3,
18382
+ fontVariant: "tabular-nums"
18383
+ };
18384
+ const bottomChoroplethH = legendPosition === "bottom" ? continuousLegend?.bounds.height ?? categoricalLegend?.bounds.height ?? 0 : 0;
18385
+ const choroplethLegendGap = bottomChoroplethH > 0 ? 8 : 0;
18386
+ const legendX = pointLegendOverlay ? fullArea.x + OVERLAY_LEGEND_INSET : fullArea.x;
18387
+ const legendY = pointLegendOverlay ? fullArea.y + OVERLAY_LEGEND_INSET : fullArea.y + mapAreaHeight + legendReserveGap + bottomChoroplethH + choroplethLegendGap;
18388
+ const ptLabelRowHeight = Math.ceil(theme.fonts.sizes.small * 1.3);
18389
+ const ptLegendHeight = ptContinuousContent.barHeight + CONTINUOUS_LABEL_GAP + ptLabelRowHeight;
18390
+ const bounds = {
18391
+ x: legendX,
18392
+ y: legendY,
18393
+ width: ptContinuousContent.barWidth,
18394
+ height: ptLegendHeight
18395
+ };
18396
+ const bar = {
18397
+ x: bounds.x,
18398
+ y: bounds.y,
18399
+ width: ptContinuousContent.barWidth,
18400
+ height: ptContinuousContent.barHeight
18401
+ };
18402
+ pointContinuousLegend = {
18403
+ type: "continuous",
18404
+ mode: ptContinuousContent.mode,
18405
+ position: pointLegendOverlay ? "top-left" : "bottom",
18406
+ bounds,
18407
+ labelStyle,
18408
+ bar,
18409
+ colorStops: ptContinuousContent.colorStops,
18410
+ bins: ptContinuousContent.bins.map((b) => ({ ...b, x: b.x + bar.x })),
18411
+ ticks: ptContinuousContent.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
18412
+ labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize
18413
+ };
18414
+ }
18415
+ }
18416
+ }
17927
18417
  const tooltipChannels = mapSpec.encoding.tooltip ? Array.isArray(mapSpec.encoding.tooltip) ? mapSpec.encoding.tooltip : [mapSpec.encoding.tooltip] : null;
17928
18418
  const tooltipDescriptors = /* @__PURE__ */ new Map();
17929
18419
  for (const mark of featureMarks) {
@@ -17941,12 +18431,12 @@ function compileMap(spec, options) {
17941
18431
  });
17942
18432
  }
17943
18433
  }
17944
- } else if (isQuantitative && mark.data[colorEncoding.field] != null) {
18434
+ } else if (colorEncoding && isQuantitative && mark.data[colorEncoding.field] != null) {
17945
18435
  fields.push({
17946
18436
  label: colorEncoding.title ?? colorEncoding.field,
17947
18437
  value: formatter(Number(mark.data[colorEncoding.field]))
17948
18438
  });
17949
- } else if (!isQuantitative && mark.data[colorEncoding.field] != null) {
18439
+ } else if (colorEncoding && !isQuantitative && mark.data[colorEncoding.field] != null) {
17950
18440
  fields.push({
17951
18441
  label: colorEncoding.title ?? colorEncoding.field,
17952
18442
  value: String(mark.data[colorEncoding.field])
@@ -17954,13 +18444,63 @@ function compileMap(spec, options) {
17954
18444
  }
17955
18445
  tooltipDescriptors.set(String(mark.id), { fields });
17956
18446
  }
18447
+ if (mapSpec.points && pointMarks.length > 0) {
18448
+ const ptTooltipChannels = mapSpec.points.tooltip ? Array.isArray(mapSpec.points.tooltip) ? mapSpec.points.tooltip : [mapSpec.points.tooltip] : null;
18449
+ for (const pm of pointMarks) {
18450
+ const fields = [];
18451
+ if (ptTooltipChannels) {
18452
+ for (const ch of ptTooltipChannels) {
18453
+ const val = pm.data[ch.field];
18454
+ if (val != null) {
18455
+ fields.push({
18456
+ label: ch.title ?? ch.field,
18457
+ value: ch.type === "quantitative" ? formatter(Number(val)) : String(val)
18458
+ });
18459
+ }
18460
+ }
18461
+ } else {
18462
+ const pts = mapSpec.points;
18463
+ if (pts.key) {
18464
+ const val = pm.data[pts.key.field];
18465
+ if (val != null)
18466
+ fields.push({ label: pts.key.title ?? pts.key.field, value: String(val) });
18467
+ }
18468
+ if (pts.color) {
18469
+ const val = pm.data[pts.color.field];
18470
+ if (val != null)
18471
+ fields.push({
18472
+ label: pts.color.title ?? pts.color.field,
18473
+ value: pts.color.type === "quantitative" ? formatter(Number(val)) : String(val)
18474
+ });
18475
+ }
18476
+ if (pts.size) {
18477
+ const val = pm.data[pts.size.field];
18478
+ if (val != null)
18479
+ fields.push({ label: pts.size.title ?? pts.size.field, value: formatter(Number(val)) });
18480
+ }
18481
+ }
18482
+ if (fields.length > 0) {
18483
+ tooltipDescriptors.set(`point:${pm.key}`, { fields });
18484
+ }
18485
+ }
18486
+ }
18487
+ const a11yDataField = colorEncoding?.field;
17957
18488
  const a11y = {
17958
- altText: `Map showing ${geoFeatures.length} regions with ${isQuantitative ? "quantitative" : "categorical"} data`,
17959
- dataTableFallback: featureMarks.filter((m) => m.data).map((m) => [m.name ?? String(m.id), String(m.data?.[colorEncoding.field] ?? "")]),
18489
+ 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`,
18490
+ dataTableFallback: [
18491
+ ...featureMarks.filter((m) => m.data).map((m) => [
18492
+ m.name ?? String(m.id),
18493
+ a11yDataField ? String(m.data?.[a11yDataField] ?? "") : ""
18494
+ ]),
18495
+ ...pointMarks.map((pm) => [
18496
+ pm.key,
18497
+ mapSpec.points?.color ? String(pm.data[mapSpec.points.color.field] ?? "") : ""
18498
+ ])
18499
+ ],
17960
18500
  role: "img",
17961
- keyboardNavigable: featureMarks.length > 0
18501
+ keyboardNavigable: featureMarks.length > 0 || pointMarks.length > 0
17962
18502
  };
17963
- const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, compileWarnings);
18503
+ const resolvedFocus = resolveFocus(mapSpec.geo.focus, featureMarks, pointMarks, compileWarnings);
17964
18504
  const resolvedAnimation = resolveAnimation(mapSpec.animation);
17965
18505
  const mapAreaY = legendPosition === "top" && legendBlockHeight > 0 ? fullArea.y + legendBlockHeight + legendReserveGap : fullArea.y;
17966
18506
  const mapArea = {
@@ -17983,6 +18523,9 @@ function compileMap(spec, options) {
17983
18523
  borders,
17984
18524
  continuousLegend,
17985
18525
  categoricalLegend,
18526
+ pointMarks,
18527
+ pointCategoricalLegend,
18528
+ pointContinuousLegend,
17986
18529
  tooltipDescriptors,
17987
18530
  a11y,
17988
18531
  theme,
@@ -17996,6 +18539,46 @@ function compileMap(spec, options) {
17996
18539
  focus: resolvedFocus
17997
18540
  };
17998
18541
  }
18542
+ function buildBasemapMarks(opts) {
18543
+ const { geoFeatures, pathGen, neutralFill, neutralStroke, droppedFeatures } = opts;
18544
+ const marks = [];
18545
+ let animIndex = 0;
18546
+ for (const feat of geoFeatures) {
18547
+ const pathD = pathGen(feat);
18548
+ if (!pathD) {
18549
+ const name2 = feat.properties?.name ?? String(feat.id);
18550
+ droppedFeatures.push(name2);
18551
+ continue;
18552
+ }
18553
+ const featureId = feat.id;
18554
+ const props = feat.properties;
18555
+ const name = props?.name ?? props?.NAME;
18556
+ const b = pathGen.bounds(feat);
18557
+ const bx = Number.isFinite(b[0][0]) ? b[0][0] : 0;
18558
+ const by = Number.isFinite(b[0][1]) ? b[0][1] : 0;
18559
+ const bw = Number.isFinite(b[1][0]) ? b[1][0] - bx : 0;
18560
+ const bh = Number.isFinite(b[1][1]) ? b[1][1] - by : 0;
18561
+ const bounds = { x: bx, y: by, width: bw, height: bh };
18562
+ const rawCentroid = pathGen.centroid(feat);
18563
+ const cx = Number.isFinite(rawCentroid[0]) ? rawCentroid[0] : bx + bw / 2;
18564
+ const cy = Number.isFinite(rawCentroid[1]) ? rawCentroid[1] : by + bh / 2;
18565
+ marks.push({
18566
+ type: "map-feature",
18567
+ path: pathD,
18568
+ fill: neutralFill,
18569
+ stroke: neutralStroke,
18570
+ strokeWidth: 0.5,
18571
+ id: featureId,
18572
+ name,
18573
+ data: null,
18574
+ aria: { role: "img", label: name ?? String(featureId) },
18575
+ animationIndex: animIndex++,
18576
+ bounds,
18577
+ centroid: [cx, cy]
18578
+ });
18579
+ }
18580
+ return marks;
18581
+ }
17999
18582
  function buildQuantitativeMarks(opts) {
18000
18583
  const {
18001
18584
  geoFeatures,
@@ -18017,7 +18600,7 @@ function buildQuantitativeMarks(opts) {
18017
18600
  if (!Number.isNaN(v)) values.push(v);
18018
18601
  }
18019
18602
  }
18020
- const paletteStops = [...SEQUENTIAL_PALETTES[palette] ?? SEQUENTIAL_PALETTES.blue];
18603
+ const paletteStops = [...SEQUENTIAL_PALETTES2[palette] ?? SEQUENTIAL_PALETTES2.blue];
18021
18604
  const colorScale = quantile2().domain(values).range(paletteStops);
18022
18605
  const marks = [];
18023
18606
  let animIndex = 0;
@@ -18080,6 +18663,7 @@ function buildCategoricalMarks(opts) {
18080
18663
  pathGen,
18081
18664
  joined,
18082
18665
  colorField,
18666
+ colorScale: scaleConfig,
18083
18667
  isDarkMode,
18084
18668
  neutralFill,
18085
18669
  neutralStroke,
@@ -18087,11 +18671,18 @@ function buildCategoricalMarks(opts) {
18087
18671
  fullArea,
18088
18672
  mapAreaHeight,
18089
18673
  legendReserveGap,
18674
+ legendPosition,
18090
18675
  showLegend,
18091
18676
  droppedFeatures
18092
18677
  } = opts;
18093
18678
  const categories = [];
18094
- const seen = /* @__PURE__ */ new Set();
18679
+ const explicitDomain = scaleConfig?.domain;
18680
+ if (explicitDomain && Array.isArray(explicitDomain)) {
18681
+ for (const cat of explicitDomain) {
18682
+ categories.push(String(cat));
18683
+ }
18684
+ }
18685
+ const seen = new Set(categories);
18095
18686
  for (const row of joined.values()) {
18096
18687
  const raw = row[colorField];
18097
18688
  if (raw != null) {
@@ -18102,9 +18693,11 @@ function buildCategoricalMarks(opts) {
18102
18693
  }
18103
18694
  }
18104
18695
  }
18696
+ const explicitRange = scaleConfig?.range;
18697
+ const colorSource = explicitRange && Array.isArray(explicitRange) ? explicitRange : CATEGORICAL_PALETTE;
18105
18698
  const categoryColors = /* @__PURE__ */ new Map();
18106
18699
  for (let i = 0; i < categories.length; i++) {
18107
- categoryColors.set(categories[i], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
18700
+ categoryColors.set(categories[i], colorSource[i % colorSource.length]);
18108
18701
  }
18109
18702
  const marks = [];
18110
18703
  let animIndex = 0;
@@ -18172,14 +18765,14 @@ function buildCategoricalMarks(opts) {
18172
18765
  shape: "square"
18173
18766
  }));
18174
18767
  const legendX = fullArea.x;
18175
- const legendY = fullArea.y + mapAreaHeight + legendReserveGap;
18768
+ const legendY = legendPosition === "bottom" ? fullArea.y + mapAreaHeight + legendReserveGap : fullArea.y + legendReserveGap;
18176
18769
  const legendWidth = fullArea.width;
18177
18770
  const swatchSize = 10;
18178
18771
  const swatchGap = 6;
18179
18772
  const entryGap = 16;
18180
18773
  legend = {
18181
18774
  type: "categorical",
18182
- position: "bottom",
18775
+ position: legendPosition,
18183
18776
  bounds: { x: legendX, y: legendY, width: legendWidth, height: swatchSize + 6 },
18184
18777
  labelStyle,
18185
18778
  entries,
@@ -18191,8 +18784,40 @@ function buildCategoricalMarks(opts) {
18191
18784
  }
18192
18785
  return { marks, legend };
18193
18786
  }
18194
- function resolveFocus(focus, features, warnings) {
18787
+ function resolveFocus(focus, features, pointMarks, warnings) {
18195
18788
  if (focus === null || focus === void 0) return null;
18789
+ if (typeof focus === "object" && !Array.isArray(focus) && "points" in focus) {
18790
+ const filter = focus.points;
18791
+ const matched = filter === true ? pointMarks : pointMarks.filter((p) => p.data[filter.field] === filter.value);
18792
+ if (matched.length === 0) {
18793
+ const detail = filter === true ? "the map has no points to fit" : `no points match { field: "${filter.field}", value: ${JSON.stringify(filter.value)} }`;
18794
+ warnings.push({
18795
+ code: "FOCUS_UNMATCHED",
18796
+ message: `geo.focus.points is set but ${detail}`,
18797
+ context: {}
18798
+ });
18799
+ return null;
18800
+ }
18801
+ const padding2 = focus.padding ?? 16;
18802
+ let minX2 = Infinity;
18803
+ let minY2 = Infinity;
18804
+ let maxX2 = -Infinity;
18805
+ let maxY2 = -Infinity;
18806
+ for (const p of matched) {
18807
+ minX2 = Math.min(minX2, p.cx);
18808
+ minY2 = Math.min(minY2, p.cy);
18809
+ maxX2 = Math.max(maxX2, p.cx);
18810
+ maxY2 = Math.max(maxY2, p.cy);
18811
+ }
18812
+ const w = Math.max(maxX2 - minX2, 1);
18813
+ const h = Math.max(maxY2 - minY2, 1);
18814
+ return {
18815
+ target: { x: minX2, y: minY2, width: w, height: h, padding: padding2 },
18816
+ // No feature ids: focus-dim is a feature concept, so a points focus
18817
+ // dims no features (there are no "other" features to mute).
18818
+ ids: []
18819
+ };
18820
+ }
18196
18821
  let ids;
18197
18822
  let padding = 16;
18198
18823
  if (typeof focus === "string" || typeof focus === "number") {
@@ -18252,6 +18877,9 @@ function emptyLayout3(chrome, theme, options, watermark) {
18252
18877
  },
18253
18878
  continuousLegend: null,
18254
18879
  categoricalLegend: null,
18880
+ pointMarks: [],
18881
+ pointCategoricalLegend: null,
18882
+ pointContinuousLegend: null,
18255
18883
  tooltipDescriptors: /* @__PURE__ */ new Map(),
18256
18884
  a11y: {
18257
18885
  altText: "Empty map",
@@ -20902,7 +21530,7 @@ import {
20902
21530
  HPAD_COMPACT_MIN as HPAD_COMPACT_MIN4,
20903
21531
  pickLabelColor as pickLabelColor4,
20904
21532
  resolveTheme as resolveTheme6,
20905
- SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES2
21533
+ SEQUENTIAL_PALETTES as SEQUENTIAL_PALETTES3
20906
21534
  } from "@opendata-ai/openchart-core";
20907
21535
  var TILE_CORNER_RADIUS = 6;
20908
21536
  var TILE_STROKE_WIDTH = 1;
@@ -20980,7 +21608,7 @@ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDa
20980
21608
  const values = Array.from(stateValueMap.values());
20981
21609
  const min4 = values.length > 0 ? Math.min(...values) : 0;
20982
21610
  const max4 = values.length > 0 ? Math.max(...values) : 100;
20983
- const paletteStops = [...SEQUENTIAL_PALETTES2[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES2.blue];
21611
+ const paletteStops = [...SEQUENTIAL_PALETTES3[tilemapSpec.palette] ?? SEQUENTIAL_PALETTES3.blue];
20984
21612
  const baseColor = isDarkMode ? paletteStops[0] : paletteStops[paletteStops.length - 1];
20985
21613
  const opacityRange = isDarkMode ? [0.15, 1] : [0.2, 1];
20986
21614
  const opacityScale = linear2().domain([min4, max4]).range(opacityRange).clamp(true);
@@ -22260,6 +22888,38 @@ function applySparklineDefaults(spec, theme) {
22260
22888
  }
22261
22889
  return { ...spec, markDef: newMarkDef };
22262
22890
  }
22891
+ function resolveScaleInvert(resolved) {
22892
+ if (!resolved) return void 0;
22893
+ const { scale, type } = resolved;
22894
+ if (type === "band" || type === "point") {
22895
+ const domain = scale.domain();
22896
+ const positions = domain.map((v) => {
22897
+ const pos = scale(v) ?? 0;
22898
+ return type === "band" ? pos + scale.bandwidth() / 2 : pos;
22899
+ });
22900
+ const range4 = scale.range();
22901
+ return {
22902
+ topPixel: range4[0],
22903
+ bottomPixel: range4[range4.length - 1],
22904
+ topData: 0,
22905
+ bottomData: 0,
22906
+ domain,
22907
+ positions
22908
+ };
22909
+ }
22910
+ const invert = scale.invert;
22911
+ if (typeof invert !== "function") return void 0;
22912
+ const range3 = scale.range();
22913
+ const topPx = range3[0];
22914
+ const bottomPx = range3[range3.length - 1];
22915
+ const topVal = invert.call(scale, topPx);
22916
+ const bottomVal = invert.call(scale, bottomPx);
22917
+ const topData = topVal instanceof Date ? +topVal : Number(topVal);
22918
+ const bottomData = bottomVal instanceof Date ? +bottomVal : Number(bottomVal);
22919
+ if (!Number.isFinite(topData) || !Number.isFinite(bottomData)) return void 0;
22920
+ const scaleType = type;
22921
+ return { topPixel: topPx, bottomPixel: bottomPx, topData, bottomData, scaleType };
22922
+ }
22263
22923
  function compileChart(spec, optionsInput) {
22264
22924
  const sugarWarnings = [];
22265
22925
  const expandedSpec = spec && typeof spec === "object" && !Array.isArray(spec) ? expandSpecSugar(spec, sugarWarnings) : spec;
@@ -22460,12 +23120,35 @@ function compileChart(spec, optionsInput) {
22460
23120
  }
22461
23121
  };
22462
23122
  }
23123
+ let facetDirection = "wrap";
23124
+ const rowChannel = renderSpec.encoding.row;
23125
+ const colChannel = renderSpec.encoding.column;
23126
+ if (rowChannel?.field || colChannel?.field) {
23127
+ const channel = rowChannel?.field ? rowChannel : colChannel;
23128
+ facetDirection = rowChannel?.field ? "row" : "column";
23129
+ const desugared = { ...renderSpec.encoding };
23130
+ delete desugared.row;
23131
+ delete desugared.column;
23132
+ desugared.facet = {
23133
+ field: channel.field,
23134
+ type: channel.type,
23135
+ sort: channel.sort,
23136
+ ...facetDirection === "row" ? { columns: 1 } : {}
23137
+ };
23138
+ renderSpec = { ...renderSpec, encoding: desugared };
23139
+ const chartDesugared = { ...chartSpec.encoding };
23140
+ delete chartDesugared.row;
23141
+ delete chartDesugared.column;
23142
+ chartDesugared.facet = desugared.facet;
23143
+ chartSpec = { ...chartSpec, encoding: chartDesugared };
23144
+ }
22463
23145
  const facetChannel = renderSpec.encoding.facet;
22464
23146
  if (facetChannel?.field) {
22465
23147
  return compileFaceted(
22466
23148
  chartSpec,
22467
23149
  renderSpec,
22468
23150
  facetChannel,
23151
+ facetDirection,
22469
23152
  options,
22470
23153
  theme,
22471
23154
  strategy,
@@ -22625,6 +23308,8 @@ function compileChart(spec, optionsInput) {
22625
23308
  );
22626
23309
  assignAnimationIndices(marks, resolvedAnimation);
22627
23310
  const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
23311
+ const xInvert = resolveScaleInvert(scales.x);
23312
+ const yInvert = resolveScaleInvert(scales.y);
22628
23313
  return {
22629
23314
  area: chartArea,
22630
23315
  chrome,
@@ -22657,10 +23342,12 @@ function compileChart(spec, optionsInput) {
22657
23342
  watermark,
22658
23343
  display: chartSpec.display,
22659
23344
  crosshair,
22660
- measureText: options.measureText
23345
+ measureText: options.measureText,
23346
+ ...xInvert ? { xInvert } : {},
23347
+ ...yInvert ? { yInvert } : {}
22661
23348
  };
22662
23349
  }
22663
- function compileFaceted(chartSpec, renderSpec, facetChannel, options, theme, strategy, watermark, resolvedAnimation, crosshair, rawSpec, optionsInput) {
23350
+ function compileFaceted(chartSpec, renderSpec, facetChannel, facetDirection, options, theme, strategy, watermark, resolvedAnimation, crosshair, rawSpec, optionsInput) {
22664
23351
  const measure = createMeasureFn(options.measureText);
22665
23352
  const panelEncoding = { ...renderSpec.encoding };
22666
23353
  delete panelEncoding.facet;
@@ -22708,7 +23395,8 @@ function compileFaceted(chartSpec, renderSpec, facetChannel, options, theme, str
22708
23395
  );
22709
23396
  const leftReservation = plan.leftGutter;
22710
23397
  const bottomReservation = plan.xAxisExtent;
22711
- const grid = computeFacetGrid(facetValues, facetChannel.columns, chartArea, {
23398
+ const effectiveColumns = facetDirection === "column" ? facetValues.length : facetChannel.columns;
23399
+ const grid = computeFacetGrid(facetValues, effectiveColumns, chartArea, {
22712
23400
  left: leftReservation,
22713
23401
  bottom: bottomReservation
22714
23402
  });
@@ -22724,8 +23412,10 @@ function compileFaceted(chartSpec, renderSpec, facetChannel, options, theme, str
22724
23412
  }
22725
23413
  }
22726
23414
  const resolveConfig = chartSpec.resolve;
22727
- const yResolve = resolveConfig?.scale?.y ?? "shared";
22728
- const xResolve = resolveConfig?.scale?.x ?? "shared";
23415
+ const xDefault = facetDirection === "column" ? "independent" : "shared";
23416
+ const yDefault = facetDirection === "row" ? "independent" : "shared";
23417
+ const yResolve = resolveConfig?.scale?.y ?? yDefault;
23418
+ const xResolve = resolveConfig?.scale?.x ?? xDefault;
22729
23419
  const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
22730
23420
  const sharedXDomain = xResolve === "shared" && fullScales.x ? fullScales.x.scale.domain() : void 0;
22731
23421
  const sharedYDomain = yResolve === "shared" && fullScales.y ? fullScales.y.scale.domain() : void 0;
@@ -22790,6 +23480,16 @@ function compileFaceted(chartSpec, renderSpec, facetChannel, options, theme, str
22790
23480
  tickMarks: false
22791
23481
  };
22792
23482
  }
23483
+ const isBottomRow = gridPanel.row === grid.rows - 1;
23484
+ if (xResolve === "shared" && facetDirection === "row" && !isBottomRow && panelAxes.x) {
23485
+ panelAxes.x = {
23486
+ ...panelAxes.x,
23487
+ ticks: [],
23488
+ label: void 0,
23489
+ domainLine: false,
23490
+ tickMarks: false
23491
+ };
23492
+ }
22793
23493
  const panelMarks = renderer ? renderer(
22794
23494
  panelSpecWithDomains,
22795
23495
  panelScales,
@@ -22843,10 +23543,11 @@ function compileFaceted(chartSpec, renderSpec, facetChannel, options, theme, str
22843
23543
  annotations: panelAnnotations,
22844
23544
  header: {
22845
23545
  text: gridPanel.key,
22846
- x: gridPanel.headerPos.x,
23546
+ x: facetDirection === "row" ? gridPanel.area.x : gridPanel.headerPos.x,
22847
23547
  y: gridPanel.headerPos.y,
22848
23548
  fontSize: theme.fonts.sizes.body,
22849
- fontWeight: theme.fonts.weights.medium
23549
+ fontWeight: theme.fonts.weights.medium,
23550
+ ...facetDirection === "row" ? { textAnchor: "start" } : {}
22850
23551
  }
22851
23552
  });
22852
23553
  }
@@ -22914,7 +23615,8 @@ function compileFaceted(chartSpec, renderSpec, facetChannel, options, theme, str
22914
23615
  panels: panelLayouts,
22915
23616
  facetField: facetChannel.field,
22916
23617
  columns: grid.columns,
22917
- sharedScales: yResolve === "shared" && xResolve === "shared"
23618
+ sharedScales: yResolve === "shared" && xResolve === "shared",
23619
+ direction: facetDirection
22918
23620
  }
22919
23621
  };
22920
23622
  }