ggaction 0.0.17 → 0.0.18

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 (263) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +2 -2
  3. package/knowledge/action-card.schema.json +1 -1
  4. package/knowledge/action-cards.json +1 -1
  5. package/knowledge/intent-taxonomy.json +1 -1
  6. package/knowledge/mcp-resources.json +1 -1
  7. package/knowledge/task-resolver.js +1 -1
  8. package/package.json +3 -3
  9. package/src/BasicChartProgram.js +1 -1
  10. package/src/accessibility.js +3 -2
  11. package/src/actions/boxPlots/create.js +14 -4
  12. package/src/actions/boxPlots/edit.js +39 -33
  13. package/src/actions/boxPlots/materialize.js +7 -3
  14. package/src/actions/boxPlots/options.js +37 -11
  15. package/src/actions/canvas/actions.js +70 -95
  16. package/src/actions/canvas/fitting.js +16 -1
  17. package/src/actions/canvas/index.js +3 -0
  18. package/src/actions/canvas/plotBorder.js +28 -0
  19. package/src/actions/charts/area.js +2 -2
  20. package/src/actions/charts/bar.js +34 -7
  21. package/src/actions/charts/ecdf.js +4 -18
  22. package/src/actions/charts/endpoints.js +4 -18
  23. package/src/actions/charts/heatmap.js +4 -4
  24. package/src/actions/charts/index.js +2 -0
  25. package/src/actions/charts/interval-regression.js +2 -2
  26. package/src/actions/charts/line.js +1 -1
  27. package/src/actions/charts/pie.js +3 -47
  28. package/src/actions/charts/radial.js +34 -12
  29. package/src/actions/charts/scatter.js +16 -2
  30. package/src/actions/charts/shared.js +2 -2
  31. package/src/actions/charts/text.js +35 -0
  32. package/src/actions/composition/actions.js +6 -2
  33. package/src/actions/data/box.js +2 -1
  34. package/src/actions/data/edit.js +1 -1
  35. package/src/actions/data/regression.js +2 -1
  36. package/src/actions/data/shared.js +3 -2
  37. package/src/actions/data/stack.js +3 -1
  38. package/src/actions/encodings/appearance.js +37 -29
  39. package/src/actions/encodings/barWidth.js +36 -11
  40. package/src/actions/encodings/channels.js +0 -1
  41. package/src/actions/encodings/color/continuous.js +1 -1
  42. package/src/actions/encodings/color/index.js +1 -1
  43. package/src/actions/encodings/color/policy.js +10 -3
  44. package/src/actions/encodings/density/resolve.js +1 -6
  45. package/src/actions/encodings/position/policies/arc.js +12 -10
  46. package/src/actions/encodings/position/policies/area.js +3 -3
  47. package/src/actions/encodings/position/policies/bar.js +15 -6
  48. package/src/actions/encodings/position/policies/line.js +17 -6
  49. package/src/actions/encodings/position/resolve.js +3 -2
  50. package/src/actions/encodings/ranged.js +17 -0
  51. package/src/actions/encodings/remove.js +3 -2
  52. package/src/actions/encodings/seriesLayout.js +1 -0
  53. package/src/actions/encodings/text.js +6 -3
  54. package/src/actions/errorBands/create.js +2 -2
  55. package/src/actions/errorBands/edit.js +2 -1
  56. package/src/actions/facets/actions.js +158 -240
  57. package/src/actions/gradientPlots/resolve.js +2 -17
  58. package/src/actions/guides/applicability.js +1 -1
  59. package/src/actions/guides/axes/axis.js +16 -4
  60. package/src/actions/guides/axes/edit.js +4 -4
  61. package/src/actions/guides/axes/labels.js +120 -46
  62. package/src/actions/guides/axes/lines.js +6 -5
  63. package/src/actions/guides/axes/parallel/lifecycle.js +1 -1
  64. package/src/actions/guides/axes/parallel/policy.js +8 -7
  65. package/src/actions/guides/axes/remove.js +3 -2
  66. package/src/actions/guides/axes/tickGroups.js +5 -2
  67. package/src/actions/guides/axes/ticks.js +6 -3
  68. package/src/actions/guides/axes/titles.js +71 -26
  69. package/src/actions/guides/basic.js +2 -0
  70. package/src/actions/guides/facadeLegend.js +10 -2
  71. package/src/actions/guides/grids/grid.js +7 -1
  72. package/src/actions/guides/grids/resolve.js +59 -12
  73. package/src/actions/guides/legends/blocks.js +3 -1
  74. package/src/actions/guides/legends/categorical/actions.js +70 -17
  75. package/src/actions/guides/legends/categorical/components.js +12 -8
  76. package/src/actions/guides/legends/categorical/index.js +6 -0
  77. package/src/actions/guides/legends/categorical/layout.js +37 -8
  78. package/src/actions/guides/legends/categorical/options.js +31 -20
  79. package/src/actions/guides/legends/categorical/resolve.js +2 -2
  80. package/src/actions/guides/legends/categorical/symbols.js +18 -12
  81. package/src/actions/guides/legends/categorical/validation.js +1 -6
  82. package/src/actions/guides/legends/continuous/colorSymbols.js +1 -0
  83. package/src/actions/guides/legends/continuous/common.js +1 -447
  84. package/src/actions/guides/legends/continuous/gradient.js +47 -34
  85. package/src/actions/guides/legends/continuous/interval.js +4 -4
  86. package/src/actions/guides/legends/continuous/opacity.js +19 -12
  87. package/src/actions/guides/legends/edit.js +1 -604
  88. package/src/actions/guides/legends/focused.js +2 -2
  89. package/src/actions/guides/legends/group.js +1 -0
  90. package/src/actions/guides/legends/lane.js +1 -516
  91. package/src/actions/guides/legends/remove.js +3 -0
  92. package/src/actions/guides/legends/sampling.js +1 -1
  93. package/src/actions/guides/legends/size.js +51 -43
  94. package/src/actions/guides/legends/strokeWidth.js +11 -29
  95. package/src/actions/guides/legends/target.js +3 -3
  96. package/src/actions/guides/legends/transition.js +21 -7
  97. package/src/actions/guides/polar/axes/labels.js +4 -2
  98. package/src/actions/guides/polar/axes/titles.js +7 -5
  99. package/src/actions/guides/polar/resolve.js +6 -14
  100. package/src/actions/index.js +16 -16
  101. package/src/actions/marks/arc/actions.js +3 -10
  102. package/src/actions/marks/area/actions.js +8 -5
  103. package/src/actions/marks/area/materialize.js +8 -9
  104. package/src/actions/marks/bar/create.js +10 -4
  105. package/src/actions/marks/bar/edit.js +8 -4
  106. package/src/actions/marks/bar/materialize.js +9 -6
  107. package/src/actions/marks/line/actions.js +11 -4
  108. package/src/actions/marks/line/materialize.js +9 -5
  109. package/src/actions/marks/point/edit.js +1 -1
  110. package/src/actions/marks/rect/actions.js +3 -3
  111. package/src/actions/marks/remove.js +2 -0
  112. package/src/actions/marks/rule/actions.js +7 -4
  113. package/src/actions/marks/text/actions.js +17 -5
  114. package/src/actions/marks/text/layout.js +3 -0
  115. package/src/actions/primitives/semanticValidation/layer.js +4 -0
  116. package/src/actions/regression/create.js +1 -1
  117. package/src/actions/regression/edit.js +3 -3
  118. package/src/actions/regression/resolve.js +2 -6
  119. package/src/actions/scales/channels.js +9 -4
  120. package/src/actions/scales/consumers/families.js +16 -5
  121. package/src/actions/scales/create.js +8 -3
  122. package/src/actions/scales/definitions.js +1 -425
  123. package/src/actions/scales/edit.js +2 -2
  124. package/src/actions/scales/editPolicy.js +9 -3
  125. package/src/actions/scales/materialize.js +1 -1
  126. package/src/actions/scales/preview.js +21 -1
  127. package/src/actions/scales/quantitativeColor.js +3 -3
  128. package/src/actions/selection/actions.js +1 -13
  129. package/src/actions/theme/reconcile.js +81 -27
  130. package/src/actions/theme/state.js +12 -0
  131. package/src/actions/titles/actions.js +5 -18
  132. package/src/actions/titles/resolve.js +41 -19
  133. package/src/actions/violinPlots/edit.js +3 -20
  134. package/src/core/compositionState.js +4 -1
  135. package/src/core/font.js +9 -2
  136. package/src/core/optionRequirements.js +1 -0
  137. package/src/core/resourceReferences.js +3 -0
  138. package/src/core/textMetricProfile.js +3 -2
  139. package/src/core/textMetrics.js +41 -4
  140. package/src/core/validation.js +26 -0
  141. package/src/core/vocabulary.js +4 -0
  142. package/src/grammar/aggregate.js +4 -18
  143. package/src/grammar/arcs.js +1 -386
  144. package/src/grammar/areaPaint.js +10 -0
  145. package/src/grammar/areaSeries.js +18 -10
  146. package/src/grammar/bars/geometry.js +30 -3
  147. package/src/grammar/bars/policy.js +12 -1
  148. package/src/grammar/bin.js +2 -12
  149. package/src/grammar/bin2d.js +1 -6
  150. package/src/grammar/boxPlot.js +56 -9
  151. package/src/grammar/categoryOrder.js +1 -6
  152. package/src/grammar/complete.js +15 -19
  153. package/src/grammar/computed.js +92 -25
  154. package/src/grammar/curveCommands.js +29 -16
  155. package/src/grammar/datasetSchema.js +3 -7
  156. package/src/grammar/displayLabels.js +28 -0
  157. package/src/grammar/ecdf.js +2 -9
  158. package/src/grammar/facets/dependencies.js +6 -2
  159. package/src/grammar/facets/guides.js +5 -0
  160. package/src/grammar/facets/index.js +2 -5
  161. package/src/grammar/fold.js +2 -12
  162. package/src/grammar/gradientProfile.js +1 -6
  163. package/src/grammar/impute.js +21 -48
  164. package/src/grammar/interval.js +2 -9
  165. package/src/grammar/legendGradient.js +18 -0
  166. package/src/grammar/legendGroups.js +1 -0
  167. package/src/grammar/legendTitleStyle.js +22 -0
  168. package/src/grammar/lineSeries.js +32 -17
  169. package/src/grammar/markLabels.js +5 -2
  170. package/src/grammar/normalize.js +4 -54
  171. package/src/grammar/numeric.js +1 -290
  172. package/src/grammar/parallelCoordinates.js +1 -6
  173. package/src/grammar/parseTime.js +41 -0
  174. package/src/grammar/pathCommands.js +11 -4
  175. package/src/grammar/polar.js +1 -184
  176. package/src/grammar/positionCompatibility.js +3 -3
  177. package/src/grammar/regression/derive.js +7 -1
  178. package/src/grammar/regression/models.js +83 -55
  179. package/src/grammar/regression/parameters.js +14 -3
  180. package/src/grammar/roundedRect.js +42 -54
  181. package/src/grammar/scales/appearance.js +1 -206
  182. package/src/grammar/scales/color.js +1 -382
  183. package/src/grammar/scales/colorConsumers.js +5 -2
  184. package/src/grammar/scales/definition.js +1 -197
  185. package/src/grammar/scales/discretized.js +1 -195
  186. package/src/grammar/scales/size.js +1 -456
  187. package/src/grammar/scales/temporal.js +2 -13
  188. package/src/grammar/scales/transformed.js +1 -383
  189. package/src/grammar/scales/types.js +1 -1
  190. package/src/grammar/scales/validation.js +1 -66
  191. package/src/grammar/schemas/concreteGraphic.js +11 -0
  192. package/src/grammar/schemas/graphic.js +3 -0
  193. package/src/grammar/schemas/semanticPath.js +2 -1
  194. package/src/grammar/stack.js +2 -10
  195. package/src/grammar/statisticalReference.js +1 -6
  196. package/src/grammar/summary.js +3 -26
  197. package/src/grammar/text.js +19 -0
  198. package/src/grammar/timeUnit.js +16 -7
  199. package/src/grammar/transformKeys.js +47 -0
  200. package/src/grammar/transforms.js +4 -1
  201. package/src/grammar/valueFormat.js +17 -5
  202. package/src/grammar/weightedStatistics.js +1 -6
  203. package/src/grammar/window.js +4 -58
  204. package/src/inspectionDescriptors.js +1 -1
  205. package/src/layout/canvas.js +1 -110
  206. package/src/layout/facets.js +1 -244
  207. package/src/layout/labels.js +1 -0
  208. package/src/layout/legend.js +13 -0
  209. package/src/layout/legendGroup.js +1 -0
  210. package/src/layout/legendItems.js +59 -15
  211. package/src/layout/legendLane.js +1 -441
  212. package/src/layout/legendText.js +1 -0
  213. package/src/layout/title.js +35 -13
  214. package/src/materialization/bars/aggregate.js +11 -5
  215. package/src/materialization/bars/histogram.js +4 -1
  216. package/src/materialization/bars/ranged.js +19 -6
  217. package/src/materialization/bars/resolve.js +22 -1
  218. package/src/materialization/coordinateBounds.js +8 -0
  219. package/src/materialization/dependencies.js +4 -44
  220. package/src/materialization/facetGuides/layout.js +1 -0
  221. package/src/materialization/facetGuides/placement.js +19 -8
  222. package/src/materialization/facetGuides/preparation.js +15 -7
  223. package/src/materialization/facetHeaders.js +1 -347
  224. package/src/materialization/facets.js +1 -185
  225. package/src/materialization/graphicHierarchy.js +1 -1
  226. package/src/materialization/guides/layout.js +1 -57
  227. package/src/materialization/guides/resources.js +1 -123
  228. package/src/materialization/layout.js +53 -0
  229. package/src/materialization/legendGroups.js +1 -0
  230. package/src/materialization/legendText.js +1 -0
  231. package/src/materialization/legends.js +1 -1
  232. package/src/materialization/marks/capabilities.js +5 -6
  233. package/src/materialization/rect.js +1 -1
  234. package/src/materialization/scaleGuideDependencies.js +1 -1
  235. package/src/materialization/scales/map.js +3 -0
  236. package/src/materialization/scales/policies/arc.js +8 -7
  237. package/src/materialization/scales/policies/bar.js +19 -7
  238. package/src/materialization/scales/policies/series.js +2 -1
  239. package/src/materialization/scales/resolve.js +5 -3
  240. package/src/materialization/selection/items/arc.js +1 -1
  241. package/src/materialization/selection/items/bar.js +7 -6
  242. package/src/materialization/selection/items/path.js +7 -6
  243. package/src/materialization/selection/items/rect.js +2 -2
  244. package/src/materialization/text.js +13 -4
  245. package/src/materialization/textLines.js +15 -0
  246. package/src/persistence/codec.js +37 -6
  247. package/src/persistence/validation.js +47 -0
  248. package/src/persistence.js +18 -5
  249. package/src/renderers/canvas/circle.js +6 -3
  250. package/src/renderers/canvas/fill.js +7 -1
  251. package/src/renderers/canvas/index.js +2 -1
  252. package/src/renderers/canvas/line.js +1 -0
  253. package/src/renderers/canvas/path.js +1 -1
  254. package/src/renderers/canvas/rect.js +11 -8
  255. package/src/renderers/canvas/stroke.js +2 -1
  256. package/src/renderers/canvas/text.js +5 -3
  257. package/src/renderers/svg.js +4 -1
  258. package/src/selectors/layers.js +19 -0
  259. package/src/theme/defaults.js +8 -2
  260. package/src/version.js +1 -1
  261. package/types/basic.d.ts +1 -0
  262. package/types/index.d.ts +7 -0
  263. package/types/program.d.ts +347 -85
