ggaction 0.0.24 → 0.0.26

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.
Files changed (217) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -2
  3. package/knowledge/action-cards.json +1 -1
  4. package/knowledge/intent-taxonomy.json +1 -1
  5. package/knowledge/mcp-resources.json +1 -1
  6. package/knowledge/task-resolver.js +1 -1
  7. package/package.json +1 -1
  8. package/src/accessibility.js +13 -0
  9. package/src/actions/canvas/actions.js +2 -1
  10. package/src/actions/canvas/fitting.js +68 -13
  11. package/src/actions/charts/beeswarm.js +2 -1
  12. package/src/actions/charts/ecdf.js +2 -1
  13. package/src/actions/charts/endpoints.js +25 -16
  14. package/src/actions/charts/heatmap.js +5 -3
  15. package/src/actions/charts/missing.js +17 -0
  16. package/src/actions/charts/radar.js +70 -33
  17. package/src/actions/charts/raincloud.js +18 -4
  18. package/src/actions/charts/rug-strip.js +2 -2
  19. package/src/actions/charts/shared.js +17 -0
  20. package/src/actions/composition/actions.js +24 -7
  21. package/src/actions/composition/adjacent.js +90 -0
  22. package/src/actions/composition/index.js +11 -1
  23. package/src/actions/composition/layer.js +42 -0
  24. package/src/actions/composition/layerPlan.js +176 -0
  25. package/src/actions/composition/targets.js +105 -0
  26. package/src/actions/data/derived.js +3 -0
  27. package/src/actions/data/filter.js +1 -1
  28. package/src/actions/data/raincloudFilter.js +3 -1
  29. package/src/actions/encodings/appearance.js +26 -7
  30. package/src/actions/encodings/channels.js +1 -0
  31. package/src/actions/encodings/color/continuous.js +37 -34
  32. package/src/actions/encodings/color/index.js +57 -43
  33. package/src/actions/encodings/color/policy.js +15 -0
  34. package/src/actions/encodings/density/resolve.js +3 -2
  35. package/src/actions/encodings/density.js +2 -2
  36. package/src/actions/encodings/offset.js +2 -0
  37. package/src/actions/encodings/pathOrder.js +2 -0
  38. package/src/actions/encodings/position/areaAlignment.js +42 -0
  39. package/src/actions/encodings/position/index.js +37 -3
  40. package/src/actions/encodings/position/policies/line.js +1 -1
  41. package/src/actions/encodings/ranged.js +2 -0
  42. package/src/actions/encodings/remove.js +13 -10
  43. package/src/actions/encodings/ruleAppearance.js +14 -2
  44. package/src/actions/facets/actions.js +1 -1
  45. package/src/actions/guides/axes/axes.js +2 -1
  46. package/src/actions/guides/axes/edit.js +1 -1
  47. package/src/actions/guides/axes/labels.js +2 -1
  48. package/src/actions/guides/axes/parallel/lifecycle.js +11 -4
  49. package/src/actions/guides/axes/parallel/policy.js +5 -2
  50. package/src/actions/guides/axes/parallel/resolve.js +3 -1
  51. package/src/actions/guides/axes/parallel.js +9 -4
  52. package/src/actions/guides/axes/titles.js +1 -1
  53. package/src/actions/guides/facadeLegend.js +3 -2
  54. package/src/actions/guides/grids/grid.js +3 -2
  55. package/src/actions/guides/grids/resolve.js +2 -1
  56. package/src/actions/guides/guides.js +3 -2
  57. package/src/actions/guides/legends/automatic.js +28 -0
  58. package/src/actions/guides/legends/blocks.js +15 -21
  59. package/src/actions/guides/legends/categorical/actions.js +1 -1
  60. package/src/actions/guides/legends/categorical/components.js +4 -3
  61. package/src/actions/guides/legends/categorical/layout.js +5 -2
  62. package/src/actions/guides/legends/categorical/options.js +9 -14
  63. package/src/actions/guides/legends/categorical/recipes.js +3 -0
  64. package/src/actions/guides/legends/categorical/resolve.js +7 -5
  65. package/src/actions/guides/legends/colorSize.js +51 -14
  66. package/src/actions/guides/legends/continuous/common.js +1 -1
  67. package/src/actions/guides/legends/continuous/gradient.js +1 -1
  68. package/src/actions/guides/legends/continuous/opacity.js +2 -0
  69. package/src/actions/guides/legends/edit.js +1 -1
  70. package/src/actions/guides/legends/focused.js +2 -2
  71. package/src/actions/guides/legends/group.js +1 -1
  72. package/src/actions/guides/legends/lane.js +1 -1
  73. package/src/actions/guides/legends/lifecycle.js +2 -2
  74. package/src/actions/guides/legends/remove.js +18 -4
  75. package/src/actions/guides/legends/size.js +1 -1
  76. package/src/actions/guides/legends/transition.js +15 -6
  77. package/src/actions/guides/polar/axes/facade.js +4 -2
  78. package/src/actions/guides/polar/axes/labels.js +6 -1
  79. package/src/actions/guides/polar/axes/shared.js +4 -4
  80. package/src/actions/guides/polar/axes/titles.js +13 -2
  81. package/src/actions/guides/polar/grids.js +6 -3
  82. package/src/actions/guides/tickValues.js +7 -1
  83. package/src/actions/index.js +2 -0
  84. package/src/actions/marks/area/actions.js +9 -2
  85. package/src/actions/marks/area/materialize.js +1 -1
  86. package/src/actions/marks/bar/materialize.js +4 -4
  87. package/src/actions/marks/lifecycle.js +14 -0
  88. package/src/actions/marks/line/actions.js +3 -0
  89. package/src/actions/marks/line/materialize.js +4 -2
  90. package/src/actions/marks/point/edit.js +2 -0
  91. package/src/actions/marks/point/jitter.js +2 -0
  92. package/src/actions/marks/point/materialize.js +5 -4
  93. package/src/actions/marks/point/packing.js +2 -0
  94. package/src/actions/marks/references.js +5 -3
  95. package/src/actions/marks/remove.js +16 -0
  96. package/src/actions/marks/shared.js +2 -1
  97. package/src/actions/marks/text/actions.js +5 -2
  98. package/src/actions/overlays/index.js +168 -0
  99. package/src/actions/overlays/snapshot.js +45 -0
  100. package/src/actions/primitives/editGraphics.js +21 -27
  101. package/src/actions/primitives/semanticAction.js +4 -5
  102. package/src/actions/primitives/semanticValidation/guide.js +1 -0
  103. package/src/actions/primitives/semanticValidation/layer.js +7 -0
  104. package/src/actions/scales/consumers/common.js +10 -0
  105. package/src/actions/scales/consumers/families.js +1 -1
  106. package/src/actions/scales/preview.js +1 -1
  107. package/src/actions/selection/actions.js +63 -11
  108. package/src/actions/titles/actions.js +3 -14
  109. package/src/actions/titles/resolve.js +12 -9
  110. package/src/core/action.js +41 -25
  111. package/src/core/compositionScales.js +12 -0
  112. package/src/core/compositionState.js +16 -2
  113. package/src/core/immutable.js +11 -6
  114. package/src/core/programState.js +16 -0
  115. package/src/core/resourceReferences.js +19 -3
  116. package/src/core/validation.js +7 -0
  117. package/src/core/vocabulary.js +8 -2
  118. package/src/grammar/actionTargets.js +24 -0
  119. package/src/grammar/bin2d.js +7 -0
  120. package/src/grammar/colorSizeLegend.js +56 -5
  121. package/src/grammar/displayLabels.js +10 -0
  122. package/src/grammar/facetHeaders.js +1 -1
  123. package/src/grammar/facets/dependencies.js +10 -3
  124. package/src/grammar/facets/guides.js +2 -1
  125. package/src/grammar/facets/legendIntent.js +7 -5
  126. package/src/grammar/legendBlocks.js +1 -1
  127. package/src/grammar/legendCombinations.js +1 -1
  128. package/src/grammar/legendGroups.js +1 -1
  129. package/src/grammar/legendItemsOffset.js +12 -0
  130. package/src/grammar/legendOverflow.js +25 -0
  131. package/src/grammar/lineSeries.js +1 -1
  132. package/src/grammar/markSelection.js +23 -0
  133. package/src/grammar/overlay.js +51 -0
  134. package/src/grammar/parallelCoordinates.js +1 -208
  135. package/src/grammar/pathCommands.js +8 -2
  136. package/src/grammar/pathSeries.js +1 -1
  137. package/src/grammar/plotComposition.js +152 -0
  138. package/src/grammar/pointShapes.js +18 -0
  139. package/src/grammar/scales/transformed.js +1 -1
  140. package/src/grammar/schemas/concreteGraphic.js +8 -1
  141. package/src/grammar/schemas/graphicBounds.js +8 -1
  142. package/src/grammar/schemas/graphicTree.js +18 -2
  143. package/src/grammar/schemas/semanticPath.js +3 -0
  144. package/src/grammar/strokeStyle.js +14 -8
  145. package/src/grammar/text.js +72 -0
  146. package/src/inspection.js +1 -1
  147. package/src/inspectionDescriptors.js +1 -1
  148. package/src/layout/composition.js +30 -4
  149. package/src/layout/compositionLegend.js +91 -0
  150. package/src/layout/facetHeaderTitles.js +1 -1
  151. package/src/layout/labels.js +55 -27
  152. package/src/layout/legend.js +3 -2
  153. package/src/layout/legendGroup.js +1 -1
  154. package/src/layout/legendItems.js +7 -6
  155. package/src/layout/legendLane.js +1 -1
  156. package/src/layout/legendText.js +1 -1
  157. package/src/materialization/adjacentSource.js +27 -0
  158. package/src/materialization/composition.js +165 -52
  159. package/src/materialization/compositionLegends.js +120 -0
  160. package/src/materialization/compositionScales.js +125 -0
  161. package/src/materialization/compositionTitle.js +96 -0
  162. package/src/materialization/facetGuides/edit.js +22 -2
  163. package/src/materialization/facetGuides/layout.js +1 -1
  164. package/src/materialization/facetGuides/owner.js +4 -3
  165. package/src/materialization/facetGuides/ownerLayout.js +12 -6
  166. package/src/materialization/facetGuides/placement.js +19 -22
  167. package/src/materialization/facetGuides/preparation.js +17 -5
  168. package/src/materialization/facetHeaders.js +5 -1
  169. package/src/materialization/facets.js +1 -1
  170. package/src/materialization/guides/layout.js +1 -1
  171. package/src/materialization/guides/resources.js +1 -1
  172. package/src/materialization/guides/visibility.js +105 -0
  173. package/src/materialization/legendClip.js +1 -1
  174. package/src/materialization/legendGroups.js +1 -1
  175. package/src/materialization/legendItemsOffset.js +68 -0
  176. package/src/materialization/legendSuppression.js +22 -0
  177. package/src/materialization/legends.js +3 -2
  178. package/src/materialization/marks/capabilities.js +4 -0
  179. package/src/materialization/marks/index.js +5 -1
  180. package/src/materialization/marks/pathOrder.js +1 -1
  181. package/src/materialization/marks/policies.js +2 -1
  182. package/src/materialization/overlayItems.js +154 -0
  183. package/src/materialization/scales/policies/binnedPosition.js +43 -5
  184. package/src/materialization/scales/policies/series.js +12 -4
  185. package/src/materialization/scales/resolve.js +2 -0
  186. package/src/materialization/selection/items/bar.js +21 -3
  187. package/src/materialization/selection/items/common.js +18 -5
  188. package/src/materialization/selection/items/overlay.js +15 -0
  189. package/src/materialization/selection/items/path.js +1 -0
  190. package/src/materialization/selection/items/point.js +2 -3
  191. package/src/materialization/selection/items/tick.js +3 -12
  192. package/src/materialization/selection/path.js +2 -2
  193. package/src/materialization/selection/policies/bar.js +2 -0
  194. package/src/materialization/selection/policies/index.js +35 -1
  195. package/src/materialization/selection/policies/point.js +2 -0
  196. package/src/materialization/selection/policies/text.js +17 -0
  197. package/src/materialization/selection/rows.js +103 -0
  198. package/src/materialization/selection/state.js +19 -3
  199. package/src/materialization/selection/styles.js +8 -0
  200. package/src/materialization/text.js +6 -489
  201. package/src/materialization/textGeometry.js +503 -0
  202. package/src/materialization/textLines.js +20 -0
  203. package/src/persistence/validation.js +97 -24
  204. package/src/renderers/canvas/index.js +12 -7
  205. package/src/renderers/canvas/text.js +5 -5
  206. package/src/renderers/nativeBackend.js +25 -0
  207. package/src/renderers/pdf.js +3 -3
  208. package/src/renderers/png.js +4 -3
  209. package/src/renderers/svgSerialization.js +1 -643
  210. package/src/scene.js +5 -3
  211. package/src/selectors/plotComposition.js +115 -0
  212. package/src/selectors/selectionTargets.js +19 -0
  213. package/src/version.js +1 -1
  214. package/types/basic.d.ts +2 -2
  215. package/types/index.d.ts +11 -0
  216. package/types/inspection.d.ts +34 -0
  217. package/types/program.d.ts +553 -365
