ggaction 0.0.18 → 0.0.19

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 (155) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +6 -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/package.json +1 -1
  7. package/src/actions/canvas/fitting.js +6 -2
  8. package/src/actions/charts/heatmap.js +4 -3
  9. package/src/actions/data/derived.js +11 -1
  10. package/src/actions/data/edit.js +2 -0
  11. package/src/actions/data/index.js +4 -0
  12. package/src/actions/data/regression.js +24 -16
  13. package/src/actions/data/regressionStatistics.js +73 -0
  14. package/src/actions/data/shared.js +5 -2
  15. package/src/actions/distributions/revision.js +24 -18
  16. package/src/actions/encodings/remove.js +11 -6
  17. package/src/actions/encodings/shared.js +26 -24
  18. package/src/actions/facets/actions.js +18 -7
  19. package/src/actions/facets/guides.js +2 -1
  20. package/src/actions/guides/axes/axes.js +30 -11
  21. package/src/actions/guides/axes/axis.js +58 -8
  22. package/src/actions/guides/axes/components.js +13 -13
  23. package/src/actions/guides/axes/edit.js +36 -24
  24. package/src/actions/guides/axes/labels.js +37 -27
  25. package/src/actions/guides/axes/lines.js +22 -20
  26. package/src/actions/guides/axes/remove.js +10 -6
  27. package/src/actions/guides/axes/tickGroups.js +8 -7
  28. package/src/actions/guides/axes/ticks.js +31 -22
  29. package/src/actions/guides/axes/titles.js +53 -60
  30. package/src/actions/guides/facadeAxes.js +33 -24
  31. package/src/actions/guides/facadeLegend.js +3 -3
  32. package/src/actions/guides/grids/grid.js +1 -1
  33. package/src/actions/guides/grids/resolve.js +48 -51
  34. package/src/actions/guides/legends/blocks.js +43 -19
  35. package/src/actions/guides/legends/categorical/actions.js +30 -7
  36. package/src/actions/guides/legends/categorical/components.js +3 -10
  37. package/src/actions/guides/legends/categorical/index.js +2 -0
  38. package/src/actions/guides/legends/categorical/layout.js +9 -13
  39. package/src/actions/guides/legends/categorical/options.js +8 -2
  40. package/src/actions/guides/legends/categorical/recipes.js +7 -3
  41. package/src/actions/guides/legends/categorical/resolve.js +9 -2
  42. package/src/actions/guides/legends/categorical/symbols.js +22 -8
  43. package/src/actions/guides/legends/colorSize.js +110 -0
  44. package/src/actions/guides/legends/combine.js +37 -0
  45. package/src/actions/guides/legends/continuous/colorSymbols.js +1 -1
  46. package/src/actions/guides/legends/continuous/common.js +1 -1
  47. package/src/actions/guides/legends/continuous/gradient.js +27 -13
  48. package/src/actions/guides/legends/continuous/opacity.js +16 -11
  49. package/src/actions/guides/legends/creation.js +2 -0
  50. package/src/actions/guides/legends/edit.js +1 -1
  51. package/src/actions/guides/legends/focused.js +6 -5
  52. package/src/actions/guides/legends/lane.js +1 -1
  53. package/src/actions/guides/legends/lifecycle.js +2 -1
  54. package/src/actions/guides/legends/remove.js +6 -3
  55. package/src/actions/guides/legends/size.js +16 -6
  56. package/src/actions/guides/legends/strokeWidth.js +2 -0
  57. package/src/actions/guides/legends/target.js +2 -79
  58. package/src/actions/guides/legends/transition.js +24 -5
  59. package/src/actions/guides/tickValues.js +8 -5
  60. package/src/actions/marks/bar/edit.js +23 -12
  61. package/src/actions/marks/bar/materialize.js +6 -1
  62. package/src/actions/marks/point/materialize.js +5 -1
  63. package/src/actions/marks/remove.js +10 -10
  64. package/src/actions/primitives/createGraphics.js +3 -2
  65. package/src/actions/primitives/editGraphics.js +15 -11
  66. package/src/actions/primitives/graphicProperties.js +1 -6
  67. package/src/actions/primitives/semanticAction.js +27 -1
  68. package/src/actions/primitives/semanticValidation/guide.js +11 -1
  69. package/src/actions/primitives/semanticValidation/layer.js +4 -0
  70. package/src/actions/regression/edit.js +15 -3
  71. package/src/actions/scales/channels.js +2 -2
  72. package/src/actions/scales/create.js +3 -3
  73. package/src/actions/scales/definitions.js +1 -1
  74. package/src/actions/scales/edit.js +1 -1
  75. package/src/actions/scales/editPolicy.js +3 -0
  76. package/src/actions/scales/patch.js +1 -1
  77. package/src/actions/scales/quantitativeColor.js +8 -3
  78. package/src/actions/theme/reconcile.js +50 -23
  79. package/src/core/compositionState.js +2 -2
  80. package/src/core/graphicProperties.js +1 -0
  81. package/src/core/programState.js +4 -0
  82. package/src/core/resourceReferences.js +2 -2
  83. package/src/core/validation.js +9 -0
  84. package/src/core/vocabulary.js +3 -0
  85. package/src/grammar/axisTextPlacement.js +1 -0
  86. package/src/grammar/colorSizeLegend.js +39 -0
  87. package/src/grammar/computed.js +23 -1
  88. package/src/grammar/datasetSchema.js +10 -2
  89. package/src/grammar/displayLabels.js +8 -0
  90. package/src/grammar/facets/guides.js +54 -31
  91. package/src/grammar/facets/legendIntent.js +65 -0
  92. package/src/grammar/guideDash.js +1 -0
  93. package/src/grammar/legendBlocks.js +1 -0
  94. package/src/grammar/legendBorder.js +9 -0
  95. package/src/grammar/legendClip.js +19 -0
  96. package/src/grammar/legendCombinations.js +45 -0
  97. package/src/grammar/legendGradient.js +9 -0
  98. package/src/grammar/legendGroups.js +1 -1
  99. package/src/grammar/legendOpacity.js +6 -0
  100. package/src/grammar/pointShapes.js +16 -2
  101. package/src/grammar/regression/derive.js +9 -1
  102. package/src/grammar/regression/models.js +60 -0
  103. package/src/grammar/regression/parameters.js +9 -1
  104. package/src/grammar/regression/statistics.js +64 -0
  105. package/src/grammar/scales/color.js +1 -1
  106. package/src/grammar/scales/colorConsumers.js +1 -1
  107. package/src/grammar/scales/definition.js +1 -1
  108. package/src/grammar/scales/types.js +1 -1
  109. package/src/grammar/schemas/concreteGraphic.js +3 -0
  110. package/src/grammar/schemas/graphic.js +3 -0
  111. package/src/grammar/schemas/graphicBounds.js +31 -7
  112. package/src/grammar/schemas/semanticPath.js +17 -2
  113. package/src/grammar/summary.js +1 -1
  114. package/src/grammar/timeUnit.js +6 -4
  115. package/src/grammar/transformTopology.js +1 -0
  116. package/src/grammar/transforms.js +11 -1
  117. package/src/inspectionDescriptors.js +1 -1
  118. package/src/layout/guideCollisions.js +9 -1
  119. package/src/layout/legendGroup.js +1 -1
  120. package/src/materialization/compositionSnapshot.js +2 -7
  121. package/src/materialization/coordinateBounds.js +17 -4
  122. package/src/materialization/facetGuides/edit.js +30 -0
  123. package/src/materialization/facetGuides/layout.js +1 -1
  124. package/src/materialization/facetGuides/owner.js +125 -0
  125. package/src/materialization/facetGuides/ownerLayout.js +71 -0
  126. package/src/materialization/facetGuides/placement.js +52 -6
  127. package/src/materialization/facetGuides/preparation.js +7 -0
  128. package/src/materialization/facets.js +1 -1
  129. package/src/materialization/graphicHierarchy.js +5 -1
  130. package/src/materialization/graphicTranslation.js +73 -0
  131. package/src/materialization/guides/layout.js +1 -1
  132. package/src/materialization/guides/resources.js +1 -1
  133. package/src/materialization/legendBorder.js +28 -0
  134. package/src/materialization/legendClip.js +76 -0
  135. package/src/materialization/legendGroups.js +1 -1
  136. package/src/materialization/legends.js +1 -1
  137. package/src/materialization/scaleGuideDependencies.js +13 -14
  138. package/src/materialization/scales/map.js +3 -0
  139. package/src/materialization/scales/resolve.js +2 -0
  140. package/src/materialization/typography.js +10 -2
  141. package/src/persistence/validation.js +174 -1
  142. package/src/persistence.js +1 -1
  143. package/src/renderers/canvas/index.js +20 -10
  144. package/src/renderers/canvas/isolated.js +166 -0
  145. package/src/renderers/canvas/limits.js +3 -0
  146. package/src/renderers/canvas/rect.js +1 -1
  147. package/src/renderers/pdf.js +2 -2
  148. package/src/renderers/png.js +2 -2
  149. package/src/renderers/svg.js +10 -3
  150. package/src/selectors/axes.js +1 -0
  151. package/src/selectors/graphicCapacity.js +38 -0
  152. package/src/version.js +1 -1
  153. package/types/basic.d.ts +2 -0
  154. package/types/index.d.ts +5 -0
  155. package/types/program.d.ts +206 -61