@@ -1,3 +1,6 @@
1
+ import { materializeTextLines } from "../../../materialization/textLines.js";
2
+ import { axisThemeTypography } from "../../theme/state.js";
3
+ import { canvasOverflowError } from "../../../layout/canvas.js";
1
4
  import { isSourceOwnedText } from "../../../grammar/text.js";
2
5
  import { withGuideLayoutValidation } from "../../../materialization/guides/layout.js";
3
6
  import { action } from "../../../core/action.js";
@@ -7,13 +10,13 @@ import {
7
10
  validateNonEmptyString,
8
11
  validateNonNegativeFinite
9
12
  } from "../../../core/validation.js";
10
- import { resolveGuideCoordinateBounds } from "../../../materialization/coordinateBounds.js";
13
+ import { axisScaleBindings, resolveGuideCoordinateBounds } from "../../../materialization/coordinateBounds.js";
11
14
  import {
12
15
  isTransformedScaleType,
13
16
  mapContinuousScaleValues,
14
17
  mapOrdinalPositionValues
15
18
  } from "../../../grammar/scales/index.js";
16
- import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
19
+ import { AXIS_TITLE_FONT_SIZE, DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
17
20
  "../../../theme/defaults.js";
18
21
  import { findDataset } from "../../../selectors/datasets.js";
19
22
  import { formatAggregateTitle } from "../../../grammar/aggregate.js";
@@ -28,6 +31,7 @@ import { findCanvasGraphic, resolvePlotGraphicPlacement } from
28
31
  "../../../materialization/graphicHierarchy.js";
29
32
  import {
30
33
  resolveTextBounds,
34
+ resolveTextLines,
31
35
  textBoundsFitCanvas,
32
36
  textBoundsIntersect
33
37
  } from "../../../core/textMetrics.js";
@@ -36,10 +40,19 @@ import { resolveConcreteGraphicBounds } from
36
40
  import { validateAxisTextStyle } from "./labels.js";
37
41
  import { resolveRotation } from "../../../grammar/rotation.js";
38
42
 
43
+ function titleLines(text, config) {
44
+ if (!/[\r\n]/u.test(text)) return undefined;
45
+ const lines = resolveTextLines(text, {
46
+ fontSize: config.fontSize, align: "center", baseline: "middle", blockAlign: "middle"
47
+ });
48
+ return lines.length > 1 ? lines : undefined;
49
+ }
50
+
39
51
  function titleBounds(geometry, config, text, profile) {
40
52
  return resolveTextBounds({
41
53
  ...geometry,
42
54
  text,
55
+ lines: titleLines(text, config),
43
56
  fontSize: config.fontSize,
44
57
  fontFamily: config.fontFamily,
45
58
  fontWeight: config.fontWeight,
@@ -49,6 +62,34 @@ function titleBounds(geometry, config, text, profile) {
49
62
  }, profile);
50
63
  }
51
64
 
65
+ function resolveTitleAlong(scale, config) {
66
+ const discrete = ["ordinal", "band", "point"].includes(scale?.type);
67
+ let along;
68
+ if (config.at === "center") {
69
+ along = finiteMidpoint(scale.range[0], scale.range[1]);
70
+ } else if (config.at === "start" || config.at === "end") {
71
+ along = scale.range[config.at === "start" ? 0 : 1];
72
+ } else {
73
+ if (discrete) {
74
+ if (!scale.domain.includes(config.at)) throw new RangeError("Axis title at value must be inside the scale domain.");
75
+ along = mapOrdinalPositionValues([config.at], scale)[0];
76
+ } else {
77
+ const low = Math.min(...scale.domain), high = Math.max(...scale.domain);
78
+ if (config.at < low || config.at > high) throw new RangeError("Axis title at value must be inside the scale domain.");
79
+ along = mapContinuousScaleValues([config.at], scale)[0];
80
+ }
81
+ }
82
+ return along;
83
+ }
84
+
85
+ function axisLabelBounds(program, channel) {
86
+ const id = `${channel}AxisLabels`, labels = program.graphicSpec.objects[id];
87
+ const bounds = target => resolveConcreteGraphicBounds(
88
+ program.graphicSpec, target, program.materializationConfigs.textMetrics
89
+ );
90
+ return labels?.items?.map(item => bounds(item.id)) ?? (labels ? [bounds(id)] : []);
91
+ }
92
+
52
93
  function inferredTitleOffset(program, channel, config) {
53
94
  const plot = resolveGuideCoordinateBounds(program, {
54
95
  coordinate: program.semanticSpec.guides.axis?.[channel]?.coordinate,
@@ -78,7 +119,26 @@ function inferredTitleOffset(program, channel, config) {
78
119
  const available = positive
79
120
  ? canvas[horizontal ? "height" : "width"] - bounds[far]
80
121
  : bounds[near];
81
- return preferred + Math.min(available, Math.max(0, needed));
122
+ // Font ink can extend beyond the one-em layout box, especially after
123
+ // rotating a middle-baseline title. Keep inferred edge-clamped placement
124
+ // inside the Canvas; explicit offsets retain their exact geometry.
125
+ const inkClearance = channel === "y" &&
126
+ Math.abs(Math.cos(config.rotation)) < 1e-10
127
+ ? config.fontSize / 4 : 0;
128
+ if (program.materializationConfigs.canvas?.plot !== undefined) {
129
+ return preferred + Math.max(0, needed);
130
+ }
131
+ const legacyAdjustment = Math.min(available, Math.max(0, needed));
132
+ if (inkClearance === 0) return preferred + legacyAdjustment;
133
+ const adjustment = Math.min(available - inkClearance, Math.max(0, needed));
134
+ const candidateBounds = titleBounds(resolveAxisTitleGeometry({
135
+ bounds: plot, channel, position: config.position,
136
+ along: resolveTitleAlong(program.resolvedScales[config.scale], config),
137
+ offset: preferred + adjustment
138
+ }), config, program.semanticSpec.guides.axis?.[channel]?.title ?? "", program.materializationConfigs.textMetrics);
139
+ // Optional ink reserve must not introduce a label collision.
140
+ return preferred + (axisLabelBounds(program, channel).some(label => label && textBoundsIntersect(candidateBounds, label))
141
+ ? legacyAdjustment : adjustment);
82
142
  }
83
143
 
84
144
  const CREATE_OPTIONS = [
@@ -88,7 +148,7 @@ const CREATE_OPTIONS = [
88
148
  const EDIT_OPTIONS = CREATE_OPTIONS.filter(key => key !== "scale");
89
149
  const DEFAULTS = {
90
150
  color: DEFAULT_COLORS.text,
91
- fontSize: 13,
151
+ fontSize: AXIS_TITLE_FONT_SIZE,
92
152
  fontFamily: DEFAULT_FONT_FAMILY,
93
153
  fontWeight: 600
94
154
  };
@@ -149,6 +209,7 @@ export function inferAxisTitleText(program, channel, scaleId) {
149
209
  }
150
210
  }
151
211
  if (primaryTitles.size === 1) return [...primaryTitles][0];
212
+ if (titles.size === 0 && axisScaleBindings(program, scaleId).some(binding => binding.channel === channel)) return scaleId;
152
213
  if (titles.size !== 1) throw new Error(`Axis title text cannot be inferred for scale "${scaleId}".`);
153
214
  return [...titles][0];
154
215
  }
@@ -160,26 +221,11 @@ function resolveGeometry(program, channel, config) {
160
221
  scale: config.scale,
161
222
  channel
162
223
  });
163
- const discrete = ["ordinal", "band", "point"].includes(scale?.type);
164
224
  if ((
165
225
  !["linear", "time", "ordinal", "band", "point"].includes(scale?.type) &&
166
226
  !isTransformedScaleType(scale?.type)
167
227
  ) || !bounds) throw new Error("Axis title requires a supported resolved scale and Canvas bounds.");
168
- let along;
169
- if (config.at === "center") {
170
- along = finiteMidpoint(scale.range[0], scale.range[1]);
171
- } else if (config.at === "start" || config.at === "end") {
172
- along = scale.range[config.at === "start" ? 0 : 1];
173
- } else {
174
- if (discrete) {
175
- if (!scale.domain.includes(config.at)) throw new RangeError("Axis title at value must be inside the scale domain.");
176
- along = mapOrdinalPositionValues([config.at], scale)[0];
177
- } else {
178
- const low = Math.min(...scale.domain), high = Math.max(...scale.domain);
179
- if (config.at < low || config.at > high) throw new RangeError("Axis title at value must be inside the scale domain.");
180
- along = mapContinuousScaleValues([config.at], scale)[0];
181
- }
182
- }
228
+ const along = resolveTitleAlong(scale, config);
183
229
  const geometry = resolveAxisTitleGeometry({
184
230
  bounds,
185
231
  channel,
@@ -194,12 +240,10 @@ function resolveGeometry(program, channel, config) {
194
240
  , program.materializationConfigs.textMetrics);
195
241
  const canvas = findCanvasGraphic(program)?.properties;
196
242
  if (!canvas || !textBoundsFitCanvas(resolvedBounds, canvas)) {
197
- throw new Error(`The ${channel}-axis title does not fit the Canvas margin.`);
243
+ throw canvasOverflowError(`The ${channel}-axis title does not fit the Canvas margin.`, [resolvedBounds], canvas);
198
244
  }
199
- const labelsBounds = program.graphicSpec.objects[`${channel}AxisLabels`]
200
- ? resolveConcreteGraphicBounds(program.graphicSpec, `${channel}AxisLabels`, program.materializationConfigs.textMetrics)
201
- : undefined;
202
- if (labelsBounds && textBoundsIntersect(resolvedBounds, labelsBounds)) {
245
+ const labelBounds = axisLabelBounds(program, channel);
246
+ if (labelBounds.some(bounds => bounds && textBoundsIntersect(resolvedBounds, bounds))) {
203
247
  throw new Error(`The ${channel}-axis title overlaps the axis labels.`);
204
248
  }
205
249
  return geometry;
@@ -265,7 +309,7 @@ function makeEdit(channel) {
265
309
  rotation: config.rotation
266
310
  };
267
311
  for (const [property, value] of Object.entries(properties)) next = next.editGraphics({ target: operation.graphic, property, value });
268
- return next;
312
+ return materializeTextLines(next, operation.graphic, titleLines(resolvedText, config));
269
313
  }));
270
314
  }
271
315
 
@@ -309,6 +353,7 @@ function makeCreate(channel) {
309
353
  fontSize: DEFAULTS.fontSize,
310
354
  fontFamily: DEFAULTS.fontFamily,
311
355
  fontWeight: DEFAULTS.fontWeight,
356
+ ...axisThemeTypography(this.materializationConfigs.theme, "title"),
312
357
  ...appearance
313
358
  };
314
359
  let next = this
@@ -1,3 +1,4 @@
1
+ import { registerStrokeColorLegendActions } from "./legends/continuous/stroke.js";
1
2
  import { registerBasicCartesianAxisActions } from "./axes/index.js";
2
3
  import { registerGuideCollectionActions } from "./guides.js";
3
4
  import { registerBasicGridActions } from "./grids/index.js";
@@ -15,6 +16,7 @@ export function registerBasicGuideActions(ProgramClass) {
15
16
  registerBasicCategoricalLegendActions(ProgramClass);
16
17
  registerGradientLegendActions(ProgramClass);
17
18
  registerIntervalLegendActions(ProgramClass);
19
+ registerStrokeColorLegendActions(ProgramClass);
18
20
  registerSizeLegendActions(ProgramClass);
19
21
  registerBasicGridActions(ProgramClass);
20
22
  registerGuideCollectionActions(ProgramClass);
@@ -17,10 +17,18 @@ function compatibleLegend(program, kind, requested, existing, explicit) {
17
17
  if (requested.symbol?.layers !== undefined && !sameGuideValue(
18
18
  requested.symbol.layers.map(layer => layer.type), existing.symbol?.layers?.map(layer => layer.type)
19
19
  )) guideConflict(`${kind} legend uses a different symbol recipe`);
20
+ if (explicit.representation !== undefined && (requested.representation ?? "gradient") !== (existing.representation ?? "gradient")) guideConflict(`${kind} legend uses a different color representation`);
21
+ if (requested.representation === "symbols") {
22
+ if (explicit.values !== undefined && !sameGuideValue(requested.sampling.values, existing.sampling?.values)) guideConflict(`${kind} legend uses different exact samples`);
23
+ if (explicit.count !== undefined && (existing.sampling?.mode !== "auto" || requested.sampling.count !== existing.sampling?.count)) guideConflict(`${kind} legend uses a different sample count`);
24
+ }
20
25
  const appearance = {};
21
26
  for (const [key, value] of Object.entries(explicit)) {
22
- if (["target", "channels"].includes(key) || requested[key] === undefined) continue;
23
- if (key === "symbol" && requested.symbol?.layers !== undefined) {
27
+ if (["target", "channels", "representation", "values"].includes(key) || requested[key] === undefined) continue;
28
+ if (key === "title") {
29
+ appearance.titleVisible = value !== false;
30
+ if (value !== false) appearance.title = value;
31
+ } else if (key === "symbol" && requested.symbol?.layers !== undefined) {
24
32
  appearance.symbol = value === "auto" ? requested.symbol
25
33
  : value.layers !== undefined ? value
26
34
  : { layers: [{ type: requested.symbol.layers[0].type, ...value }] };
@@ -8,6 +8,7 @@ import {
8
8
  refreshGridConfig,
9
9
  resolveGridConfig,
10
10
  resolveGridGeometry,
11
+ resolveGridPlacement,
11
12
  resolveGridResources,
12
13
  validateGridCreateArgs,
13
14
  validateGridEditArgs
@@ -60,7 +61,12 @@ function makeRematerialize(direction) {
60
61
 
61
62
  const config = refreshGridConfig(this, storedConfig);
62
63
  const geometry = resolveGridGeometry(this, config);
63
- let next = this._withGridConfig(direction, config).editGraphics({
64
+ let next = this._withGridConfig(direction, config);
65
+ if (config.zIndex !== undefined) {
66
+ const placement = resolveGridPlacement(next, config);
67
+ next = next.editGraphics({ target: operation.graphic, remove: true }).createGraphics({ id: operation.graphic, type: "line", length: 0, ...placement });
68
+ }
69
+ next = next.editGraphics({
64
70
  target: operation.graphic,
65
71
  property: "length",
66
72
  value: geometry.values.length
@@ -1,3 +1,4 @@
1
+ import { isNominalValue } from "../../../grammar/scales/fields.js";
1
2
  import { isSourceOwnedText } from "../../../grammar/text.js";
2
3
  import { validateUserId } from "../../../core/identifiers.js";
3
4
  import {
@@ -8,6 +9,7 @@ import {
8
9
  } from "../../../core/validation.js";
9
10
  import {
10
11
  isTransformedScaleType,
12
+ mapOrdinalPositionValues,
11
13
  mapContinuousScaleValues
12
14
  } from "../../../grammar/scales/index.js";
13
15
  import { resolveGuideCoordinateBounds } from "../../../materialization/coordinateBounds.js";
@@ -20,14 +22,15 @@ import {
20
22
  } from "../tickValues.js";
21
23
  import {
22
24
  findGraphicParent,
25
+ graphicSiblings,
23
26
  walkGraphicDrawOrder
24
27
  } from "../../../grammar/schemas/graphicTree.js";
25
28
 
26
29
  const GRID_OPTIONS = Object.freeze([
27
- "scale", "coordinate", "count", "values", "color", "lineWidth", "strokeDash"
30
+ "scale", "coordinate", "count", "values", "color", "lineWidth", "strokeDash", "zIndex", "bandPosition", "alongOffset"
28
31
  ]);
29
32
  const GRID_EDIT_OPTIONS = Object.freeze([
30
- "count", "values", "color", "lineWidth", "strokeDash"
33
+ "count", "values", "color", "lineWidth", "strokeDash", "zIndex", "bandPosition", "alongOffset"
31
34
  ]);
32
35
  const DEFAULT_STYLE = Object.freeze({
33
36
  color: DEFAULT_COLORS.grid,
@@ -75,16 +78,19 @@ export function validateGridCreateArgs(args, operation) {
75
78
  if (Object.hasOwn(args, "values") && (
76
79
  !Array.isArray(args.values) ||
77
80
  args.values.length === 0 ||
78
- !args.values.every(Number.isFinite)
81
+ !args.values.every(isNominalValue)
79
82
  )) {
80
- throw new TypeError("Grid values must be a non-empty finite number array.");
83
+ throw new TypeError("Grid values must be a non-empty finite number array or nominal category array; continuous grids require finite numbers.");
81
84
  }
85
+ if (Object.hasOwn(args, "alongOffset") && !Number.isFinite(args.alongOffset)) throw new TypeError("Grid alongOffset must be a finite number.");
82
86
  if (Object.hasOwn(args, "color")) {
83
87
  validateNonEmptyString(args.color, "Grid color");
84
88
  }
85
89
  if (Object.hasOwn(args, "lineWidth")) {
86
90
  validateNonNegativeFinite(args.lineWidth, "Grid lineWidth");
87
91
  }
92
+ if (Object.hasOwn(args, "bandPosition") && !["center", "boundary"].includes(args.bandPosition)) throw new TypeError("Grid bandPosition must be center or boundary.");
93
+ if (Object.hasOwn(args, "zIndex") && !Number.isFinite(args.zIndex)) throw new TypeError("Grid zIndex must be a finite number.");
88
94
  if (Object.hasOwn(args, "strokeDash")) validateStrokeDash(args.strokeDash);
89
95
  }
90
96
 
@@ -109,18 +115,21 @@ export function validateGridEditArgs(args, operation) {
109
115
  if (Object.hasOwn(args, "values") && args.values !== "auto" && (
110
116
  !Array.isArray(args.values) ||
111
117
  args.values.length === 0 ||
112
- !args.values.every(Number.isFinite)
118
+ !args.values.every(isNominalValue)
113
119
  )) {
114
120
  throw new TypeError(
115
121
  'Grid values must be "auto" or a non-empty finite number array.'
116
122
  );
117
123
  }
124
+ if (Object.hasOwn(args, "alongOffset") && !Number.isFinite(args.alongOffset)) throw new TypeError("Grid alongOffset must be a finite number.");
118
125
  if (Object.hasOwn(args, "color")) {
119
126
  validateNonEmptyString(args.color, "Grid color");
120
127
  }
121
128
  if (Object.hasOwn(args, "lineWidth")) {
122
129
  validateNonNegativeFinite(args.lineWidth, "Grid lineWidth");
123
130
  }
131
+ if (Object.hasOwn(args, "bandPosition") && !["center", "boundary"].includes(args.bandPosition)) throw new TypeError("Grid bandPosition must be center or boundary.");
132
+ if (Object.hasOwn(args, "zIndex") && !Number.isFinite(args.zIndex)) throw new TypeError("Grid zIndex must be a finite number.");
124
133
  if (Object.hasOwn(args, "strokeDash")) validateStrokeDash(args.strokeDash);
125
134
  }
126
135
 
@@ -163,10 +172,10 @@ export function resolveGridResources(program, direction, args) {
163
172
  }
164
173
  const scale = requestedScale ?? scaleIds[0];
165
174
  if (
166
- !["linear", "time"].includes(program.resolvedScales[scale]?.type) &&
175
+ !["linear", "time", "band", "point"].includes(program.resolvedScales[scale]?.type) &&
167
176
  !isTransformedScaleType(program.resolvedScales[scale]?.type)
168
177
  ) {
169
- throw new Error(`${direction} grid requires resolved continuous scale "${scale}".`);
178
+ throw new Error(`${direction} grid requires resolved continuous, band or point scale "${scale}".`);
170
179
  }
171
180
  const sourceIds = new Set(layers.map(layer => layer.id));
172
181
  const relatedLayers = [...layers, ...program.semanticSpec.layers.filter(layer =>
@@ -177,8 +186,9 @@ export function resolveGridResources(program, direction, args) {
177
186
  return leader === undefined ? [layer.id] : [leader, layer.id];
178
187
  }));
179
188
  let before;
189
+ let after;
180
190
  walkGraphicDrawOrder(program.graphicSpec, ({ id }) => {
181
- if (before === undefined && related.has(id)) before = id;
191
+ if (related.has(id)) { if (before === undefined) before = id; after = id; }
182
192
  });
183
193
  if (before === undefined) throw new Error(`${direction} grid requires related mark graphics.`);
184
194
  const owner = findGraphicParent(program.graphicSpec, before);
@@ -187,6 +197,7 @@ export function resolveGridResources(program, direction, args) {
187
197
  coordinate,
188
198
  scale,
189
199
  before,
200
+ after,
190
201
  ...(owner?.kind === "parent" ? { parent: owner.id } : {})
191
202
  };
192
203
  }
@@ -201,6 +212,9 @@ export function resolveGridConfig(program, direction, args, resources) {
201
212
  : inferGridTickConfig(program, resources.channel, resources.scale);
202
213
  return {
203
214
  direction,
215
+ ...(args.bandPosition === undefined ? {} : { bandPosition: args.bandPosition }),
216
+ ...(args.zIndex === undefined ? {} : { zIndex: args.zIndex }),
217
+ ...(args.alongOffset === undefined ? {} : { alongOffset: args.alongOffset }),
204
218
  scale: resources.scale,
205
219
  coordinate: resources.coordinate,
206
220
  ...explicit,
@@ -255,23 +269,56 @@ export function resolveGridGeometry(program, config) {
255
269
  channel
256
270
  });
257
271
  if ((
258
- !["linear", "time"].includes(scale?.type) &&
272
+ !["linear", "time", "band", "point"].includes(scale?.type) &&
259
273
  !isTransformedScaleType(scale?.type)
260
274
  ) || bounds === undefined) {
261
- throw new Error("Grid materialization requires a continuous scale and Canvas bounds.");
275
+ throw new Error("Grid materialization requires a continuous, band or point scale and Canvas bounds.");
276
+ }
277
+ if (config.bandPosition !== undefined && scale.type !== "band") throw new Error("Grid bandPosition requires a band scale.");
278
+ if (["band", "point"].includes(scale.type)) {
279
+ if (config.mode === "count") throw new Error("Categorical grid values cannot use count.");
280
+ const values = valuesFromTickConfig(program, config);
281
+ if (!values.every(value => scale.domain.includes(value))) throw new RangeError("Grid values must be inside the scale domain.");
282
+ const centers = mapOrdinalPositionValues(values, scale);
283
+ const direction = scale.range[1] >= scale.range[0] ? 1 : -1;
284
+ const positions = config.bandPosition === "boundary"
285
+ ? [...new Set(centers.flatMap(center => [center - direction * scale.bandwidth / 2, center + direction * scale.bandwidth / 2]))].sort((a, b) => direction * (a - b))
286
+ : centers;
287
+ validateGeneratedItemLimit(positions.length, "Grid value count");
288
+ return resolveGridLineGeometry({ direction: config.direction, values: positions, positions: positions.map(value => value + (config.alongOffset ?? 0)), bounds });
262
289
  }
263
290
  const values = valuesFromTickConfig(program, config);
264
291
  validateGeneratedItemLimit(values.length, "Grid value count");
265
292
  const low = Math.min(...scale.domain);
266
293
  const high = Math.max(...scale.domain);
267
- if (!values.every(value => value >= low && value <= high)) {
294
+ if (!values.every(value => Number.isFinite(value) && value >= low && value <= high)) {
268
295
  throw new RangeError("Grid values must be inside the scale domain.");
269
296
  }
270
297
  const positions = mapContinuousScaleValues(values, scale);
271
298
  return resolveGridLineGeometry({
272
299
  direction: config.direction,
273
300
  values,
274
- positions,
301
+ positions: positions.map(value => value + (config.alongOffset ?? 0)),
275
302
  bounds
276
303
  });
277
304
  }
305
+
306
+
307
+ export function resolveGridPlacement(program, config) {
308
+ const resources = resolveGridResources(program, config.direction, { scale: config.scale, coordinate: config.coordinate });
309
+ const id = gridNames(config.direction).graphic;
310
+ const siblings = graphicSiblings(program.graphicSpec, resources.before);
311
+ const rank = direction => [program.guideConfigs.grid?.[direction]?.zIndex ?? 0, direction === "horizontal" ? 0 : 1];
312
+ const ownRank = [config.zIndex ?? 0, config.direction === "horizontal" ? 0 : 1];
313
+ const compare = (a, b) => a[0] - b[0] || a[1] - b[1];
314
+ const neighbors = ["horizontal", "vertical"].filter(direction => {
315
+ const other = gridNames(direction).graphic;
316
+ return other !== id && siblings.includes(other) && ((rank(direction)[0] > 0) === (ownRank[0] > 0));
317
+ }).sort((a, b) => compare(rank(a), rank(b)));
318
+ const greater = neighbors.find(direction => compare(rank(direction), ownRank) > 0);
319
+ const lower = neighbors.filter(direction => compare(rank(direction), ownRank) < 0).at(-1);
320
+ return {
321
+ ...(resources.parent === undefined ? {} : { parent: resources.parent }),
322
+ ...(greater ? { before: gridNames(greater).graphic } : lower ? { after: gridNames(lower).graphic } : ownRank[0] > 0 ? { after: resources.after } : { before: resources.before })
323
+ };
324
+ }
@@ -78,6 +78,7 @@ function validateSymbolSupport(descriptor, property) {
78
78
  }
79
79
  return;
80
80
  }
81
+ if (kind === "gradient" && property === "fill") throw new Error("editLegendBlock symbol.fill conflicts with the color mapping.");
81
82
  if (kind === "size" && property === "size") {
82
83
  throw new Error("editLegendBlock symbol.size conflicts with the size mapping.");
83
84
  }
@@ -239,7 +240,7 @@ export function resolveEffectiveLegendBlockConfig(program, kind, config) {
239
240
  blockSymbol: override.symbol
240
241
  };
241
242
  }
242
- if (kind === "opacity") {
243
+ if (kind === "opacity" || kind === "gradient" && config.representation === "symbols") {
243
244
  return {
244
245
  ...effective,
245
246
  symbol: {
@@ -289,6 +290,7 @@ export function reconcileLegendBlockTitleGraphic(program, kind, visible) {
289
290
  }
290
291
 
291
292
  const SPECIALIZED_SYMBOLS = Object.freeze({
293
+ gradient: Object.freeze({ id: "colorGradientSymbols", type: "circle" }),
292
294
  size: Object.freeze({ id: "sizeLegendSymbols", type: "circle" }),
293
295
  opacity: Object.freeze({ id: "opacityLegendSymbols", type: "circle" }),
294
296
  strokeWidth: Object.freeze({ id: "strokeWidthLegendSymbols", type: "line" }),
@@ -1,3 +1,8 @@
1
+ import { recordLegendOffsetIntent } from "../group.js";
2
+ import { prepareLegendGroup, creationLegendTarget, validateLegendGroupOptions } from "../group.js";
3
+ import { PLOT_LEGEND_POSITIONS } from "../../../../layout/legend.js";
4
+ import { normalizeInitialLegendTitle } from "../continuous/common.js";
5
+ import { isContinuousColorScaleType } from "../../../../grammar/scales/types.js";
1
6
  import { withGuideLayoutValidation } from "../../../../materialization/guides/layout.js";
2
7
  import { sameGuideValue } from "../../reuse.js";
3
8
  import { normalizeLegendOrder } from "../../../../grammar/categoryOrder.js";
@@ -17,8 +22,7 @@ import { legendResourcePolicies } from
17
22
  "../../../../materialization/guides/resources.js";
18
23
  import {
19
24
  hasHorizontalLegendLane,
20
- hasLegendLane,
21
- hasMultiSideLegendLane
25
+ hasLegendLane
22
26
  } from "../lane.js";
23
27
  import {
24
28
  resolveCurrentDefinition,
@@ -33,7 +37,8 @@ import {
33
37
  } from "../blocks.js";
34
38
 
35
39
  function finishLegend(program) {
36
- return hasLegendLane(program) ? program.rematerializeLegend() : program;
40
+ return hasLegendLane(program) || Object.keys(program.guideConfigs.legendGroups ?? {}).length > 0
41
+ ? program.rematerializeLegend() : program;
37
42
  }
38
43
 
39
44
  function requestedCandidate(program, target, candidates) {
@@ -162,9 +167,6 @@ export const rematerializeLegend = /* @__PURE__ */ action(
162
167
  next = next[policy.rematerializeOp]();
163
168
  }
164
169
  }
165
- if (hasMultiSideLegendLane(next)) {
166
- next = next.rematerializeSideLegendLane();
167
- }
168
170
  if (hasHorizontalLegendLane(next)) {
169
171
  next = next.rematerializeHorizontalLegendLane();
170
172
  }
@@ -178,7 +180,7 @@ export function resolveCategoricalLegendConfig(program, args = {}) {
178
180
  program,
179
181
  layer,
180
182
  args.channels,
181
- args.title,
183
+ args.title === false ? undefined : args.title,
182
184
  args.order === undefined ? undefined : normalizeLegendOrder(args.order)
183
185
  );
184
186
  const inferredSymbol = args.symbol === undefined || args.symbol === "auto";
@@ -194,7 +196,8 @@ export function resolveCategoricalLegendConfig(program, args = {}) {
194
196
  const config = {
195
197
  target: layer.id,
196
198
  ...definition,
197
- inferredTitle: !Object.hasOwn(args, "title"),
199
+ ...normalizeInitialLegendTitle(args.title, definition.title),
200
+ inferredTitle: args.title === false || !Object.hasOwn(args, "title"),
198
201
  inferredSymbol,
199
202
  position: options.position,
200
203
  align: options.align,
@@ -207,8 +210,8 @@ export function resolveCategoricalLegendConfig(program, args = {}) {
207
210
  titleStyle: options.titleStyle,
208
211
  itemGap: options.itemGap,
209
212
  layout: options.layout,
210
- border: options.border,
211
- titleVisible: true
213
+ ...(options.overflow === undefined ? {} : { overflow: options.overflow }),
214
+ border: options.border
212
215
  };
213
216
  return config;
214
217
  }
@@ -228,7 +231,7 @@ export const createCategoricalLegend = /* @__PURE__ */ action(
228
231
  })
229
232
  );
230
233
 
231
- export function resolveLegendCreationPlan(program, args = {}, layers = program.semanticSpec.layers) {
234
+ function resolveRawLegendCreationPlan(program, args = {}, layers = program.semanticSpec.layers) {
232
235
  validateOptionObject(args, undefined, "createLegend");
233
236
  const candidates = args.target === undefined ? layers : layers.filter(layer => layer.id === args.target);
234
237
  const channels = args.channels;
@@ -252,15 +255,23 @@ export function resolveLegendCreationPlan(program, args = {}, layers = program.s
252
255
  }
253
256
  }
254
257
  const continuousColorCandidates = candidates.filter(layer => {
255
- const encoding = ["point", "bar", "rect"].includes(layer.mark?.type)
258
+ const encoding = ["point", "bar", "rect", "text"].includes(layer.mark?.type)
256
259
  ? layer.encoding?.color
257
260
  : undefined;
258
261
  const scale = findSemanticScale(program, encoding?.scale);
259
- return scale?.type === "sequential";
262
+ return isContinuousColorScaleType(scale?.type);
260
263
  });
261
264
  const continuousColor = requestedCandidate(
262
265
  program, args.target, continuousColorCandidates
263
266
  );
267
+ if (args.representation !== undefined && channels !== undefined && channels.length > 1) {
268
+ if (!continuousColor || !channels.includes("color")) throw new Error("Legend representation requires selected continuous color.");
269
+ const { representation, values, count, symbol, gradient, ...common } = args;
270
+ const steps = channels.flatMap(channel => resolveRawLegendCreationPlan(program,
271
+ channel === "color" ? { ...args, channels: [channel] }
272
+ : { ...common, channels: [channel] }, layers).steps);
273
+ return { steps, finish: "always" };
274
+ }
264
275
  if (
265
276
  (channels?.length === 1 && channels[0] === "color" && continuousColor) ||
266
277
  (channels === undefined && continuousColor)
@@ -268,7 +279,7 @@ export function resolveLegendCreationPlan(program, args = {}, layers = program.s
268
279
  return { steps: [{ op: "createGradientLegend", args }], finish: "auto" };
269
280
  }
270
281
  const intervalColorCandidates = candidates.filter(layer => {
271
- const encoding = ["point", "bar", "rect"].includes(layer.mark?.type) ? layer.encoding?.color : undefined;
282
+ const encoding = ["point", "bar", "rect", "text"].includes(layer.mark?.type) ? layer.encoding?.color : undefined;
272
283
  const scale = findSemanticScale(program, encoding?.scale);
273
284
  return ["quantize", "quantile", "threshold"].includes(scale?.type);
274
285
  });
@@ -281,7 +292,7 @@ export function resolveLegendCreationPlan(program, args = {}, layers = program.s
281
292
  }
282
293
  const continuousStrokeCandidates = candidates.filter(layer => {
283
294
  const scale = findSemanticScale(program, layer.encoding?.stroke?.scale);
284
- return scale?.type === "sequential";
295
+ return isContinuousColorScaleType(scale?.type);
285
296
  });
286
297
  const continuousStroke = requestedCandidate(
287
298
  program,
@@ -365,6 +376,9 @@ export function resolveLegendCreationPlan(program, args = {}, layers = program.s
365
376
  if (combined) steps.push({ op: "createSizeLegend", args: {
366
377
  target: requestedPoint.id,
367
378
  ...(count === undefined ? {} : { count }),
379
+ ...(categoricalArgs.overflow !== undefined &&
380
+ findSemanticScale(program, requestedPoint.encoding.size.scale)?.type === "ordinal"
381
+ ? { overflow: categoricalArgs.overflow } : {}),
368
382
  ...(categoricalArgs.labels === undefined ? {} : { labels: {
369
383
  ...(categoricalArgs.labels.offset === undefined ? {} : { offset: categoricalArgs.labels.offset }),
370
384
  ...(sizeFormat === undefined ? {} : { format: sizeFormat })
@@ -389,11 +403,50 @@ export function resolveLegendCreationPlan(program, args = {}, layers = program.s
389
403
  return { steps: [{ op: "createCategoricalLegend", args }], finish: "auto" };
390
404
  }
391
405
 
406
+ export function resolveLegendCreationPlan(program, args = {}, layers = program.semanticSpec.layers) {
407
+ validateLegendGroupOptions(args);
408
+ const { blockLayout: _layout, at: _at, ...raw } = args;
409
+ const groups = program.guideConfigs.legendGroups ?? {};
410
+ if (Object.keys(groups).length > 0 && raw.position === undefined) {
411
+ const target = creationLegendTarget(program, args);
412
+ const placement = groups[target]?.placement;
413
+ if (placement?.position !== undefined) {
414
+ raw.position = placement.position;
415
+ raw.align = args.align ?? placement.align;
416
+ raw.offset = args.offset ?? placement.offset;
417
+ }
418
+ }
419
+ if (raw.position === undefined && args.direction === "horizontal" &&
420
+ (args.at !== undefined || Object.keys(groups).length > 0)) raw.position = "top";
421
+ const initial = resolveRawLegendCreationPlan(program, raw, layers);
422
+ const inherited = Object.keys(program.guideConfigs.legendGroups ?? {}).length > 0;
423
+ const corner = PLOT_LEGEND_POSITIONS.includes(args.position) &&
424
+ (initial.steps.length > 1 || initial.steps[0]?.op !== "createCategoricalLegend" ||
425
+ Object.keys(program.guideConfigs.legend ?? {}).length > 0);
426
+ if (args.blockLayout === undefined && args.at === undefined && !inherited && !corner) return initial;
427
+ const target = creationLegendTarget(program, args);
428
+ const first = initial.steps[0];
429
+ const nativeOffset = program.guideConfigs.legendGroups?.[target] !== undefined ? 30 : first?.op === "createCategoricalLegend"
430
+ ? resolveCategoricalLegendConfig(program, first.args).offset
431
+ : first?.op === "createSizeLegend" ? resolveSizeLegendConfig(program, first.args).offset : 30;
432
+ const prepared = prepareLegendGroup(program, args, target, { creation: true, corner, nativeOffset });
433
+ if (!prepared.grouped) return initial;
434
+ if (prepared.options.position === undefined && raw.position !== undefined) {
435
+ prepared.options.position = raw.position;
436
+ }
437
+ return { ...resolveRawLegendCreationPlan(prepared.program, prepared.options, layers),
438
+ legendGroup: { target, config: prepared.program.guideConfigs.legendGroups[target] } };
439
+ }
440
+
392
441
  export function applyLegendCreationPlan(program, plan) {
393
442
  if (plan.steps.length === 0) return program;
394
- let next = program;
443
+ let next = plan.legendGroup === undefined ? program : program._withMaterializationConfig(
444
+ ["guides", "legendGroups", plan.legendGroup.target], plan.legendGroup.config);
395
445
  for (const step of plan.steps) {
396
446
  const existingSize = next.guideConfigs.legend?.size;
447
+ if (plan.combined && existingSize !== undefined && step.op === "createCategoricalLegend") {
448
+ next = next._withLegendConfig("size", { ...existingSize, inheritPosition: true });
449
+ }
397
450
  // The plan already validated that a retained size block is compatible.
398
451
  if (!(plan.combined && step.op === "createSizeLegend" && existingSize !== undefined)) {
399
452
  next = next[step.op](step.args);
@@ -406,7 +459,7 @@ export function applyLegendCreationPlan(program, plan) {
406
459
  export const createLegend = /* @__PURE__ */ action(
407
460
  { op: "createLegend", description: "Create an inferred legend for selected channels." },
408
461
  withGuideLayoutValidation(function (args = {}) {
409
- return applyLegendCreationPlan(this, resolveLegendCreationPlan(this, args));
462
+ return recordLegendOffsetIntent(this, applyLegendCreationPlan(this, resolveLegendCreationPlan(this, args)), args, { creation: true });
410
463
  })
411
464
  );
412
465