@@ -0,0 +1,105 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
2
+ import { axisGraphicIds } from "../../selectors/axes.js";
3
+ import { legendResourcePolicies } from "./resources.js";
4
+
5
+ // Visibility is retained guide intent. Paint is derived, not a deletion of the
6
+ // guide or its typography. Keeping geometry also preserves relative anchors.
7
+ export function splitGuideVisibility(args, parts = ["line", "ticks", "labels", "title"]) {
8
+ if (!isPlainObject(args)) return { args, visibility: {} };
9
+ if (args.ticksAndLabels !== undefined && (args.ticks !== undefined || args.labels !== undefined)) throw new Error("Axis edit cannot combine ticksAndLabels with ticks or labels.");
10
+ const out = { ...args }, visibility = {};
11
+ const take = (object, key) => {
12
+ if (!Object.hasOwn(object, "visible")) return;
13
+ if (typeof object.visible !== "boolean") throw new TypeError(`${key}.visible must be a boolean.`);
14
+ visibility[key] = object.visible; delete object.visible;
15
+ };
16
+ take(out, "all");
17
+ for (const part of parts) if (isPlainObject(out[part])) {
18
+ out[part] = { ...out[part] }; take(out[part], part);
19
+ if (Object.hasOwn(visibility, part) && !Object.keys(out[part]).length) delete out[part];
20
+ }
21
+ if (isPlainObject(out.ticksAndLabels)) {
22
+ out.ticksAndLabels = { ...out.ticksAndLabels };
23
+ for (const part of ["ticks", "labels"]) if (isPlainObject(out.ticksAndLabels[part])) {
24
+ out.ticksAndLabels[part] = { ...out.ticksAndLabels[part] };
25
+ take(out.ticksAndLabels[part], part);
26
+ if (Object.hasOwn(visibility, part) && !Object.keys(out.ticksAndLabels[part]).length) delete out.ticksAndLabels[part];
27
+ }
28
+ if ((Object.hasOwn(visibility, "ticks") || Object.hasOwn(visibility, "labels")) && !Object.keys(out.ticksAndLabels).length) delete out.ticksAndLabels;
29
+ }
30
+ return { args: out, visibility };
31
+ }
32
+
33
+ export function patchGuideVisibility(program, family, id, patch) {
34
+ if (!Object.keys(patch).length) return program;
35
+ const config = program.guideConfigs[family]?.[id];
36
+ if (!config) throw new Error(`Visibility requires an existing ${family} guide.`);
37
+ return program._withMaterializationConfig(["guides", family, id], {
38
+ ...config, visibility: { ...config.visibility, ...patch }
39
+ });
40
+ }
41
+
42
+ function paint(program, restore) {
43
+ const retained = program.materializationConfigs.guideVisibilityPaint ?? {};
44
+ const backups = {}, objects = { ...program.graphicSpec.objects };
45
+ let changed = false; const visited = new Set();
46
+ function visit(id, hide = false) {
47
+ const object = objects[id]; if (!object || visited.has(id)) return; visited.add(id);
48
+ const patch = (item, key) => {
49
+ const previous = retained[id]?.[key];
50
+ if (restore && previous === undefined || !restore && !hide) return item;
51
+ const properties = { ...item.properties };
52
+ if (restore) {
53
+ if (previous.opacity === null) delete properties.opacity;
54
+ else properties.opacity = previous.opacity;
55
+ } else {
56
+ (backups[id] ??= {})[key] = { opacity: properties.opacity ?? null };
57
+ properties.opacity = 0;
58
+ }
59
+ changed = true; return { ...item, properties };
60
+ };
61
+ let next = object;
62
+ if (object.items) next = { ...next, items: object.items.map(item => patch(item, item.id)) };
63
+ else if (object.type !== "canvas" && object.properties) next = patch(next, id);
64
+ objects[id] = next;
65
+ for (const child of object.children ?? []) visit(child, hide);
66
+ }
67
+ if (restore) {
68
+ if (!Object.keys(retained).length) return program;
69
+ for (const id of Object.keys(retained)) visit(id);
70
+ } else {
71
+ const parts = ["line", "ticks", "labels", "title"];
72
+ for (const [id, config] of Object.entries(program.guideConfigs.axis ?? {})) {
73
+ const channel = program.semanticSpec.guides.axis?.[id]?.channel ?? id;
74
+ if (channel === "parallel") continue;
75
+ const visibility = config.visibility ?? {};
76
+ axisGraphicIds(channel, id).forEach((graphic, index) => {
77
+ if (visibility.all === false || visibility[parts[index]] === false) visit(graphic, true);
78
+ });
79
+ }
80
+ for (const policy of legendResourcePolicies()) {
81
+ const visibility = program.guideConfigs.legend?.[policy.kind]?.visibility ?? {};
82
+ for (const id of policy.graphicIds) if (visibility.all === false ||
83
+ id.endsWith("Labels") && visibility.labels === false ||
84
+ id.endsWith("Title") && visibility.titleStyle === false) visit(id, true);
85
+ }
86
+ for (const [direction, config] of Object.entries(program.guideConfigs.grid ?? {})) {
87
+ if (config.visible === false) visit({horizontal:"horizontalGridLines",vertical:"verticalGridLines",theta:"thetaGridLines",radius:"radialGridCircles",radial:"radialGridCircles"}[direction], true);
88
+ }
89
+ }
90
+ let next = changed ? program._clone({ graphicSpec: cloneAndFreeze({ ...program.graphicSpec, objects }) }) : program;
91
+ return restore || !Object.keys(backups).length
92
+ ? next._withoutMaterializationConfig(["guideVisibilityPaint"])
93
+ : next._withMaterializationConfig(["guideVisibilityPaint"], backups);
94
+ }
95
+ export function restoreGuideVisibilityPaint(program) { return paint(program, true); }
96
+ export function applyGuideVisibilityPaint(program) { return paint(restoreGuideVisibilityPaint(program), false); }
97
+
98
+
99
+ // Grids change paint/geometry inside the plot and do not need cross-guide reflow.
100
+ export function withGuideVisibilityPaint(implementation) {
101
+ return function (args) {
102
+ if (this.context.deferGuideLayoutValidation === true) return implementation.call(this, args);
103
+ return applyGuideVisibilityPaint(implementation.call(restoreGuideVisibilityPaint(this), args));
104
+ };
105
+ }
@@ -14,7 +14,7 @@ function roles(kind,config){
14
14
  type:layer.type==='line'?'line':layer.type==='point'?config.channels.includes('shape')?'collection':'circle':hasRectRadius(layer)?'collection':'rect'
15
15
  }));
