ggaction 0.0.5 → 0.0.6

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 (35) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/actions/boxPlots/edit.js +455 -3
  5. package/src/actions/boxPlots/options.js +3 -3
  6. package/src/actions/composition/actions.js +13 -3
  7. package/src/actions/data/bin2d.js +140 -17
  8. package/src/actions/data/index.js +6 -1
  9. package/src/actions/data/intervalEdit.js +76 -0
  10. package/src/actions/distributions/revision.js +326 -0
  11. package/src/actions/encodings/appearance.js +35 -1
  12. package/src/actions/encodings/density.js +215 -80
  13. package/src/actions/encodings/index.js +2 -0
  14. package/src/actions/encodings/remove.js +268 -0
  15. package/src/actions/errorBands/edit.js +121 -13
  16. package/src/actions/errorBars/edit.js +45 -8
  17. package/src/actions/errorBars/options.js +7 -1
  18. package/src/actions/facets/actions.js +136 -1
  19. package/src/actions/gradientPlots/edit.js +352 -4
  20. package/src/actions/guides/axes/edit.js +129 -47
  21. package/src/actions/guides/legends/categorical/actions.js +19 -8
  22. package/src/actions/guides/legends/continuous/common.js +3 -3
  23. package/src/actions/guides/legends/edit.js +61 -3
  24. package/src/actions/guides/legends/remove.js +124 -23
  25. package/src/actions/guides/legends/strokeWidth.js +42 -14
  26. package/src/actions/marks/arc/actions.js +46 -14
  27. package/src/actions/marks/point/edit.js +15 -3
  28. package/src/actions/marks/point/materialize.js +9 -3
  29. package/src/actions/marks/rule/actions.js +4 -1
  30. package/src/actions/regression/edit.js +235 -74
  31. package/src/actions/selection/actions.js +122 -2
  32. package/src/actions/selection/index.js +6 -0
  33. package/src/core/vocabulary.js +7 -0
  34. package/types/index.d.ts +1 -0
  35. package/types/program.d.ts +71 -7
@@ -1,20 +1,34 @@
1
1
  import { action } from "../../core/action.js";
2
2
  import { isPlainObject } from "../../core/immutable.js";
3
+ import { validateUserId } from "../../core/identifiers.js";
3
4
  import { validateKeys } from "../../core/validation.js";
4
5
  import { planDerivedDataRevision } from "../../materialization/dataProvenance.js";
5
6
  import { findDataset } from "../../selectors/datasets.js";
6
7
  import { findLayer } from "../../selectors/layers.js";
8
+ import { GRADIENT_PROFILE_FIELDS } from "../../grammar/gradientProfile.js";
7
9
  import { removeGradientPlotLegend } from "./components.js";
10
+ import {
11
+ assertDistributionScaleHandoff,
12
+ clearCartesianPositions,
13
+ rebindDistributionGuides,
14
+ resolveDistributionScalePlan,
15
+ setCartesianPosition,
16
+ setCartesianRange
17
+ } from "../distributions/revision.js";
8
18
  import {
9
19
  resolveGradientAppearance,
10
20
  resolveGradientCenter,
11
21
  resolveGradientDensity,
22
+ resolveGradientPosition,
12
23
  resolveGradientWidth
13
24
  } from "./options.js";
14
- import { resolveGradientOwner } from "./resolve.js";
25
+ import {
26
+ normalizeGradientPositionTypes,
27
+ resolveGradientOwner
28
+ } from "./resolve.js";
15
29
 
16
30
  const OPTIONS = Object.freeze([
17
- "target", "density", "width", "gradient", "center"
31
+ "target", "data", "x", "y", "density", "width", "gradient", "center"
18
32
  ]);
19
33
 