@@ -1,4 +1,7 @@
1
+ import { selectCartesianAxis, axisTargetArgs } from "../../../selectors/axes.js";
2
+ import { axisGraphicIds } from "../../../materialization/guides/resources.js";
1
3
  import { canvasOverflowError } from "../../../layout/canvas.js";
4
+ import { patchGuideDash, validateGuideDash, normalizeGuideDashMap, resolveGuideDashes } from "../../../grammar/guideDash.js";
2
5
  import { editGraphicProperties } from "../../primitives/graphicProperties.js";
3
6
  import { withGuideLayoutValidation } from "../../../materialization/guides/layout.js";
4
7
  import { applyMaterializationPlan } from "../../../materialization/planner.js";
@@ -11,7 +14,7 @@ import {
11
14
  validateNonNegativeFinite,
12
15
  validateOptionObject
13
16
  } from "../../../core/validation.js";
14
- import { resolveGuideCoordinateBounds } from "../../../materialization/coordinateBounds.js";
17
+ import { resolveCartesianAxisFrameBounds } from "../../../materialization/coordinateBounds.js";
15
18
  import {
16
19
  isTransformedScaleType,
17
20
  mapContinuousScaleValues,
@@ -32,7 +35,7 @@ import {
32
35
  import { findCanvasGraphic, resolvePlotGraphicPlacement } from
33
36
  "../../../materialization/graphicHierarchy.js";
34
37
 
35
- const OPTIONS = Object.freeze(["scale", "position", "count", "values", "length", "color", "lineWidth", "alongOffset"]);
38
+ const OPTIONS = Object.freeze(["target", "scale", "position", "count", "values", "length", "color", "lineWidth", "alongOffset", "strokeDash", "strokeDashMap"]);
36
39
  const DEFAULTS = Object.freeze({
37
40
  count: DEFAULT_TICK_COUNT,
38
41
  length: 6,
@@ -54,6 +57,8 @@ function validateOptions(args, operation, create) {
54
57
  }
55
58
 
56
59
  function validateConfig(channel, config) {
60
+ if (config.strokeDash !== undefined) validateGuideDash(config.strokeDash, "Tick strokeDash");
61
+ if (config.strokeDashMap !== undefined) normalizeGuideDashMap(config.strokeDashMap, { reset: false });
57
62
  validateAxisPosition(channel, config.position);
58
63
  if (config.mode === "count") {
59
64
  if (!Number.isInteger(config.count) || config.count <= 0) throw new RangeError("Tick count must be a positive integer.");
@@ -73,13 +78,9 @@ function validateConfig(channel, config) {
73
78
  validateNonEmptyString(config.color, "Tick color");
74
79
  }
75
80
 
76
- function geometry(program, channel, config) {
81
+ function geometry(program, channel, config, axisId = channel) {
77
82
  const scale = program.resolvedScales[config.scale];
78
- const bounds = resolveGuideCoordinateBounds(program, {
79
- coordinate: program.semanticSpec.guides.axis?.[channel]?.coordinate,
80
- scale: config.scale,
81
- channel
82
- });
83
+ const bounds = resolveCartesianAxisFrameBounds(program, channel, config.position, config.scale, axisId);
83
84
  const discrete = ["ordinal", "band", "point"].includes(scale?.type);
84
85
  if ((
85
86
  !["linear", "time", "ordinal", "band", "point"].includes(scale?.type) &&
@@ -139,9 +140,11 @@ function makeEdit(channel) {
139
140
  const op = channel === "x" ? "editXAxisTicks" : "editYAxisTicks";
140
141
  return action({ op, description: `Edit concrete ${channel}-axis ticks.` }, withGuideLayoutValidation(function (args = {}) {
141
142
  validateOptions(args, op, false);
142
- const id = `${channel}AxisTicks`;
143
+ const selected = selectCartesianAxis(this, channel, args, { create: false });
144
+ const axisId = selected.id; args = selected.options;
145
+ const id = axisGraphicIds(channel, axisId)[1];
143
146
  if (this.graphicSpec.objects[id]?.type !== "line") throw new Error(`${op} requires existing axis ticks.`);
144
- const previous = this.guideConfigs.axis?.[channel]?.ticks;
147
+ const previous = this.guideConfigs.axis?.[axisId]?.ticks;
145
148
  if (!previous) throw new Error(`${op} requires tick configuration.`);
146
149
  const explicitMode = Object.hasOwn(args, "values") || Object.hasOwn(args, "count");
147
150
  const inferredValues = !explicitMode && previous.inferredValues === true
@@ -155,24 +158,28 @@ function makeEdit(channel) {
155
158
  const mode = Object.hasOwn(args, "values") || inferredValues !== undefined
156
159
  ? "values"
157
160
  : Object.hasOwn(args, "count") ? "count" : previous.mode;
158
- const config = {
161
+ const config = patchGuideDash({
159
162
  ...previous,
160
163
  ...args,
161
164
  ...(inferredValues === undefined ? {} : { values: inferredValues }),
162
165
  ...(explicitMode ? { inferredValues: false } : {}),
163
166
  mode
164
- };
167
+ }, args);
165
168
  if (mode === "values") delete config.count; else delete config.values;
166
169
  validateConfig(channel, config);
167
- const resolved = geometry(this, channel, config);
168
- let next = this._withGuideConfig(channel, config).editGraphics({ target: id, property: "length", value: resolved.values.length });
170
+ const resolved = geometry(this, channel, config, axisId);
171
+ let next = this._withGuideConfig(axisId, config).editGraphics({ target: id, property: "length", value: resolved.values.length });
169
172
  for (const property of ["x1", "y1", "x2", "y2"]) next = next.editGraphics({ target: id, property, value: resolved[property] });
170
173
  next = editGraphicProperties(next, id, {
171
174
  stroke: config.color,
172
175
  strokeWidth: config.lineWidth
173
176
  });
177
+ if (config.strokeDash !== undefined || config.strokeDashMap !== undefined ||
178
+ previous.strokeDashMap !== undefined) {
179
+ next = next.editGraphics({ target: id, property: "strokeDash", value: resolveGuideDashes(resolved.values, config) });
180
+ }
174
181
  // Structural dependency plans own grids during a scale/role handoff.
175
- return explicitMode ? applyMaterializationPlan(next, planAxisTickGridRematerialization(next, channel)) : next;
182
+ return explicitMode ? applyMaterializationPlan(next, planAxisTickGridRematerialization(next, channel, axisId)) : next;
176
183
  }));
177
184
  }
178
185
 
@@ -183,10 +190,12 @@ function makeCreate(channel) {
183
190
  const edit = channel === "x" ? "editXAxisTicks" : "editYAxisTicks";
184
191
  return action({ op, description: `Create concrete ${channel}-axis ticks.` }, withGuideLayoutValidation(function (args = {}) {
185
192
  validateOptions(args, op, true);
193
+ const selected = selectCartesianAxis(this, channel, args, { create: true });
194
+ const axisId = selected.id; args = selected.options;
186
195
  const scale = validateUserId(args.scale ?? channel, "Scale id");
187
- const existingGuide = this.semanticSpec.guides.axis?.[channel]?.scale;
196
+ const existingGuide = this.semanticSpec.guides.axis?.[axisId]?.scale;
188
197
  if (existingGuide && existingGuide !== scale) throw new Error(`${op} conflicts with the existing axis scale.`);
189
- const id = `${channel}AxisTicks`;
198
+ const id = axisGraphicIds(channel, axisId)[1];
190
199
  if (this.graphicSpec.objects[id]) throw new Error(`${op} requires missing axis ticks.`);
191
200
  const resolvedScale = this.resolvedScales[scale];
192
201
  const inferredValues =
@@ -197,18 +206,18 @@ function makeCreate(channel) {
197
206
  : inferHistogramBoundaries(this, channel, scale);
198
207
  const options =
199
208
  inferredValues === undefined ? args : { ...args, values: inferredValues };
200
- const config = { scale, position: defaultAxisPosition(channel), length: DEFAULTS.length, color: DEFAULTS.color, lineWidth: DEFAULTS.lineWidth, ...options, inferredValues: inferredValues !== undefined, mode: Object.hasOwn(options, "values") ? "values" : "count" };
209
+ const config = patchGuideDash({ scale, position: defaultAxisPosition(channel), length: DEFAULTS.length, color: DEFAULTS.color, lineWidth: DEFAULTS.lineWidth, ...options, inferredValues: inferredValues !== undefined, mode: Object.hasOwn(options, "values") ? "values" : "count" }, args);
201
210
  if (config.mode === "values") delete config.count; else config.count ??= DEFAULTS.count;
202
- validateConfig(channel, config); geometry(this, channel, config);
203
- const next = this.editSemantic({ property: `guide.axis.${channel}.scale`, value: scale })
211
+ validateConfig(channel, config); geometry(this, channel, config, axisId);
212
+ const next = this.editSemantic({ property: `guide.axis.${axisId}.scale`, value: scale })
204
213
  .createGraphics({
205
214
  id,
206
215
  type: "line",
207
216
  length: 0,
208
217
  ...resolvePlotGraphicPlacement(this)
209
218
  })
210
- ._withGuideConfig(channel, config)[edit]();
211
- return applyMaterializationPlan(next, planAxisTickGridRematerialization(next, channel));
219
+ ._withGuideConfig(axisId, config)[edit](axisId === channel ? undefined : axisTargetArgs(axisId, channel));
220
+ return applyMaterializationPlan(next, planAxisTickGridRematerialization(next, channel, axisId));
212
221
  }));
213
222
  }
214
223
 
@@ -1,4 +1,7 @@
1
+ import { selectCartesianAxis, axisTargetArgs } from "../../../selectors/axes.js";
2
+ import { axisGraphicIds } from "../../../materialization/guides/resources.js";
1
3
  import { materializeTextLines } from "../../../materialization/textLines.js";
4
+ import { axisTitleTextLines as titleLines, validateAxisTextPlacement } from "../../../grammar/axisTextPlacement.js";
2
5
  import { axisThemeTypography } from "../../theme/state.js";
3
6
  import { canvasOverflowError } from "../../../layout/canvas.js";
4
7
  import { isSourceOwnedText } from "../../../grammar/text.js";
@@ -7,10 +10,9 @@ import { action } from "../../../core/action.js";
7
10
  import { validateUserId } from "../../../core/identifiers.js";
8
11
  import {
9
12
  validateKeys,
10
- validateNonEmptyString,
11
- validateNonNegativeFinite
13
+ validateNonEmptyString
12
14
  } from "../../../core/validation.js";
13
- import { axisScaleBindings, resolveGuideCoordinateBounds } from "../../../materialization/coordinateBounds.js";
15
+ import { axisScaleBindings, resolveCartesianAxisFrameBounds } from "../../../materialization/coordinateBounds.js";
14
16
  import {
15
17
  isTransformedScaleType,
16
18
  mapContinuousScaleValues,
@@ -31,7 +33,6 @@ import { findCanvasGraphic, resolvePlotGraphicPlacement } from
31
33
  "../../../materialization/graphicHierarchy.js";
32
34
  import {
33
35
  resolveTextBounds,
34
- resolveTextLines,
35
36
  textBoundsFitCanvas,
36
37
  textBoundsIntersect
37
38
  } from "../../../core/textMetrics.js";
@@ -40,14 +41,6 @@ import { resolveConcreteGraphicBounds } from
40
41
  import { validateAxisTextStyle } from "./labels.js";
41
42
  import { resolveRotation } from "../../../grammar/rotation.js";
42
43
 
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
-
51
44
  function titleBounds(geometry, config, text, profile) {
52
45
  return resolveTextBounds({
53
46
  ...geometry,
@@ -56,8 +49,8 @@ function titleBounds(geometry, config, text, profile) {
56
49
  fontSize: config.fontSize,
57
50
  fontFamily: config.fontFamily,
58
51
  fontWeight: config.fontWeight,
59
- textAlign: "center",
60
- textBaseline: "middle",
52
+ textAlign: config.align ?? "center",
53
+ textBaseline: config.baseline ?? "middle",
61
54
  rotation: config.rotation
62
55
  }, profile);
63
56
  }
@@ -79,26 +72,22 @@ function resolveTitleAlong(scale, config) {
79
72
  along = mapContinuousScaleValues([config.at], scale)[0];
80
73
  }
81
74
  }
82
- return along;
75
+ return along + (config.alongOffset ?? 0);
83
76
  }
84
77
 
85
- function axisLabelBounds(program, channel) {
86
- const id = `${channel}AxisLabels`, labels = program.graphicSpec.objects[id];
78
+ function axisLabelBounds(program, channel, axisId = channel) {
79
+ const id = axisGraphicIds(channel, axisId)[2], labels = program.graphicSpec.objects[id];
87
80
  const bounds = target => resolveConcreteGraphicBounds(
88
81
  program.graphicSpec, target, program.materializationConfigs.textMetrics
89
82
  );
90
83
  return labels?.items?.map(item => bounds(item.id)) ?? (labels ? [bounds(id)] : []);
91
84
  }
92
85
 
93
- function inferredTitleOffset(program, channel, config) {
94
- const plot = resolveGuideCoordinateBounds(program, {
95
- coordinate: program.semanticSpec.guides.axis?.[channel]?.coordinate,
96
- scale: config.scale,
97
- channel
98
- });
86
+ function inferredTitleOffset(program, channel, config, axisId = channel) {
87
+ const plot = resolveCartesianAxisFrameBounds(program, channel, config.position, config.scale, axisId);
99
88
  const canvas = findCanvasGraphic(program)?.properties;
100
- const labels = program.graphicSpec.objects[`${channel}AxisLabels`]
101
- ? resolveConcreteGraphicBounds(program.graphicSpec, `${channel}AxisLabels`, program.materializationConfigs.textMetrics)
89
+ const labels = program.graphicSpec.objects[axisGraphicIds(channel, axisId)[2]]
90
+ ? resolveConcreteGraphicBounds(program.graphicSpec, axisGraphicIds(channel, axisId)[2], program.materializationConfigs.textMetrics)
102
91
  : undefined;
103
92
  const preferred = channel === "x" ? 42 : 52;
104
93
  if (!plot || !canvas || !labels) return preferred;
@@ -112,7 +101,7 @@ function inferredTitleOffset(program, channel, config) {
112
101
  position: config.position,
113
102
  along: 0,
114
103
  offset: preferred
115
- }), config, program.semanticSpec.guides.axis?.[channel]?.title ?? "", program.materializationConfigs.textMetrics);
104
+ }), config, program.semanticSpec.guides.axis?.[axisId]?.title ?? "", program.materializationConfigs.textMetrics);
116
105
  const needed = positive
117
106
  ? labels[far] + 4 - bounds[near]
118
107
  : bounds[far] - labels[near] + 4;
@@ -135,15 +124,15 @@ function inferredTitleOffset(program, channel, config) {
135
124
  bounds: plot, channel, position: config.position,
136
125
  along: resolveTitleAlong(program.resolvedScales[config.scale], config),
137
126
  offset: preferred + adjustment
138
- }), config, program.semanticSpec.guides.axis?.[channel]?.title ?? "", program.materializationConfigs.textMetrics);
127
+ }), config, program.semanticSpec.guides.axis?.[axisId]?.title ?? "", program.materializationConfigs.textMetrics);
139
128
  // Optional ink reserve must not introduce a label collision.
140
- return preferred + (axisLabelBounds(program, channel).some(label => label && textBoundsIntersect(candidateBounds, label))
129
+ return preferred + (axisLabelBounds(program, channel, axisId).some(label => label && textBoundsIntersect(candidateBounds, label))
141
130
  ? legacyAdjustment : adjustment);
142
131
  }
143
132
 
144
- const CREATE_OPTIONS = [
133
+ const CREATE_OPTIONS = ["target",
145
134
  "text", "scale", "position", "at", "offset", "rotation", "color",
146
- "fontSize", "fontFamily", "fontWeight"
135
+ "fontSize", "fontFamily", "fontWeight", "alongOffset", "align", "baseline"
147
136
  ];
148
137
  const EDIT_OPTIONS = CREATE_OPTIONS.filter(key => key !== "scale");
149
138
  const DEFAULTS = {
@@ -160,7 +149,7 @@ function validateText(text) {
160
149
  function validateConfig(channel, config) {
161
150
  validateAxisPosition(channel, config.position);
162
151
  if (!["start", "center", "end"].includes(config.at) && !Number.isFinite(config.at)) throw new TypeError("Axis title at must be start, center, end, or a finite number.");
163
- validateNonNegativeFinite(config.offset, "Axis title offset");
152
+ validateAxisTextPlacement(config, { title: true });
164
153
  if (!Number.isFinite(config.rotation)) throw new TypeError("Axis title rotation must be finite radians.");
165
154
  validateAxisTextStyle(config, "Axis title");
166
155
  }
@@ -214,13 +203,9 @@ export function inferAxisTitleText(program, channel, scaleId) {
214
203
  return [...titles][0];
215
204
  }
216
205
 
217
- function resolveGeometry(program, channel, config) {
206
+ function resolveGeometry(program, channel, config, axisId = channel) {
218
207
  const scale = program.resolvedScales[config.scale];
219
- const bounds = resolveGuideCoordinateBounds(program, {
220
- coordinate: program.semanticSpec.guides.axis?.[channel]?.coordinate,
221
- scale: config.scale,
222
- channel
223
- });
208
+ const bounds = resolveCartesianAxisFrameBounds(program, channel, config.position, config.scale, axisId);
224
209
  if ((
225
210
  !["linear", "time", "ordinal", "band", "point"].includes(scale?.type) &&
226
211
  !isTransformedScaleType(scale?.type)
@@ -236,13 +221,13 @@ function resolveGeometry(program, channel, config) {
236
221
  const resolvedBounds = titleBounds(
237
222
  geometry,
238
223
  config,
239
- program.semanticSpec.guides.axis?.[channel]?.title ?? ""
224
+ program.semanticSpec.guides.axis?.[axisId]?.title ?? ""
240
225
  , program.materializationConfigs.textMetrics);
241
226
  const canvas = findCanvasGraphic(program)?.properties;
242
227
  if (!canvas || !textBoundsFitCanvas(resolvedBounds, canvas)) {
243
228
  throw canvasOverflowError(`The ${channel}-axis title does not fit the Canvas margin.`, [resolvedBounds], canvas);
244
229
  }
245
- const labelBounds = axisLabelBounds(program, channel);
230
+ const labelBounds = axisLabelBounds(program, channel, axisId);
246
231
  if (labelBounds.some(bounds => bounds && textBoundsIntersect(resolvedBounds, bounds))) {
247
232
  throw new Error(`The ${channel}-axis title overlaps the axis labels.`);
248
233
  }
@@ -251,15 +236,19 @@ function resolveGeometry(program, channel, config) {
251
236
 
252
237
  function names(channel) {
253
238
  const prefix = channel === "x" ? "X" : "Y";
254
- return { create: `create${prefix}AxisTitle`, edit: `edit${prefix}AxisTitle`, graphic: `${channel}AxisTitle` };
239
+ return { create: `create${prefix}AxisTitle`, edit: `edit${prefix}AxisTitle` };
255
240
  }
256
241
 
257
242
  function makeEdit(channel) {
258
243
  const operation = names(channel);
259
244
  return action({ op: operation.edit, description: `Edit the ${channel}-axis title.` }, withGuideLayoutValidation(function (args = {}) {
260
245
  validateKeys(args, EDIT_OPTIONS, operation.edit);
261
- if (this.graphicSpec.objects[operation.graphic]?.type !== "text") throw new Error(`${operation.edit} requires an existing axis title.`);
262
- const previous = this.guideConfigs.axis?.[channel]?.title;
246
+ const selected = selectCartesianAxis(this, channel, args, { create: false });
247
+ const axisId = selected.id; args = selected.options;
248
+ const graphic = axisGraphicIds(channel, axisId)[3];
249
+ validateAxisTextPlacement(args, { title: true });
250
+ if (this.graphicSpec.objects[graphic]?.type !== "text") throw new Error(`${operation.edit} requires an existing axis title.`);
251
+ const previous = this.guideConfigs.axis?.[axisId]?.title;
263
252
  if (!previous) throw new Error(`${operation.edit} requires title configuration.`);
264
253
  const { text, rotation: requestedRotation, ...appearance } = args;
265
254
  const explicitText = Object.hasOwn(args, "text");
@@ -289,27 +278,27 @@ function makeEdit(channel) {
289
278
  ? validateText(text)
290
279
  : config.inferredText === true
291
280
  ? inferAxisTitleText(this, channel, config.scale)
292
- : this.semanticSpec.guides.axis?.[channel]?.title;
293
- if (resolvedTitle !== this.semanticSpec.guides.axis?.[channel]?.title) {
281
+ : this.semanticSpec.guides.axis?.[axisId]?.title;
282
+ if (resolvedTitle !== this.semanticSpec.guides.axis?.[axisId]?.title) {
294
283
  next = next.editSemantic({
295
- property: `guide.axis.${channel}.title`,
284
+ property: `guide.axis.${axisId}.title`,
296
285
  value: validateText(resolvedTitle)
297
286
  });
298
287
  }
299
- const resolvedText = validateText(next.semanticSpec.guides.axis?.[channel]?.title);
288
+ const resolvedText = validateText(next.semanticSpec.guides.axis?.[axisId]?.title);
300
289
  if (config.inferredOffset) {
301
- config.offset = inferredTitleOffset(next, channel, config);
290
+ config.offset = inferredTitleOffset(next, channel, config, axisId);
302
291
  }
303
- const geometry = resolveGeometry(next, channel, config);
304
- next = next._withGuideConfig(channel, "title", config);
292
+ const geometry = resolveGeometry(next, channel, config, axisId);
293
+ next = next._withGuideConfig(axisId, "title", config);
305
294
  const properties = {
306
295
  x: geometry.x, y: geometry.y, text: resolvedText, fill: config.color,
307
296
  fontSize: config.fontSize, fontFamily: config.fontFamily,
308
- fontWeight: config.fontWeight, textAlign: "center", textBaseline: "middle",
297
+ fontWeight: config.fontWeight, textAlign: config.align ?? "center", textBaseline: config.baseline ?? "middle",
309
298
  rotation: config.rotation
310
299
  };
311
- for (const [property, value] of Object.entries(properties)) next = next.editGraphics({ target: operation.graphic, property, value });
312
- return materializeTextLines(next, operation.graphic, titleLines(resolvedText, config));
300
+ for (const [property, value] of Object.entries(properties)) next = next.editGraphics({ target: graphic, property, value });
301
+ return materializeTextLines(next, graphic, titleLines(resolvedText, config));
313
302
  }));
314
303
  }
315
304
 
@@ -320,6 +309,10 @@ function makeCreate(channel) {
320
309
  const operation = names(channel);
321
310
  return action({ op: operation.create, description: `Create the ${channel}-axis title.` }, withGuideLayoutValidation(function (args = {}) {
322
311
  validateKeys(args, CREATE_OPTIONS, operation.create);
312
+ const selected = selectCartesianAxis(this, channel, args, { create: true });
313
+ const axisId = selected.id; args = selected.options;
314
+ const graphic = axisGraphicIds(channel, axisId)[3];
315
+ validateAxisTextPlacement(args, { title: true });
323
316
  const {
324
317
  text: requestedText,
325
318
  scale: requestedScale,
@@ -328,9 +321,9 @@ function makeCreate(channel) {
328
321
  ...appearance
329
322
  } = args;
330
323
  const scale = validateUserId(requestedScale ?? channel, "Scale id");
331
- const guideScale = this.semanticSpec.guides.axis?.[channel]?.scale;
324
+ const guideScale = this.semanticSpec.guides.axis?.[axisId]?.scale;
332
325
  if (guideScale && guideScale !== scale) throw new Error(`${operation.create} conflicts with the existing axis scale.`);
333
- if (this.graphicSpec.objects[operation.graphic]) throw new Error(`${operation.create} requires a missing axis title.`);
326
+ if (this.graphicSpec.objects[graphic]) throw new Error(`${operation.create} requires a missing axis title.`);
334
327
  const inferredText = !Object.hasOwn(args, "text");
335
328
  const text = validateText(
336
329
  requestedText ?? inferAxisTitleText(this, channel, scale)
@@ -357,18 +350,18 @@ function makeCreate(channel) {
357
350
  ...appearance
358
351
  };
359
352
  let next = this
360
- .editSemantic({ property: `guide.axis.${channel}.scale`, value: scale })
361
- .editSemantic({ property: `guide.axis.${channel}.title`, value: text });
362
- if (inferredOffset) config.offset = inferredTitleOffset(next, channel, config);
353
+ .editSemantic({ property: `guide.axis.${axisId}.scale`, value: scale })
354
+ .editSemantic({ property: `guide.axis.${axisId}.title`, value: text });
355
+ if (inferredOffset) config.offset = inferredTitleOffset(next, channel, config, axisId);
363
356
  validateConfig(channel, config);
364
- resolveGeometry(next, channel, config);
357
+ resolveGeometry(next, channel, config, axisId);
365
358
  return next
366
359
  .createGraphics({
367
- id: operation.graphic,
360
+ id: graphic,
368
361
  type: "text",
369
362
  ...resolvePlotGraphicPlacement(this)
370
363
  })
371
- ._withGuideConfig(channel, "title", config)[operation.edit]();
364
+ ._withGuideConfig(axisId, "title", config)[operation.edit](axisId === channel ? undefined : axisTargetArgs(axisId, channel));
372
365
  }));
373
366
  }
374
367
 
@@ -1,3 +1,4 @@
1
+ import { axisTargetArgs } from "../../selectors/axes.js";
1
2
  import { validatePolarAxisArgs } from "./polar/axes/facade.js";
2
3
  import { prefix as polarPrefix, resolveAngle } from "./polar/axes/shared.js";
3
4
  import { validateAxesArgs } from "./axes/axes.js";
@@ -47,24 +48,26 @@ export function scopeFacadeAxes(program, layer, args) {
47
48
  }
48
49
  const scoped = { coordinate: { id: coordinate.id, type: "cartesian" } };
49
50
  for (const channel of ["x", "y"]) {
50
- const option = args[channel];
51
- if (option === false || (option === undefined && layer.encoding?.[channel] === undefined)) {
52
- scoped[channel] = false;
53
- continue;
54
- }
55
- const scale = layer.encoding?.[channel]?.scale;
56
- if (scale === undefined) guideConflict(`this facade has no ${channel} scale`);
57
- if (option?.scale !== undefined && option.scale !== scale) {
58
- guideConflict(`axes.${channel}.scale does not belong to this facade`);
59
- }
60
- if (option?.coordinate !== undefined && option.coordinate !== coordinate.id) {
61
- guideConflict(`axes.${channel}.coordinate does not belong to this facade`);
62
- }
63
- scoped[channel] = { ...option, scale, coordinate: coordinate.id };
64
- validateAxisArgs(scoped[channel], `create${channel.toUpperCase()}Axis`);
65
- if (scoped[channel].ticksAndLabels !== false) {
66
- validateAxisTickGroupArgs(scoped[channel].ticksAndLabels ?? {}, "createAxisTicksAndLabels", true);
67
- }
51
+ const scope = option => {
52
+ if (option === false || (option === undefined && layer.encoding?.[channel] === undefined)) {
53
+ return false;
54
+ }
55
+ const scale = layer.encoding?.[channel]?.scale;
56
+ if (scale === undefined) guideConflict(`this facade has no ${channel} scale`);
57
+ if (option?.scale !== undefined && option.scale !== scale) {
58
+ guideConflict(`axes.${channel}.scale does not belong to this facade`);
59
+ }
60
+ if (option?.coordinate !== undefined && option.coordinate !== coordinate.id) {
61
+ guideConflict(`axes.${channel}.coordinate does not belong to this facade`);
62
+ }
63
+ const scopedOption = { ...option, scale, coordinate: coordinate.id };
64
+ validateAxisArgs(scopedOption, `create${channel.toUpperCase()}Axis`);
65
+ if (scopedOption.ticksAndLabels !== false) {
66
+ validateAxisTickGroupArgs(scopedOption.ticksAndLabels ?? {}, "createAxisTicksAndLabels", true);
67
+ }
68
+ return scopedOption;
69
+ };
70
+ scoped[channel] = Array.isArray(args[channel]) ? args[channel].map(scope) : scope(args[channel]);
68
71
  }
69
72
  return scoped;
70
73
  }
@@ -76,21 +79,25 @@ function tickMode(config) {
76
79
 
77
80
  function planAxis(program, channel, args, explicit) {
78
81
  const prefix = channel.toUpperCase();
79
- const guide = program.semanticSpec.guides.axis?.[channel];
80
- const configs = program.guideConfigs.axis?.[channel] ?? {};
82
+ const axisId = args.id ?? channel;
83
+ const guide = program.semanticSpec.guides.axis?.[axisId];
84
+ const configs = program.guideConfigs.axis?.[axisId] ?? {};
81
85
  if (guide === undefined && Object.keys(configs).length === 0) {
82
86
  return [{ op: `create${prefix}Axis`, args }];
83
87
  }
84
88
  if (guide?.scale !== args.scale || resolveStoredGuideCoordinate(program, guide, channel) !== args.coordinate) {
85
89
  guideConflict(`${channel} axis uses a different coordinate or scale`);
86
90
  }
91
+ if (explicit.offset !== undefined && explicit.offset !== (configs.frame?.offset ?? 0)) {
92
+ guideConflict(`${channel} axis uses a different frame offset`);
93
+ }
87
94
  const positions = [...new Set(Object.values(configs).map(config => config.position).filter(Boolean))];
88
95
  if (positions.length > 1) guideConflict(`${channel} axis components have incompatible positions`);
89
96
  const position = explicit.position ?? positions[0];
90
- const shared = { scale: args.scale, ...(position === undefined ? {} : { position }) };
97
+ const shared = { ...axisTargetArgs(axisId, channel), scale: args.scale, ...(position === undefined ? {} : { position }) };
91
98
  const explicitShared = explicit.position === undefined ? {} : { position: explicit.position };
92
99
  const steps = guide.coordinate === undefined ? [{
93
- op: "editSemantic", args: { property: `guide.axis.${channel}.coordinate`, value: args.coordinate }
100
+ op: "editSemantic", args: { property: `guide.axis.${axisId}.coordinate`, value: args.coordinate }
94
101
  }] : [];
95
102
  const group = args.ticksAndLabels ?? {};
96
103
  const explicitGroup = explicit.ticksAndLabels ?? {};
@@ -106,7 +113,7 @@ function planAxis(program, channel, args, explicit) {
106
113
  for (const component of ["line", "ticks", "labels", "title"]) {
107
114
  const config = configs[component];
108
115
  if (axisComponentDisabled(args, component)) {
109
- if (hasAxisComponent(program, channel, component)) guideConflict(`${channel} axis already has ${component}`);
116
+ if (hasAxisComponent(program, channel, component, axisId)) guideConflict(`${channel} axis already has ${component}`);
110
117
  continue;
111
118
  }
112
119
  if (config === undefined) continue;
@@ -154,7 +161,9 @@ export function planFacadeAxes(program, layer, scoped, explicit = {}) {
154
161
  : planPolarAxis(program, channel, scoped[channel], explicit[channel] ?? {}));
155
162
  }
156
163
  return ["x", "y"].flatMap(channel => scoped[channel] === false ? []
157
- : planAxis(program, channel, scoped[channel], explicit[channel] ?? {}));
164
+ : Array.isArray(scoped[channel])
165
+ ? scoped[channel].flatMap((entry, index) => planAxis(program, channel, entry, explicit[channel]?.[index] ?? {}))
166
+ : planAxis(program, channel, scoped[channel], explicit[channel] ?? {}));
158
167
  }
159
168
 
160
169
  function planPolarAxis(program, channel, args, explicit) {
@@ -4,8 +4,8 @@ import { legendResourcePolicy } from "../../materialization/guides/resources.js"
4
4
  import { assertGuideOptions, guideConflict, sameGuideValue } from "./reuse.js";
5
5
 
6
6
  function compatibleLegend(program, kind, requested, existing, explicit) {
7
- const expectedScales = requested.scales ?? [requested.scale];
8
- const actualScales = existing.scales ?? [existing.scale];
7
+ const expectedScales = kind === "colorSize" ? [requested.colorScale,requested.sizeScale] : requested.scales ?? [requested.scale];
8
+ const actualScales = kind === "colorSize" ? [existing.colorScale,existing.sizeScale] : existing.scales ?? [existing.scale];
9
9
  if (!sameGuideValue(expectedScales, actualScales) ||
10
10
  !sameGuideValue(requested.channels, existing.channels)) {
11
11
  guideConflict(`${kind} legend uses different channels or scales`);
@@ -18,7 +18,7 @@ function compatibleLegend(program, kind, requested, existing, explicit) {
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
20
  if (explicit.representation !== undefined && (requested.representation ?? "gradient") !== (existing.representation ?? "gradient")) guideConflict(`${kind} legend uses a different color representation`);
21
- if (requested.representation === "symbols") {
21
+ if (requested.representation === "symbols" || kind === "colorSize") {
22
22
  if (explicit.values !== undefined && !sameGuideValue(requested.sampling.values, existing.sampling?.values)) guideConflict(`${kind} legend uses different exact samples`);
23
23
  if (explicit.count !== undefined && (existing.sampling?.mode !== "auto" || requested.sampling.count !== existing.sampling?.count)) guideConflict(`${kind} legend uses a different sample count`);
24
24
  }
@@ -85,7 +85,7 @@ function makeRematerialize(direction) {
85
85
  .editGraphics({
86
86
  target: operation.graphic,
87
87
  property: "strokeDash",
88
- value: geometry.values.map(() => config.strokeDash)
88
+ value: geometry.strokeDash
89
89
  });
90
90
  }
91
91
  );