16
16
  const prefix=kind==='gradient'?'colorGradient':`${kind}Legend`;
17
- return [{id:`${prefix}Symbols`,type:'circle',point:true}];
17
+ return [{id:`${prefix}Symbols`,type:config.shapeScale===undefined?'circle':'collection',point:true}];
18
18
  }
19
19
  function wrapped(owner){return owner?.children?.some(id=>id.startsWith(clipPrefix));}
20
20
  export function hasLegendClipGraphics(program){
@@ -1 +1 @@
1
- import{localLegendPosition as c,localLegendPlacement as s}from"../grammar/legendGroups.js";import"../core/vocabulary.js";import{PLOT_LEGEND_POSITIONS as l}from"../layout/legend.js";function u(n,t){return n.guideConfigs.legendGroups?.[t]}function f(n,t){const e=s(t),o=e===void 0?u(n,t.target)??(n.context.facetLegendIntrinsic===!0?{placement:{position:t.position}}:void 0):{placement:e};if(o===void 0)return t;const i=o.placement.position,r=l.includes(i),p=r?t.direction==="horizontal"?"top":"right":i??(l.includes(t.position)?t.direction==="horizontal"?"top":"right":t.position??"right");return{...t,...e===void 0?{}:{blockLocalPosition:e.position},position:p,offset:0,align:"left",groupIntrinsic:!0,...r?{groupCorner:!0}:{},...p==="right"?{align:"center"}:{}}}export{f as intrinsicLegendConfig,u as legendGroup,s as localLegendPlacement,c as localLegendPosition};
1
+ import{localLegendPosition as u,localLegendPlacement as d,retainsLegendSlot as c}from"../grammar/legendGroups.js";import"../core/vocabulary.js";import{PLOT_LEGEND_POSITIONS as l}from"../layout/legend.js";function p(e,t){return e.guideConfigs.legendGroups?.[t]}function m(e,t){const n=d(t);if(e.context.facetLegendIntrinsic!==!0&&p(e,t.target)===void 0&&n?.position===t.position&&t.inferredOffset!==!1&&["left","right","top","bottom"].includes(t.position))return t;const o=n===void 0?p(e,t.target)??(e.context.facetLegendIntrinsic===!0?{placement:{position:t.position}}:void 0):{placement:n};if(o===void 0)return t;const i=o.placement.position,r=l.includes(i),s=r?t.direction==="horizontal"?"top":"right":i??(l.includes(t.position)?t.direction==="horizontal"?"top":"right":t.position??"right");return{...t,...n===void 0?{}:{blockLocalPosition:n.position},position:s,offset:0,align:"left",groupIntrinsic:!0,...r?{groupCorner:!0}:{},...s==="right"?{align:"center"}:{}}}export{m as intrinsicLegendConfig,p as legendGroup,d as localLegendPlacement,u as localLegendPosition,c as retainsLegendSlot};
@@ -0,0 +1,68 @@
1
+ import { cloneAndFreeze } from "../core/immutable.js";
2
+ import { legendResourcePolicies } from "./guides/resources.js";
3
+ import { translatedProperties } from "./graphicTranslation.js";
4
+ import { unionConcreteGraphicBounds } from "../grammar/schemas/graphicBounds.js";
5
+ import { editLegendBorderBounds } from "./legendBorder.js";
6
+
7
+ // Layout always works with the native, unshifted block. Retain its concrete
8
+ // baseline so an unrelated transaction can restore it without regenerating
9
+ // marks or accumulating translations. Rebuilt resources supersede old paint.
10
+ export function restoreLegendItemsOffset(program) {
11
+ const retained = program.materializationConfigs.legendItemsPaint;
12
+ if (!retained) return program;
13
+ const objects = { ...program.graphicSpec.objects };
14
+ for (const [id, { before, after }] of Object.entries(retained)) {
15
+ if (objects[id] === after || JSON.stringify(objects[id]) === JSON.stringify(after)) objects[id] = before;
16
+ }
17
+ return program._clone({ graphicSpec: cloneAndFreeze({ ...program.graphicSpec, objects }) })
18
+ ._withoutMaterializationConfig(["legendItemsPaint"]);
19
+ }
20
+
21
+ export function applyLegendItemsOffset(program) {
22
+ let next = restoreLegendItemsOffset(program);
23
+ if (!Object.values(next.guideConfigs.legend ?? {}).some(config => config.itemsOffset?.x || config.itemsOffset?.y)) return next;
24
+ const baseline = next.graphicSpec.objects, objects = { ...baseline }, retained = {};
25
+ const touched = new Set();
26
+ function shift(id, dx, dy) {
27
+ const object = objects[id];
28
+ if (!object) return;
29
+ touched.add(id);
30
+ const moved = { ...object };
31
+ if (object.properties) moved.properties = translatedProperties(object.type, object.properties, dx, dy);
32
+ if (object.clip) moved.clip = { ...object.clip, x: object.clip.x + dx, y: object.clip.y + dy };
33
+ if (object.items) moved.items = object.items.map(item => ({ ...item,
34
+ properties: translatedProperties(item.type ?? object.type, item.properties, dx, dy) }));
35
+ objects[id] = moved;
36
+ // Canvas children inherit the translated clipping window.
37
+ if (object.type !== "canvas") for (const child of object.children ?? []) shift(child, dx, dy);
38
+ }
39
+ const changed = [];
40
+ for (const policy of legendResourcePolicies()) {
41
+ const config = next.guideConfigs.legend?.[policy.kind];
42
+ const { x = 0, y = 0 } = config?.itemsOffset ?? {};
43
+ if (!x && !y) continue;
44
+ const body = policy.graphicIds.filter(id => !/(Title|Background)$/.test(id));
45
+ for (const id of body) shift(id, x, y);
46
+ changed.push({ policy, config });
47
+ }
48
+ if (!changed.length) return next;
49
+ next = next._clone({ graphicSpec: cloneAndFreeze({ ...next.graphicSpec, objects }) });
50
+ for (const { policy, config } of changed) {
51
+ const background = policy.graphicIds.find(id => id.endsWith("Background"));
52
+ if (!next.graphicSpec.objects[background]) continue;
53
+ touched.add(background);
54
+ const bounds = unionConcreteGraphicBounds(next.graphicSpec,
55
+ policy.graphicIds.filter(id => id !== background && next.graphicSpec.objects[id]), next.materializationConfigs.textMetrics);
56
+ if (!bounds) continue;
57
+ const padding = config.border?.padding ?? 0;
58
+ next = editLegendBorderBounds(next, background, { x: bounds.left - padding, y: bounds.top - padding,
59
+ width: bounds.right - bounds.left + padding * 2, height: bounds.bottom - bounds.top + padding * 2 });
60
+ }
61
+ for (const id of touched) {
62
+ const object = next.graphicSpec.objects[id];
63
+ if (baseline[id] && object !== baseline[id]) {
64
+ retained[id] = { before: baseline[id], after: object };
65
+ }
66
+ }
67
+ return next._withMaterializationConfig(["legendItemsPaint"], retained);
68
+ }
@@ -0,0 +1,22 @@
1
+ import { legendBlockChannels } from "../grammar/legendBlocks.js";
2
+
3
+ // Replayable authoring intent, independent of the current concrete legend blocks.
4
+ export function suppressLegendChannels(program, target, channels) {
5
+ const previous = program.guideConfigs.suppressedLegends?.[target]?.channels ?? [];
6
+ return program._withMaterializationConfig(["guides", "suppressedLegends", target],
7
+ { channels: [...new Set([...previous, ...channels])] });
8
+ }
9
+
10
+ export function restoreCreatedLegendChannels(program) {
11
+ let next = program;
12
+ for (const [target, { channels: suppressed }] of Object.entries(program.guideConfigs.suppressedLegends ?? {})) {
13
+ const active = new Set(Object.entries(program.guideConfigs.legend ?? {})
14
+ .filter(([, config]) => config.target === target)
15
+ .flatMap(([kind, config]) => legendBlockChannels(kind, config)));
16
+ const remaining = suppressed.filter(channel => !active.has(channel));
17
+ if (remaining.length === suppressed.length) continue;
18
+ next = remaining.length ? next._withMaterializationConfig(["guides", "suppressedLegends", target], { channels: remaining })
19
+ : next._withoutMaterializationConfig(["guides", "suppressedLegends", target]);
20
+ }
21
+ return next;
22
+ }
@@ -1,8 +1,9 @@
1
+ import {colorSizeScaleIds} from "../grammar/colorSizeLegend.js";
1
2
  import { findLayer } from "../selectors/layers.js";
2
3
  import { LEGEND_CONFIG_KINDS } from "../core/vocabulary.js";
3
4
 
4
5
  export function isOpacityLegendLayer(layer) {
5
- return ["point", "line", "bar", "rect"].includes(layer.mark?.type) &&
6
+ return ["point", "line", "area", "bar", "rect"].includes(layer.mark?.type) &&
6
7
  layer.encoding?.opacity?.scale !== undefined;
7
8
  }
8
9
 
@@ -24,6 +25,6 @@ export function materializedLegendUsesScale(program, id) {
24
25
  const order = program.semanticSpec.guides.legend?.[kind]?.order;
25
26
  const linkedScale = order?.channel === undefined ? undefined
26
27
  : findLayer(program, config.target)?.encoding?.[order.channel]?.scale;
27
- return config.colorScale === id || config.sizeScale === id || config.scale === id || config.scales?.includes(id) || linkedScale === id;
28
+ return (kind === "colorSize" ? colorSizeScaleIds(config).includes(id) : config.scale === id || config.scales?.includes(id)) || linkedScale === id;
28
29
  });