20
34
  function patch(value, label) {
@@ -26,13 +40,224 @@ function patch(value, label) {
26
40
 
27
41
  function removeCenter(program, id) {
28
42
  let next = program;
43
+ const selections = Object.entries(
44
+ program.materializationConfigs.selections ?? {}
45
+ ).filter(([, config]) => config.target === id).map(([selection]) => selection);
46
+ for (const [highlight, config] of Object.entries(
47
+ program.materializationConfigs.highlights ?? {}
48
+ )) {
49
+ if (config.target === id || selections.includes(config.selection)) {
50
+ next = next._withoutMaterializationConfig(["highlights", highlight]);
51
+ }
52
+ }
53
+ for (const selection of selections) {
54
+ next = next._withoutMaterializationConfig(["selections", selection]);
55
+ }
29
56
  if (findLayer(next, id) !== undefined) {
30
57
  next = next.editSemantic({ property: `layer[${id}]`, remove: true });
31
58
  }
32
59
  if (next.graphicSpec.objects[id] !== undefined) {
33
60
  next = next.editGraphics({ target: id, remove: true });
34
61
  }
35
- return next._withoutMaterializationConfig(["marks", id]);
62
+ return next
63
+ ._withoutMaterializationConfig(["marks", id])
64
+ ._withContext({
65
+ ...(program.context.currentMark === id ? { currentMark: undefined } : {}),
66
+ ...(selections.includes(program.context.currentSelection)
67
+ ? { currentSelection: undefined }
68
+ : {})
69
+ });
70
+ }
71
+
72
+ function currentGradientPositions(owner, current) {
73
+ const categoryChannel = current.orientation === "vertical" ? "x" : "y";
74
+ const measureChannel = current.orientation === "vertical" ? "y" : "x";
75
+ const categoryEncoding = owner.encoding[categoryChannel];
76
+ const measureEncoding = owner.encoding[measureChannel];
77
+ return {
78
+ x: current.orientation === "vertical"
79
+ ? {
80
+ field: current.category,
81
+ fieldType: current.categoryType,
82
+ scale: categoryEncoding.scale
83
+ }
84
+ : {
85
+ field: current.measure,
86
+ fieldType: "quantitative",
87
+ scale: measureEncoding.scale
88
+ },
89
+ y: current.orientation === "vertical"
90
+ ? {
91
+ field: current.measure,
92
+ fieldType: "quantitative",
93
+ scale: measureEncoding.scale
94
+ }
95
+ : {
96
+ field: current.category,
97
+ fieldType: current.categoryType,
98
+ scale: categoryEncoding.scale
99
+ },
100
+ categoryScale: categoryEncoding.scale,
101
+ measureScale: measureEncoding.scale
102
+ };
103
+ }
104
+
105
+ function requirePositionField(position, label) {
106
+ if (typeof position?.field !== "string" || position.field.length === 0) {
107
+ throw new TypeError(`${label} field must be a non-empty string.`);
108
+ }
109
+ return position;
110
+ }
111
+
112
+ function roleCandidate(program, owner, current, args) {
113
+ const previous = currentGradientPositions(owner, current);
114
+ const positions = normalizeGradientPositionTypes(
115
+ requirePositionField(
116
+ Object.hasOwn(args, "x")
117
+ ? resolveGradientPosition(args.x, "x", "editGradientPlot")
118
+ : previous.x,
119
+ "editGradientPlot x"
120
+ ),
121
+ requirePositionField(
122
+ Object.hasOwn(args, "y")
123
+ ? resolveGradientPosition(args.y, "y", "editGradientPlot")
124
+ : previous.y,
125
+ "editGradientPlot y"
126
+ )
127
+ );
128
+ if (positions.orientation === undefined) {
129
+ throw new Error(
130
+ "editGradientPlot requires one categorical axis and one quantitative axis."
131
+ );
132
+ }
133
+ const xRoleScale = positions.orientation === "vertical"
134
+ ? previous.categoryScale
135
+ : previous.measureScale;
136
+ const yRoleScale = positions.orientation === "vertical"
137
+ ? previous.measureScale
138
+ : previous.categoryScale;
139
+ const xScale = resolveDistributionScalePlan(program, {
140
+ channel: "x",
141
+ fieldType: positions.x.fieldType,
142
+ requested: positions.x.scale,
143
+ fallback: xRoleScale,
144
+ defaults: ["ordinal", "nominal"].includes(positions.x.fieldType)
145
+ ? { discreteType: "band" }
146
+ : {}
147
+ });
148
+ const yScale = resolveDistributionScalePlan(program, {
149
+ channel: "y",
150
+ fieldType: positions.y.fieldType,
151
+ requested: positions.y.scale,
152
+ fallback: yRoleScale,
153
+ defaults: ["ordinal", "nominal"].includes(positions.y.fieldType)
154
+ ? { discreteType: "band" }
155
+ : {}
156
+ });
157
+ return {
158
+ source: Object.hasOwn(args, "data")
159
+ ? validateUserId(args.data, "Gradient-plot data id")
160
+ : current.source,
161
+ orientation: positions.orientation,
162
+ x: { ...positions.x, scale: xScale.id },
163
+ y: { ...positions.y, scale: yScale.id },
164
+ xScale,
165
+ yScale,
166
+ category: positions.orientation === "vertical"
167
+ ? positions.x.field
168
+ : positions.y.field,
169
+ categoryType: positions.orientation === "vertical"
170
+ ? positions.x.fieldType
171
+ : positions.y.fieldType,
172
+ measure: positions.orientation === "vertical"
173
+ ? positions.y.field
174
+ : positions.x.field,
175
+ previous
176
+ };
177
+ }
178
+
179
+ function sameRoleCandidate(current, candidate) {
180
+ return candidate.source === current.source &&
181
+ candidate.orientation === current.orientation &&
182
+ candidate.category === current.category &&
183
+ candidate.categoryType === current.categoryType &&
184
+ candidate.measure === current.measure &&
185
+ candidate.xScale.id === candidate.previous.x.scale &&
186
+ candidate.yScale.id === candidate.previous.y.scale &&
187
+ candidate.xScale.edit === undefined &&
188
+ candidate.yScale.edit === undefined;
189
+ }
190
+
191
+ function updateGradientPositions(program, owner, current, candidate, hasCenter) {
192
+ const owned = [owner.id, ...(hasCenter ? [current.centerId] : [])];
193
+ assertDistributionScaleHandoff(program, {
194
+ owned,
195
+ oldXScale: candidate.previous.x.scale,
196
+ oldYScale: candidate.previous.y.scale,
197
+ newXScale: candidate.xScale.id,
198
+ newYScale: candidate.yScale.id
199
+ });
200
+ let next = program;
201
+ for (const id of owned) next = clearCartesianPositions(next, id);
202
+ for (const scale of [candidate.xScale, candidate.yScale]) {
203
+ if (scale.create) next = next.createScale(scale.definition);
204
+ }
205
+ const category = candidate.orientation === "vertical" ? candidate.x : candidate.y;
206
+ const measure = candidate.orientation === "vertical" ? candidate.y : candidate.x;
207
+ const categoryChannel = candidate.orientation === "vertical" ? "x" : "y";
208
+ const measureChannel = candidate.orientation === "vertical" ? "y" : "x";
209
+ next = setCartesianPosition(next, owner.id, categoryChannel, {
210
+ field: candidate.category,
211
+ fieldType: candidate.categoryType,
212
+ scale: category.scale
213
+ });
214
+ next = setCartesianRange(
215
+ next,
216
+ owner.id,
217
+ measureChannel,
218
+ GRADIENT_PROFILE_FIELDS.lower,
219
+ GRADIENT_PROFILE_FIELDS.upper,
220
+ measure.scale
221
+ );
222
+ if (hasCenter) {
223
+ next = setCartesianPosition(next, current.centerId, categoryChannel, {
224
+ field: candidate.category,
225
+ fieldType: candidate.categoryType,
226
+ scale: category.scale
227
+ });
228
+ next = setCartesianPosition(next, current.centerId, measureChannel, {
229
+ field: GRADIENT_PROFILE_FIELDS.center,
230
+ fieldType: "quantitative",
231
+ scale: measure.scale
232
+ })._withMarkConfig(current.centerId, {
233
+ ...next.markConfigs[current.centerId],
234
+ fixedSpan: {
235
+ ...next.markConfigs[current.centerId].fixedSpan,
236
+ orientation: candidate.orientation === "vertical"
237
+ ? "horizontal"
238
+ : "vertical"
239
+ }
240
+ });
241
+ }
242
+ for (const id of new Set([candidate.xScale.id, candidate.yScale.id])) {
243
+ next = next.rematerializeScale({ id, marks: false, guides: false });
244
+ }
245
+ next = rebindDistributionGuides(next, {
246
+ oldXScale: candidate.previous.x.scale,
247
+ oldYScale: candidate.previous.y.scale,
248
+ newXScale: candidate.xScale.id,
249
+ newYScale: candidate.yScale.id,
250
+ oldXTitle: candidate.previous.x.field,
251
+ oldYTitle: candidate.previous.y.field,
252
+ newXTitle: candidate.x.field,
253
+ newYTitle: candidate.y.field,
254
+ oldMeasureChannel: current.orientation === "vertical" ? "y" : "x",
255
+ newMeasureChannel: candidate.orientation === "vertical" ? "y" : "x"
256
+ });
257
+ for (const scale of [candidate.xScale, candidate.yScale]) {
258
+ if (scale.edit !== undefined) next = next.editScale(scale.edit);
259
+ }
260
+ return next;
36
261
  }
37
262
 
38
263
  export const editGradientPlot = action(
@@ -67,6 +292,129 @@ export const editGradientPlot = action(
67
292
  "editGradientPlot"
68
293
  )
69
294
  : current.center;
295
+ const candidate = roleCandidate(this, owner, current, args);
296
+ const sourceDataset = findDataset(this, candidate.source);
297
+ if (sourceDataset === undefined) {
298
+ throw new Error(`Unknown gradient-plot data "${candidate.source}".`);
299
+ }
300
+ const roleRequested = ["data", "x", "y"].some(
301
+ key => Object.hasOwn(args, key)
302
+ );
303
+ const changesRoles = !sameRoleCandidate(current, candidate);
304
+ if (changesRoles) {
305
+ const hadCenter = findLayer(this, current.centerId) !== undefined;
306
+ const revision = planDerivedDataRevision(this, {
307
+ owner: owner.id,
308
+ role: "ProfileData",
309
+ previous: current.profileId,
310
+ consumers: [owner.id, ...(hadCenter ? [current.centerId] : [])]
311
+ });
312
+ const applyEdit = program => {
313
+ let next = program.createGradientProfileData({
314
+ id: revision.id,
315
+ source: candidate.source,
316
+ category: candidate.category,
317
+ field: candidate.measure,
318
+ ...density,
319
+ center: center === false ? "median" : center.type
320
+ });
321
+ for (const rebind of revision.rebinds) {
322
+ next = next.rebindLayerData(rebind);
323
+ }
324
+ const profile = findDataset(next, revision.id);
325
+ next = next._withMarkConfig(owner.id, {
326
+ ...next.markConfigs[owner.id],
327
+ gradientPlot: {
328
+ ...current,
329
+ source: candidate.source,
330
+ orientation: candidate.orientation,
331
+ category: candidate.category,
332
+ categoryType: candidate.categoryType,
333
+ measure: candidate.measure,
334
+ density,
335
+ width,
336
+ gradient,
337
+ center,
338
+ profileId: revision.id,
339
+ intensityDomain: profile.transform[0].resolved.intensityDomain
340
+ }
341
+ });
342
+ if (center === false && hadCenter) {
343
+ next = removeCenter(next, current.centerId);
344
+ }
345
+ const retainedCenter = center !== false && hadCenter;
346
+ next = updateGradientPositions(
347
+ next,
348
+ owner,
349
+ current,
350
+ candidate,
351
+ retainedCenter
352
+ ).rematerializeRectMark({ id: owner.id });
353
+ const category = candidate.orientation === "vertical"
354
+ ? candidate.x
355
+ : candidate.y;
356
+ const measure = candidate.orientation === "vertical"
357
+ ? candidate.y
358
+ : candidate.x;
359
+ const categoryScale = next.resolvedScales[category.scale];
360
+ const spanSize = Math.max(
361
+ 1,
362
+ categoryScale.bandwidth * width.band - 16
363
+ );
364
+ if (center !== false && !hadCenter) {
365
+ next = next.createGradientPlotCenter({
366
+ id: current.centerId,
367
+ owner: owner.id,
368
+ data: revision.id,
369
+ category: candidate.category,
370
+ categoryType: candidate.categoryType,
371
+ coordinate: owner.coordinate,
372
+ categoryScale: category.scale,
373
+ measureScale: measure.scale,
374
+ orientation: candidate.orientation,
375
+ size: spanSize,
376
+ stroke: center.stroke,
377
+ strokeWidth: center.strokeWidth
378
+ });
379
+ } else if (retainedCenter) {
380
+ next = next
381
+ .encodeStroke({ target: current.centerId, value: center.stroke })
382
+ .encodeStrokeWidth({
383
+ target: current.centerId,
384
+ value: center.strokeWidth
385
+ })
386
+ .materializeRuleSpan({
387
+ id: current.centerId,
388
+ orientation: candidate.orientation === "vertical"
389
+ ? "horizontal"
390
+ : "vertical",
391
+ size: spanSize
392
+ });
393
+ }
394
+ if (current.guides?.legend !== false && current.guides?.legend !== undefined) {
395
+ next = removeGradientPlotLegend(next, owner.id)
396
+ .createGradientPlotLegend({
397
+ owner: owner.id,
398
+ ...current.guides.legend
399
+ });
400
+ }
401
+ next = next.releaseDerivedData(revision.release);
402
+ return next._withContext({
403
+ currentMark: owner.id,
404
+ currentData: candidate.source
405
+ });
406
+ };
407
+ applyEdit(this);
408
+ return applyEdit(this);
409
+ }
410
+ if (roleRequested && !OPTIONS.slice(4).some(
411
+ key => Object.hasOwn(args, key)
412
+ )) {
413
+ return this._withContext({
414
+ currentMark: owner.id,
415
+ currentData: current.source
416
+ });
417
+ }
70
418
  const densityChanged = JSON.stringify(density) !== JSON.stringify(current.density);
71
419
  const centerTypeChanged = center !== false && current.center !== false &&
72
420
  center.type !== current.center.type;
@@ -131,7 +479,7 @@ export const editGradientPlot = action(
131
479
  .encodeStroke({ target: current.centerId, value: center.stroke })
132
480
  .encodeStrokeWidth({ target: current.centerId, value: center.strokeWidth });
133
481
  }
134
- next = next.materializeGradientPlotFill({ id: owner.id });
482
+ next = next.rematerializeRectMark({ id: owner.id });
135
483
  if (center !== false && findLayer(next, current.centerId) !== undefined) {
136
484
  const categoryChannel = current.orientation === "vertical" ? "x" : "y";
137
485
  const categoryScale = next.resolvedScales[owner.encoding[categoryChannel].scale];
@@ -18,11 +18,19 @@ const TITLE_OPTIONS = Object.freeze([
18
18
  "text", "at", "offset", "rotation", "color", "fontSize",
19
19
  "fontFamily", "fontWeight"
20
20
  ]);
21
+ const COMPONENTS = Object.freeze({
22
+ line: Object.freeze({ suffix: "Line", config: "line" }),
23
+ ticks: Object.freeze({ suffix: "Ticks", config: "ticks" }),
24
+ labels: Object.freeze({ suffix: "Labels", config: "labels" }),
25
+ title: Object.freeze({ suffix: "Title", config: "title" })
26
+ });
21
27
 
22
- function validateNested(args, key, options, operation) {
28
+ function validateNested(args, key, options, operation, { allowFalse = false } = {}) {
23
29
  if (!Object.hasOwn(args, key)) return;
30
+ if (allowFalse && args[key] === false) return;
24
31
  if (!isPlainObject(args[key])) {
25
- throw new TypeError(`${operation}.${key} must be a plain object.`);
32
+ const accepted = allowFalse ? "false or a plain object" : "a plain object";
33
+ throw new TypeError(`${operation}.${key} must be ${accepted}.`);
26
34
  }
27
35
  validateKeys(args[key], options, `${operation}.${key}`);
28
36
  }
@@ -33,11 +41,12 @@ function validateArgs(args, operation) {
33
41
  emptyMessage: `${operation} requires at least one axis change.`,
34
42
  emptyError: Error
35
43
  });
36
- validateNested(args, "line", LINE_OPTIONS, operation);
37
- validateNested(args, "ticks", TICK_OPTIONS, operation);
38
- validateNested(args, "labels", LABEL_OPTIONS, operation);
39
- validateNested(args, "ticksAndLabels", GROUP_OPTIONS, operation);
40
- validateNested(args, "title", TITLE_OPTIONS, operation);
44
+ const removable = { allowFalse: true };
45
+ validateNested(args, "line", LINE_OPTIONS, operation, removable);
46
+ validateNested(args, "ticks", TICK_OPTIONS, operation, removable);
47
+ validateNested(args, "labels", LABEL_OPTIONS, operation, removable);
48
+ validateNested(args, "ticksAndLabels", GROUP_OPTIONS, operation, removable);
49
+ validateNested(args, "title", TITLE_OPTIONS, operation, removable);
41
50
  if (args.ticksAndLabels !== undefined && (
42
51
  args.ticks !== undefined || args.labels !== undefined
43
52
  )) {
@@ -45,10 +54,10 @@ function validateArgs(args, operation) {
45
54
  `${operation} cannot combine ticksAndLabels with ticks or labels.`
46
55
  );
47
56
  }
48
- if (args.ticksAndLabels?.ticks !== undefined) {
57
+ if (args.ticksAndLabels !== false && args.ticksAndLabels?.ticks !== undefined) {
49
58
  validateNested(args.ticksAndLabels, "ticks", ["length", "color", "lineWidth"], `${operation}.ticksAndLabels`);
50
59
  }
51
- if (args.ticksAndLabels?.labels !== undefined) {
60
+ if (args.ticksAndLabels !== false && args.ticksAndLabels?.labels !== undefined) {
52
61
  validateNested(args.ticksAndLabels, "labels", [
53
62
  "offset", "format", "color", "fontSize", "fontFamily", "fontWeight"
54
63
  ], `${operation}.ticksAndLabels`);
@@ -63,10 +72,114 @@ function names(channel) {
63
72
  ticks: `edit${prefix}AxisTicks`,
64
73
  labels: `edit${prefix}AxisLabels`,
65
74
  group: `edit${prefix}AxisTicksAndLabels`,
66
- title: `edit${prefix}AxisTitle`
75
+ title: `edit${prefix}AxisTitle`,
76
+ remove: `remove${prefix}Axis`
67
77
  };
68
78
  }
69
79
 
80
+ function graphicId(channel, component) {
81
+ return `${channel}Axis${COMPONENTS[component].suffix}`;
82
+ }
83
+
84
+ function hasComponent(program, channel, component) {
85
+ return program.graphicSpec.objects[graphicId(channel, component)] !== undefined ||
86
+ program.guideConfigs.axis?.[channel]?.[COMPONENTS[component].config] !== undefined ||
87
+ (component === "title" &&
88
+ program.semanticSpec.guides.axis?.[channel]?.title !== undefined);
89
+ }
90
+
91
+ function assertRemovable(program, channel, component, operation) {
92
+ if (!hasComponent(program, channel, component)) {
93
+ throw new Error(
94
+ `${operation}.${component} requires an existing ${channel}-axis ${component}.`
95
+ );
96
+ }
97
+ }
98
+
99
+ function removeComponent(program, channel, component) {
100
+ const id = graphicId(channel, component);
101
+ let next = program;
102
+
103
+ if (
104
+ component === "title" &&
105
+ next.semanticSpec.guides.axis?.[channel]?.title !== undefined
106
+ ) {
107
+ next = next.editSemantic({
108
+ property: `guide.axis.${channel}.title`,
109
+ remove: true
110
+ });
111
+ }
112
+ if (next.graphicSpec.objects[id] !== undefined) {
113
+ next = next.editGraphics({ target: id, remove: true });
114
+ }
115
+ return next._withoutMaterializationConfig([
116
+ "guides", "axis", channel, COMPONENTS[component].config
117
+ ]);
118
+ }
119
+
120
+ function buildPlan(program, channel, args, operation) {
121
+ const shared = Object.hasOwn(args, "position")
122
+ ? { position: args.position }
123
+ : {};
124
+ const plan = [];
125
+ const addLeaf = (component, edit) => {
126
+ const value = args[component];
127
+ if (value === false) {
128
+ assertRemovable(program, channel, component, operation.operation);
129
+ plan.push({ kind: "remove", component });
130
+ } else if (value !== undefined) {
131
+ plan.push({ kind: "edit", operation: edit, args: { ...shared, ...value } });
132
+ } else if (args.position !== undefined && hasComponent(program, channel, component)) {
133
+ plan.push({ kind: "edit", operation: edit, args: shared });
134
+ }
135
+ };
136
+
137
+ addLeaf("line", operation.line);
138
+ if (args.ticksAndLabels === false) {
139
+ assertRemovable(program, channel, "ticks", operation.operation);
140
+ assertRemovable(program, channel, "labels", operation.operation);
141
+ plan.push(
142
+ { kind: "remove", component: "ticks" },
143
+ { kind: "remove", component: "labels" }
144
+ );
145
+ } else if (args.ticksAndLabels !== undefined) {
146
+ plan.push({
147
+ kind: "edit",
148
+ operation: operation.group,
149
+ args: { ...shared, ...args.ticksAndLabels }
150
+ });
151
+ } else {
152
+ addLeaf("ticks", operation.ticks);
153
+ addLeaf("labels", operation.labels);
154
+ }
155
+ addLeaf("title", operation.title);
156
+
157
+ if (plan.length === 0) {
158
+ throw new Error(`${operation.operation} requires an existing ${channel}-axis component.`);
159
+ }
160
+ return plan;
161
+ }
162
+
163
+ function applyPlan(program, channel, plan, operation) {
164
+ let next = program;
165
+ for (const step of plan) {
166
+ next = step.kind === "remove"
167
+ ? removeComponent(next, channel, step.component)
168
+ : next[step.operation](step.args);
169
+ }
170
+
171
+ const hasRetainedGraphic = Object.keys(COMPONENTS).some(
172
+ component => next.graphicSpec.objects[graphicId(channel, component)] !== undefined
173
+ );
174
+ if (!hasRetainedGraphic && (
175
+ next.semanticSpec.guides.axis?.[channel] !== undefined ||
176
+ next.guideConfigs.axis?.[channel] !== undefined
177
+ )) {
178
+ next = next[operation.remove]();
179
+ }
180
+ return next;
181
+ }
182
+
70
183
  function makeEditAxis(channel) {
71
184
  const operation = names(channel);
72
185
  return action(
@@ -76,43 +189,12 @@ function makeEditAxis(channel) {
76
189
  },
77
190
  function (args = {}) {
78
191
  validateArgs(args, operation.operation);
79
- const shared = Object.hasOwn(args, "position")
80
- ? { position: args.position }
81
- : {};
82
- const has = id => this.graphicSpec.objects[id] !== undefined;
83
- let next = this;
84
-
85
- if (args.line !== undefined || (args.position !== undefined && has(`${channel}AxisLine`))) {
86
- next = next[operation.line]({ ...shared, ...(args.line ?? {}) });
87
- }
88
-
89
- if (args.ticksAndLabels !== undefined) {
90
- next = next[operation.group]({
91
- ...shared,
92
- ...args.ticksAndLabels
93
- });
94
- } else if (args.ticks !== undefined || args.labels !== undefined) {
95
- if (args.ticks !== undefined) {
96
- next = next[operation.ticks]({ ...shared, ...args.ticks });
97
- }
98
- if (args.labels !== undefined) {
99
- next = next[operation.labels]({ ...shared, ...args.labels });
100
- }
101
- } else if (args.position !== undefined) {
102
- const hasTicks = has(`${channel}AxisTicks`);
103
- const hasLabels = has(`${channel}AxisLabels`);
104
- if (hasTicks && hasLabels) {
105
- next = next[operation.group](shared);
106
- } else {
107
- if (hasTicks) next = next[operation.ticks](shared);
108
- if (hasLabels) next = next[operation.labels](shared);
109
- }
110
- }
111
-
112
- if (args.title !== undefined || (args.position !== undefined && has(`${channel}AxisTitle`))) {
113
- next = next[operation.title]({ ...shared, ...(args.title ?? {}) });
114
- }
115
- return next;
192
+ const plan = buildPlan(this, channel, args, operation);
193
+
194
+ // Run the complete proposal against an immutable speculative branch so a
195
+ // later leaf failure cannot begin the returned action trace or state.
196
+ applyPlan(this, channel, plan, operation);
197
+ return applyPlan(this, channel, plan, operation);
116
198
  }
117
199
  );
118
200
  }
@@ -360,14 +360,25 @@ export const createLegend = action(
360
360
  symbol
361
361
  });
362
362
  if (requestedPoint.encoding?.size?.scale !== undefined) {
363
- next = next.createSizeLegend({
364
- target: requestedPoint.id,
365
- ...(count === undefined ? {} : { count }),
366
- inheritAppearance:
367
- categoricalArgs.position === "left" ||
368
- categoricalArgs.labels !== undefined ||
369
- categoricalArgs.titleStyle !== undefined
370
- });
363
+ const existingSize = next.guideConfigs.legend?.size;
364
+ if (existingSize === undefined) {
365
+ next = next.createSizeLegend({
366
+ target: requestedPoint.id,
367
+ ...(count === undefined ? {} : { count }),
368
+ inheritAppearance:
369
+ categoricalArgs.position === "left" ||
370
+ categoricalArgs.labels !== undefined ||
371
+ categoricalArgs.titleStyle !== undefined
372
+ });
373
+ } else if (existingSize.target !== requestedPoint.id) {
374
+ throw new Error(
375
+ "Combined point series legend requires the active size legend to share its target."
376
+ );
377
+ } else if (count !== undefined && count !== existingSize.count) {
378
+ throw new Error(
379
+ "Existing size legend count must be edited before recreating the categorical block."
380
+ );
381
+ }
371
382
  next = next.rematerializeLegend();
372
383
  }
373
384
  return next;
@@ -49,7 +49,7 @@ const DEFAULT_BORDER = Object.freeze({
49
49
  export const validatePositive = validatePositiveFinite;
50
50
  export const validateNonNegative = validateNonNegativeFinite;
51
51
 
52
- function validateTextOptions(value, label, defaults) {
52
+ export function normalizeLegendTextOptions(value, label, defaults) {
53
53
  if (value === undefined) return { ...defaults };
54
54
  if (!isPlainObject(value)) {
55
55
  throw new TypeError(`${label} must be a plain object.`);
@@ -132,12 +132,12 @@ export function normalizeContinuousLegend(args, kind) {
132
132
  count,
133
133
  title: args.title,
134
134
  inferredTitle: args.title === undefined,
135
- labels: validateTextOptions(
135
+ labels: normalizeLegendTextOptions(
136
136
  args.labels,
137
137
  "createLegend.labels",
138
138
  DEFAULT_LABELS
139
139
  ),
140
- titleStyle: validateTextOptions(
140
+ titleStyle: normalizeLegendTextOptions(
141
141
  args.titleStyle,
142
142
  "createLegend.titleStyle",
143
143
  DEFAULT_TITLE