29
30
  }
@@ -1,3 +1,4 @@
1
+ import { isHorizontalLine } from "../../grammar/lineSeries.js";
1
2
  import { findDataset } from "../../selectors/datasets.js";
2
3
  import { findLayer } from "../../selectors/layers.js";
3
4
  import { findCoordinate } from "../../selectors/coordinates.js";
@@ -37,6 +38,7 @@ export function canMaterializeTick(_program, layer) {
37
38
  }
38
39
 
39
40
  export function canMaterializeLine(program, layer) {
41
+ if (layer.derivedBindings?.overlay) return hasCartesianPositionScales(layer);
40
42
  const parallel = layer.encoding?.parallel;
41
43
  if (parallel !== undefined) {
42
44
  const coordinate = findCoordinate(program, layer.coordinate);
@@ -45,6 +47,8 @@ export function canMaterializeLine(program, layer) {
45
47
  parallel.dimensions?.length >= 2 &&
46
48
  parallel.dimensions.every(dimension => dimension.scale !== undefined);
47
49
  }
50
+ if (isHorizontalLine(layer)) return canMaterializeLine(program, { ...layer,
51
+ encoding: { ...layer.encoding, x: layer.encoding.y, y: layer.encoding.x } });
48
52
  const dataset = findDataset(program, layer.data);
49
53
  const interval = dataset?.transform?.some(item => item.type === "interval");
50
54
  if (hasPolarPositionScales(layer)) {
@@ -36,6 +36,7 @@ export function getScaleConsumerMarkSteps(program, scaleIds) {
36
36
  }
37
37
 
38
38
  export function getMarkRematerializationStep(program, layer) {
39
+ if (layer.derivedBindings?.overlay) return { op: "rematerializeOverlay", args: { id: layer.id } };
39
40
  if (program.markConfigs?.[layer.id]?.statisticalReference !== undefined) {
40
41
  return { op: "rematerializeStatisticalReference", args: { id: layer.id } };
41
42
  }
@@ -63,7 +64,8 @@ export function getSourceDependentMarkSteps(program, sourceId) {
63
64
  const statistical = program.markConfigs?.[layer.id]
64
65
  ?.statisticalReference?.source === sourceId;
65
66
  const filteredCompanion = program.markConfigs?.[layer.id]?.markFilter?.owner === sourceId;
66
- return sourceOwned || statistical || filteredCompanion
67
+ const overlay = layer.derivedBindings?.overlay?.source === sourceId;
68
+ return sourceOwned || statistical || filteredCompanion || overlay
67
69
  ? [getMarkMaterializationStep(program, layer) ??
68
70
  getExistingMarkRematerializationStep(program, layer)].filter(
69
71
  step => step !== undefined
@@ -104,6 +106,7 @@ export function getPositionEncodingMaterializationSteps(program, layer, scaleId)
104
106
  }
105
107
 
106
108
  export function getScaleConsumerMaterializationMode(layer, channel) {
109
+ if (layer.derivedBindings?.overlay) return "defer";
107
110
  const policy = getMarkMaterializationPolicy(layer);
108
111
  if (policy === undefined) return "direct";
109
112
  if (POSITION_ENCODING_CHANNELS.includes(channel)) {
@@ -118,6 +121,7 @@ export function getScaleConsumerMaterializationMode(layer, channel) {
118
121
  }
119
122
 
120
123
  export function canDeferScaleConsumerApplication(layer) {
124
+ if (layer.derivedBindings?.overlay) return true;
121
125
  return getMarkMaterializationPolicy(layer)?.scaleApplication.deferWithMark === true;
122
126
  }
123
127
 
@@ -56,7 +56,7 @@ export function pathOrderCompatibility(program, layer) {
56
56
  reason: "does not support generated error or regression paths"
57
57
  };
58
58
  }
59
- if (layer.mark.type === "line" && isAggregate(layer.encoding?.y?.aggregate)) {
59
+ if (layer.mark.type === "line" && (isAggregate(layer.encoding?.y?.aggregate) || isAggregate(layer.encoding?.x?.aggregate))) {
60
60
  return { compatible: false, reason: "does not support aggregate lines" };
61
61
  }
62
62
  return { compatible: true, dataset };
@@ -51,7 +51,8 @@ const MARK_MATERIALIZATION_POLICIES = Object.freeze({
51
51
  acceptsResolvedScales: true,
52
52
  positionEncoding: Object.freeze({ incomplete: "scale", scaleFirst: true }),
53
53
  encoding: Object.freeze({
54
- sharedChannels: Object.freeze(["color", "stroke"]),
54
+ completeOnly: true,
55
+ sharedChannels: Object.freeze(["color", "stroke", "opacity"]),
55
56
  skipRematerialization: isIntentionallyEmptyArea
56
57
  }),
57
58
  scaleApplication: Object.freeze({ default: "defer" })
@@ -0,0 +1,154 @@
1
+ import { findCoordinate } from "../selectors/coordinates.js";
2
+ import { requireLayer } from "../selectors/layers.js";
3
+ import { requireMaterializedDataset } from "../selectors/datasets.js";
4
+ import { resolveBarItems, resolveBarItemDefinitions } from "./selection/items/bar.js";
5
+ import { resolvePointItems } from "./selection/items/point.js";
6
+ import { applyItemMissingPolicy } from "../grammar/itemMissing.js";
7
+ import { resolveBarChannels, resolveBarColorLayout } from "../grammar/bars/policy.js";
8
+ import { overlayGroupFields, overlayPositionEncoding, overlaySourceReason } from "../grammar/overlay.js";
9
+ import { mapContinuousScaleValues, mapOrdinalPositionValues } from "../grammar/scales/index.js";
10
+ import { deriveLineSeries, resolveLineBins } from "../grammar/lineSeries.js";
11
+ import { deriveAreaSeries, deriveCenteredAreaSeries, deriveDensityAreaSeries, layoutDensityAreaSeries } from "../grammar/areaSeries.js";
12
+ import { findUpstreamTransform } from "./dataProvenance.js";
13
+ import { requireSemanticScale } from "../selectors/scales.js";
14
+ import { uniqueFields, ownFields, channelMapFromRow } from "./selection/items/common.js";
15
+ import { pointGraphicAnchor } from "../grammar/pointShapes.js";
16
+ import { mapScaleConsumerValues } from "./scales/map.js";
17
+ import { resolveDirectionValues } from "../grammar/direction.js";
18
+
19
+ export function overlaySegments(group, binding) {
20
+ const segments = [[]];
21
+ for (const point of group.points) {
22
+ const before = segments.at(-1).at(-1);
23
+ if (binding.missing === "break" && ((before?.bin && point.bin &&
24
+ Math.abs(before.bin[1] - point.bin[0]) > 1e-10) ||
25
+ (Number.isFinite(before?.categoryIndex) && Number.isFinite(point.categoryIndex) &&
26
+ Math.abs(point.categoryIndex - before.categoryIndex) > 1))) segments.push([]);
27
+ segments.at(-1).push(point);
28
+ }
29
+ return segments.filter(points => points.length);
30
+ }
31
+
32
+ function sourcePointAnchor(program, source, item, graphics) {
33
+ const owner = program.graphicSpec.objects[source.id];
34
+ const graphic = graphics.get(item.graphicIds[0]);
35
+ const encoding = source.encoding.shape;
36
+ const shape = encoding?.scale ? mapScaleConsumerValues([item.fields[encoding.field]], program.resolvedScales[encoding.scale], "shape")[0]
37
+ : program.markConfigs[source.id].shape ?? "circle";
38
+ const angle = resolveDirectionValues(item.members, source.encoding.angle)?.[0];
39
+ return pointGraphicAnchor({ ...graphic, type: graphic.type ?? owner.type }, shape, angle);
40
+ }
41
+
42
+ function mapPosition(program, source, channel, value) {
43
+ const encoding = source.encoding[channel];
44
+ const scale = program.resolvedScales[encoding.scale];
45
+ const map = ["nominal", "ordinal"].includes(encoding.fieldType)
46
+ ? mapOrdinalPositionValues : mapContinuousScaleValues;
47
+ return map([value], scale)[0];
48
+ }
49
+
50
+ function itemGroups(items, source, binding) {
51
+ const fields = overlayGroupFields(source);
52
+ const groups = new Map();
53
+ for (const item of items) {
54
+ const tuple = fields.map(field => item.fields[field] ?? item.members[0]?.[field]);
55
+ const key = binding.series === "total" ? "total" : JSON.stringify(tuple);
56
+ if (!groups.has(key)) groups.set(key, { key, members: [], points: [] });
57
+ const group = groups.get(key);
58
+ group.members.push(...item.members);
59
+ group.points.push(item);
60
+ }
61
+ return [...groups.values()];
62
+ }
63
+
64
+ export function resolveOverlaySeries(program, binding, { geometry = true } = {}) {
65
+ const source = requireLayer(program, binding.source);
66
+ const dataset = applyItemMissingPolicy(source, requireMaterializedDataset(program, source.data));
67
+ const density = findUpstreamTransform(program, dataset, 'density');
68
+ const unsupported = overlaySourceReason(source, findCoordinate(program, source.coordinate)?.type, density);
69
+ if (unsupported) throw new Error(unsupported);
70
+ if (["bar", "point"].includes(source.mark.type)) {
71
+ const bar = source.mark.type === "bar";
72
+ if (!bar && !binding.orderBy) throw new Error("Connecting point results requires explicit orderBy x or y.");
73
+ const channels = bar ? resolveBarChannels(source) : { category: binding.orderBy, measure: binding.orderBy === "y" ? "x" : "y" };
74
+ // Scale domains need the new final item grain before source graphics have
75
+ // been rebuilt (for example, after facet filtering). Geometry consumers
76
+ // still resolve and validate the fully materialized source collections.
77
+ const items = geometry
78
+ ? bar ? resolveBarItems(program, source, dataset, "item") : resolvePointItems(program, source, dataset)
79
+ : bar ? resolveBarItemDefinitions(program, source, dataset)
80
+ : dataset.values.map(row => ({ fields: ownFields(row), channels: channelMapFromRow(row, source), members: [row] }));
81
+ const graphics = bar || !geometry ? undefined : new Map(program.graphicSpec.objects[source.id].items.map(item => [item.id, item]));
82
+ const anchors = items.map(item => {
83
+ const p = item.properties;
84
+ const measure = item.channels[`${channels.measure}2`] ?? item.channels[channels.measure];
85
+ const point = !geometry ? item.channels : bar ? p : sourcePointAnchor(program, source, item, graphics);
86
+ const endpoint = bar && geometry ? mapPosition(program, source, channels.measure, measure) : point[channels.measure];
87
+ const x = geometry && bar && channels.category === "x" ? p.x + p.width / 2 : channels.measure === "x" ? endpoint : point.x;
88
+ const y = geometry && bar && channels.category === "y" ? p.y + p.height / 2 : channels.measure === "y" ? endpoint : point.y;
89
+ const category = item.channels[channels.category];
90
+ const categoryEnd = item.channels[`${channels.category}2`];
91
+ const categoryScale = program.resolvedScales[source.encoding[channels.category].scale];
92
+ const categoryValue = bar && source.encoding[channels.category].bin ? (category + categoryEnd) / 2 : category;
93
+ return { ...item, x, y, measure, category,
94
+ fields: { ...item.fields, ...(bar ? { [source.encoding[channels.measure].field]: measure,
95
+ [source.encoding[channels.category].field]: categoryValue } : {}) },
96
+ channels: { ...item.channels, ...(bar ? { [channels.measure]: measure, [channels.category]: categoryValue } : {}) },
97
+ ...(["point", "band", "ordinal"].includes(categoryScale.type) ? { categoryIndex: categoryScale.domain.indexOf(category) } : {}),
98
+ ...(bar && channels.category === "x" && source.encoding.x.bin ? { bin: [category, categoryEnd] } : {}) };
99
+ }).filter(item => !geometry || Number.isFinite(item.x) && Number.isFinite(item.y));
100
+ let groups = itemGroups(anchors, source, binding);
101
+ if (bar && binding.series === "total") {
102
+ if (overlayGroupFields(source).length && !["stack", "fill", "diverging"].includes(resolveBarColorLayout(source))) {
103
+ throw new Error("A total overlay requires stacked source series; grouped and overlapping bars have separate endpoints.");
104
+ }
105
+ const cells = new Map();
106
+ for (const item of anchors) {
107
+ const key = JSON.stringify([item.category, item.bin, item.measure < 0 ? "negative" : "positive"]);
108
+ const previous = cells.get(key);
109
+ if (!previous || Math.abs(item.measure) > Math.abs(previous.measure)) cells.set(key, item);
110
+ }
111
+ groups = [false, true].map(negative => ({ key: negative ? "negative-total" : "positive-total",
112
+ points: [...cells.values()].filter(item => (item.measure < 0) === negative), members: anchors.flatMap(item => item.members) }))
113
+ .filter(group => group.points.length);
114
+ }
115
+ const direction = binding.orderBy ?? channels.category;
116
+ for (const group of groups) {
117
+ // Y categories run upward in data space. Preserve the source scale's order.
118
+ const sign = direction === "y" ? -1 : 1;
119
+ group.points.sort((a, b) => sign * (a[direction] - b[direction]));
120
+ }
121
+ return groups;
122
+ }
123
+ const x = source.encoding.x;
124
+ const derived = source.mark.type === "line"
125
+ ? deriveLineSeries(dataset.values, source, { scales: program.semanticSpec.scales,
126
+ xDomain: program.resolvedScales[x.scale]?.domain,
127
+ yDomain: program.resolvedScales[source.encoding.y.scale]?.domain,
128
+ ...(x.bin ? { xBinBoundaries: resolveLineBins(dataset.values, source, requireSemanticScale(program, x.scale)).boundaries } : {}) })
129
+ : density ? layoutDensityAreaSeries(deriveDensityAreaSeries(dataset.values, source, density), source.layout?.mode ?? source.encoding.color?.layout ?? "overlay")
130
+ : source.encoding.y?.stack === "center" ? deriveCenteredAreaSeries(dataset.values, source)
131
+ : deriveAreaSeries(dataset.values, source, { xDomain: program.resolvedScales[x.scale]?.domain,
132
+ yDomain: program.resolvedScales[source.encoding.y.scale]?.domain });
133
+ if (binding.series === "total" && derived.series.length > 1) {
134
+ throw new Error("Total overlays currently require stacked bars; use source series for line or area results.");
135
+ }
136
+ function endpoint(point, channel) {
137
+ if (channel === "y" && point.upper !== undefined) return point.upper;
138
+ if (source.mark.type === "area" && (source.layout?.mode ?? "overlay") === "overlay" &&
139
+ Object.hasOwn(source.encoding[`${channel}2`] ?? {}, "datum")) return point[channel];
140
+ return point[`${channel}2`] ?? point[channel];
141
+ }
142
+ return derived.series.map((series, index) => {
143
+ const members = series.sourceIndices ? series.sourceIndices.map(i => dataset.values[i])
144
+ : dataset.values.filter(row => Object.entries(series.key).every(([field, value]) => row[field] === value));
145
+ return { key: JSON.stringify([series.key, index]), members,
146
+ points: series.values.map((point, i) => ({ key: JSON.stringify([series.key, i]), members,
147
+ fields: { ...uniqueFields(members), ...series.key,
148
+ [overlayPositionEncoding(source, "x").field]: endpoint(point, "x"),
149
+ [overlayPositionEncoding(source, "y").field]: endpoint(point, "y") },
150
+ x: mapPosition(program, source, "x", endpoint(point, "x")),
151
+ y: mapPosition(program, source, "y", endpoint(point, "y")),
152
+ channels: { x: endpoint(point, "x"), y: endpoint(point, "y") } })) };
153
+ });
154
+ }
@@ -1,8 +1,10 @@
1
1
  import { isSourceOwnedText } from "../../../grammar/text.js";
2
- import { resolveHistogramBins } from "../../../grammar/histogram.js";
2
+ import { compositionChannel } from "../../../selectors/plotComposition.js";
3
+ import { findHistogramBinIndex, resolveHistogramBins } from "../../../grammar/histogram.js";
3
4
 
4
5
  export function resolveBinnedPositionDomain({
5
6
  valuesByConsumer,
7
+ program,
6
8
  channel,
7
9
  scale,
8
10
  id
@@ -14,9 +16,21 @@ export function resolveBinnedPositionDomain({
14
16
  );
15
17
  if (binnedPositions.length === 0) return undefined;
16
18
  const independentConsumers = valuesByConsumer.filter(({ consumer, scalePolicy }) =>
17
- scalePolicy !== false && !isSourceOwnedText(consumer.layer)
19
+ scalePolicy !== false && !isSourceOwnedText(consumer.layer) && consumer.layer.mark?.type !== "tick"
18
20
  );
19
- if (channel !== "x" || binnedPositions.length !== independentConsumers.length) {
21
+ const signature = consumer => {
22
+ const role = program && compositionChannel(program, consumer.encoding, consumer.layer.data);
23
+ return role && role.fieldType === "quantitative" ? JSON.stringify([
24
+ role.field ?? null, role.fields ?? null, role.measurement.unit, role.temporalUnit ?? null
25
+ ]) : undefined;
26
+ };
27
+ const expected = signature(binnedPositions[0].consumer);
28
+ // Binning changes sampling, not the physical value units of a position.
29
+ // Raw points and compatible summary paths can share that coordinate while
30
+ // retaining their own observations and the binned mark's original bins.
31
+ const compatibleUnbinned = expected !== undefined && independentConsumers.every(({ consumer }) =>
32
+ signature(consumer) === expected);
33
+ if (channel !== "x" || (binnedPositions.length !== independentConsumers.length && !compatibleUnbinned)) {
20
34
  throw new Error(
21
35
  `Binned scale "${id}" cannot be shared with an unbinned consumer.`
22
36
  );
@@ -27,11 +41,35 @@ export function resolveBinnedPositionDomain({
27
41
  if (binDefinitions.size !== 1) {
28
42
  throw new Error(`Binned scale "${id}" requires one shared bin definition.`);
29
43
  }
30
- return resolveHistogramBins({
44
+ const bins = resolveHistogramBins({
31
45
  values: binnedPositions.flatMap(item => item.values).filter(value => value !== undefined),
32
46
  bin: binnedPositions[0].consumer.encoding.bin,
33
47
  domain: scale.domain,
34
48
  nice: scale.nice ?? true,
35
49
  zero: scale.zero ?? false
36
- }).domain;
50
+ });
51
+ if (Array.isArray(scale.domain)) return bins.domain;
52
+ let [minimum, maximum] = bins.domain;
53
+ // A final-item filter freezes the bin partition so surviving observations
54
+ // are never rebinned. Those boundaries describe the partition, not a fixed
55
+ // display domain: auto scales follow the retained bins' complete intervals.
56
+ // An unfiltered consumer still contributes its full requested bin extent.
57
+ if (binnedPositions.every(({ consumer }) => program?.markConfigs[consumer.layer.id]?.markFilter)) {
58
+ let first = Infinity, last = -1;
59
+ for (const { values } of binnedPositions) for (const value of values) {
60
+ if (!Number.isFinite(value)) continue;
61
+ const index = findHistogramBinIndex(value, bins.boundaries);
62
+ if (index < 0) continue;
63
+ first = Math.min(first, index); last = Math.max(last, index);
64
+ }
65
+ if (last >= 0) {
66
+ minimum = bins.boundaries[first]; maximum = bins.boundaries[last + 1];
67
+ if (scale.zero) { minimum = Math.min(0, minimum); maximum = Math.max(0, maximum); }
68
+ }
69
+ }
70
+ for (const { values } of independentConsumers) for (const value of values) {
71
+ if (!Number.isFinite(value)) continue;
72
+ minimum = Math.min(minimum, value); maximum = Math.max(maximum, value);
73
+ }
74
+ return [minimum, maximum];
37
75
  }
@@ -5,8 +5,10 @@ import {
5
5
  resolveTransformedDomain
6
6
  } from "../../../grammar/scales/index.js";
7
7
  import { numericExtent } from "../../../grammar/numeric.js";
8
+ import { compositionChannel } from "../../../selectors/plotComposition.js";
8
9
 
9
10
  export function resolveSeriesLayoutDomain({
11
+ program,
10
12
  id,
11
13
  scale,
12
14
  valuesByConsumer,
@@ -15,7 +17,9 @@ export function resolveSeriesLayoutDomain({
15
17
  if (!seriesLayouts.some(Boolean)) return undefined;
16
18
  const activeLayouts = seriesLayouts.filter(Boolean);
17
19
  const layouts = new Set(activeLayouts.map(item => item.layout));
18
- if (layouts.size !== 1) {
20
+ // Independent native layers may use different layouts in the same value
21
+ // units. Only normalized/centered policies change the axis interpretation.
22
+ if (layouts.size !== 1 && [...layouts].some(layout => !["group", "overlay", "stack", "diverging"].includes(layout))) {
19
23
  throw new Error(`Series layout scale "${id}" requires one layout policy.`);
20
24
  }
21
25
  const layoutConsumers = valuesByConsumer.filter(
@@ -36,8 +40,12 @@ export function resolveSeriesLayoutDomain({
36
40
  layout === "overlay" ||
37
41
  Object.hasOwn(directEncoding, "datum") ||
38
42
  (
39
- aggregate !== "count" &&
40
- directEncoding.field === layoutConsumer.encoding.field
43
+ (directEncoding.field === layoutConsumer.encoding.field || program &&
44
+ compositionChannel(program, directEncoding, consumer.layer.data)?.field ===
45
+ compositionChannel(program, layoutConsumer.encoding, layoutConsumer.layer.data)?.field) && (
46
+ aggregate !== "count" || (directEncoding.aggregate === "count" &&
47
+ JSON.stringify(consumer.layer.encoding.x?.bin) === JSON.stringify(layoutConsumer.layer.encoding.x?.bin))
48
+ )
41
49
  )
42
50
  );
43
51
  })
@@ -61,7 +69,7 @@ export function resolveSeriesLayoutDomain({
61
69
  }
62
70
  }
63
71
  if (
64
- ["group", "overlay"].includes(layout) &&
72
+ [...layouts].some(item => ["group", "overlay"].includes(item)) &&
65
73
  scale.domain !== "auto" &&
66
74
  (numericExtent(scale.domain)[0] > 0 || numericExtent(scale.domain)[1] < 0)
67
75
  ) {
@@ -325,6 +325,7 @@ export function resolveScaleMaterialization(options) {
325
325
  const binnedDomain = isDiscretizedColor
326
326
  ? undefined
327
327
  : resolveBinnedPositionDomain({
328
+ program: options.program,
328
329
  valuesByConsumer,
329
330
  channel,
330
331
  scale,
@@ -334,6 +335,7 @@ export function resolveScaleMaterialization(options) {
334
335
  const seriesDomain = isDiscretizedColor || binnedDomain !== undefined
335
336
  ? undefined
336
337
  : resolveSeriesLayoutDomain({
338
+ program: options.program,
337
339
  id,
338
340
  scale,
339
341
  valuesByConsumer,