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
@@ -3,7 +3,8 @@ import { validateUserId } from "../../core/identifiers.js";
3
3
  import { validateKeys } from "../../core/validation.js";
4
4
  import {
5
5
  deriveBin2DRows,
6
- normalizeBin2DTransform
6
+ normalizeBin2DTransform,
7
+ requestedBin2DTransform
7
8
  } from "../../grammar/bin2d.js";
8
9
  import { applyLayerDataRematerialization } from
9
10
  "../../materialization/dependencies.js";
@@ -18,11 +19,43 @@ import { MATERIALIZE_OPTIONS, requireDerivedDataset } from "./shared.js";
18
19
  const OPTIONS = Object.freeze([
19
20
  "id", "source", "x", "y", "bins", "extent", "includeEmpty", "members", "as"
20
21
  ]);
22
+ const EDIT_OPTIONS = Object.freeze([
23
+ "target", "source", "x", "y", "bins", "extent", "includeEmpty", "members", "as"
24
+ ]);
25
+ const EDITABLE = Object.freeze(EDIT_OPTIONS.slice(1));
26
+ const OUTPUT_FIELDS = Object.freeze([
27
+ "x0", "x1", "y0", "y1", "count"
28
+ ]);
21
29
 
22
30
  function ownerConfig(program, id) {
23
31
  return program.materializationConfigs.data?.bin2d?.[id];
24
32
  }
25
33
 
34
+ function ownerEntries(program) {
35
+ return Object.entries(program.materializationConfigs.data?.bin2d ?? {});
36
+ }
37
+
38
+ function resolveBin2DOwner(program, requested) {
39
+ if (requested !== undefined) {
40
+ const owner = validateUserId(requested, "2D bin owner id");
41
+ if (ownerConfig(program, owner) === undefined) {
42
+ throw new Error(`Unknown 2D bin owner "${owner}".`);
43
+ }
44
+ return owner;
45
+ }
46
+ const owners = ownerEntries(program);
47
+ const current = owners.filter(([, config]) =>
48
+ config?.current === program.context.currentData
49
+ );
50
+ if (current.length === 1) return current[0][0];
51
+ if (current.length > 1) {
52
+ throw new Error("2D bin owner is ambiguous; provide target.");
53
+ }
54
+ if (owners.length === 1) return owners[0][0];
55
+ if (owners.length === 0) throw new Error("No 2D bin owner is available.");
56
+ throw new Error("2D bin owner is ambiguous; provide target.");
57
+ }
58
+
26
59
  function requireCurrentBin2D(program, id, config) {
27
60
  const current = findDataset(program, config.current);
28
61
  if (
@@ -59,6 +92,74 @@ function preflight(program, sourceId, transform) {
59
92
  deriveBin2DRows(source.values, transform);
60
93
  }
61
94
 
95
+ function requireCompleteEditOutputFields(value, members) {
96
+ const required = [...OUTPUT_FIELDS, ...(members ? ["members"] : [])];
97
+ const missing = required.find(field => !Object.hasOwn(value, field));
98
+ if (missing !== undefined) {
99
+ throw new Error(
100
+ `editBin2DData as requires the complete "${missing}" output field.`
101
+ );
102
+ }
103
+ }
104
+
105
+ function editedTransform(owner, previous, args) {
106
+ const prior = requestedBin2DTransform(previous.transform[0]);
107
+ const option = property => Object.hasOwn(args, property)
108
+ ? args[property]
109
+ : prior[property];
110
+ const members = option("members");
111
+ let as = option("as");
112
+ if (!Object.hasOwn(args, "as")) {
113
+ as = { ...as };
114
+ if (members && as.members === undefined) {
115
+ as.members = `__${owner}_members`;
116
+ } else if (!members) {
117
+ delete as.members;
118
+ }
119
+ }
120
+ const transform = normalizeBin2DTransform({
121
+ id: owner,
122
+ x: option("x"),
123
+ y: option("y"),
124
+ bins: option("bins"),
125
+ extent: option("extent"),
126
+ includeEmpty: option("includeEmpty"),
127
+ members,
128
+ as
129
+ });
130
+ if (Object.hasOwn(args, "as")) {
131
+ requireCompleteEditOutputFields(args.as, members);
132
+ }
133
+ return transform;
134
+ }
135
+
136
+ function sameRequestedTransform(left, right) {
137
+ return JSON.stringify(left) === JSON.stringify(right);
138
+ }
139
+
140
+ function applyBin2DRevision(program, { owner, previous, source, transform }) {
141
+ rejectDerivedConsumers(program, previous.id);
142
+ const consumers = directLayerConsumers(program, previous.id);
143
+ const revision = planDerivedDataRevision(program, {
144
+ owner,
145
+ role: "Bin2DData",
146
+ previous: previous.id,
147
+ consumers
148
+ });
149
+ let next = program
150
+ .createDerivedData({ id: revision.id, source, transform: [transform] })
151
+ .materializeBin2DData({ id: revision.id });
152
+ for (const rebind of revision.rebinds) {
153
+ next = next.rebindLayerData(rebind);
154
+ next = applyLayerDataRematerialization(next, rebind.id);
155
+ }
156
+ next = next.releaseDerivedData(revision.release);
157
+ return next._withMaterializationConfig(
158
+ ["data", "bin2d", owner],
159
+ { current: revision.id }
160
+ );
161
+ }
162
+
62
163
  export const materializeBin2DData = action(
63
164
  {
64
165
  op: "materializeBin2DData",
@@ -113,25 +214,47 @@ export const createBin2DData = action(
113
214
  );
114
215
  }
115
216
 
116
- rejectDerivedConsumers(this, previous.id);
117
- const consumers = directLayerConsumers(this, previous.id);
118
- const revision = planDerivedDataRevision(this, {
217
+ return applyBin2DRevision(this, {
119
218
  owner,
120
- role: "Bin2DData",
121
- previous: previous.id,
122
- consumers
219
+ previous,
220
+ source,
221
+ transform
123
222
  });
124
- let next = this
125
- .createDerivedData({ id: revision.id, source, transform: [transform] })
126
- .materializeBin2DData({ id: revision.id });
127
- for (const rebind of revision.rebinds) {
128
- next = next.rebindLayerData(rebind);
129
- next = applyLayerDataRematerialization(next, rebind.id);
223
+ }
224
+ );
225
+
226
+ export const editBin2DData = action(
227
+ {
228
+ op: "editBin2DData",
229
+ description: "Partially revise one logical rectangular 2D-bin owner."
230
+ },
231
+ function (args = {}) {
232
+ validateKeys(args, EDIT_OPTIONS, "editBin2DData");
233
+ if (!EDITABLE.some(option => Object.hasOwn(args, option))) {
234
+ throw new Error("editBin2DData requires at least one transform or source option.");
130
235
  }
131
- next = next.releaseDerivedData(revision.release);
132
- return next._withMaterializationConfig(
133
- ["data", "bin2d", owner],
134
- { current: revision.id }
236
+ const owner = resolveBin2DOwner(this, args.target);
237
+ const previous = requireCurrentBin2D(this, owner, ownerConfig(this, owner));
238
+ const source = validateUserId(
239
+ args.source ?? previous.source,
240
+ "2D bin source dataset id"
135
241
  );
242
+ const transform = editedTransform(owner, previous, args);
243
+ if (
244
+ source === previous.source &&
245
+ sameRequestedTransform(
246
+ transform,
247
+ requestedBin2DTransform(previous.transform[0])
248
+ )
249
+ ) {
250
+ throw new Error("editBin2DData requires an actual transform or source change.");
251
+ }
252
+ preflight(this, source, transform);
253
+ const revision = { owner, previous, source, transform };
254
+
255
+ // Execute one speculative immutable branch so every consumer plan is known
256
+ // to succeed before the returned action trace records its first child.
257
+ applyBin2DRevision(this, revision);
258
+ return applyBin2DRevision(this, revision);
136
259
  }
137
260
  );
@@ -23,7 +23,11 @@ import {
23
23
  import { createIntervalData, materializeIntervalData } from "./interval.js";
24
24
  import { createHorizonData, materializeHorizonData } from "./horizon.js";
25
25
  import { createWindowData, materializeWindowData } from "./window.js";
26
- import { createBin2DData, materializeBin2DData } from "./bin2d.js";
26
+ import {
27
+ createBin2DData,
28
+ editBin2DData,
29
+ materializeBin2DData
30
+ } from "./bin2d.js";
27
31
  import { createBoxSummaryData, createBoxOutlierData, materializeBoxSummaryData, materializeBoxOutlierData } from "./box.js";
28
32
 
29
33
  export function registerDataActions(ProgramClass) {
@@ -51,6 +55,7 @@ export function registerDataActions(ProgramClass) {
51
55
  ProgramClass.prototype.createWindowData = createWindowData;
52
56
  ProgramClass.prototype.materializeWindowData = materializeWindowData;
53
57
  ProgramClass.prototype.createBin2DData = createBin2DData;
58
+ ProgramClass.prototype.editBin2DData = editBin2DData;
54
59
  ProgramClass.prototype.materializeBin2DData = materializeBin2DData;
55
60
  ProgramClass.prototype.createBoxSummaryData = createBoxSummaryData;
56
61
  ProgramClass.prototype.createBoxOutlierData = createBoxOutlierData;
@@ -0,0 +1,76 @@
1
+ import { isPlainObject } from "../../core/immutable.js";
2
+ import { validateKeys } from "../../core/validation.js";
3
+ import { normalizeIntervalParameters } from "../../grammar/interval.js";
4
+ import { planDerivedDataRevision } from
5
+ "../../materialization/dataProvenance.js";
6
+ import { findDataset } from "../../selectors/datasets.js";
7
+
8
+ const STATISTICS_OPTIONS = Object.freeze(["center", "extent", "level"]);
9
+
10
+ export function planIntervalEdit(program, {
11
+ owner,
12
+ data,
13
+ consumers,
14
+ statistics,
15
+ operation
16
+ }) {
17
+ if (!isPlainObject(statistics)) {
18
+ throw new TypeError(`${operation} statistics must be a plain object.`);
19
+ }
20
+ validateKeys(statistics, STATISTICS_OPTIONS, `${operation} statistics`);
21
+ if (!STATISTICS_OPTIONS.some(key => Object.hasOwn(statistics, key))) {
22
+ throw new Error(
23
+ `${operation} statistics requires center, extent, or level.`
24
+ );
25
+ }
26
+ const previous = findDataset(program, data);
27
+ const transform = previous?.transform?.length === 1
28
+ ? previous.transform[0]
29
+ : undefined;
30
+ if (transform?.type !== "interval") {
31
+ throw new Error(
32
+ `${operation} statistics requires a statistical interval owner; ` +
33
+ "explicit interval fields cannot be converted by edit."
34
+ );
35
+ }
36
+ const center = Object.hasOwn(statistics, "center")
37
+ ? statistics.center
38
+ : transform.center;
39
+ const extent = Object.hasOwn(statistics, "extent")
40
+ ? statistics.extent
41
+ : transform.extent;
42
+ const raw = { center, extent };
43
+ if (Object.hasOwn(statistics, "level")) {
44
+ raw.level = statistics.level;
45
+ } else if (extent === "ci" && transform.extent === "ci") {
46
+ raw.level = transform.level;
47
+ }
48
+ const parameters = normalizeIntervalParameters(raw);
49
+ const current = {
50
+ center: transform.center,
51
+ extent: transform.extent,
52
+ ...(transform.level === undefined ? {} : { level: transform.level })
53
+ };
54
+ const changed = JSON.stringify(parameters) !== JSON.stringify(current);
55
+ if (!changed) return { changed, parameters };
56
+
57
+ const revision = planDerivedDataRevision(program, {
58
+ owner,
59
+ role: "IntervalData",
60
+ previous: previous.id,
61
+ consumers
62
+ });
63
+ return {
64
+ changed,
65
+ parameters,
66
+ revision,
67
+ dataArgs: {
68
+ id: revision.id,
69
+ source: previous.source,
70
+ field: transform.field,
71
+ groupBy: transform.groupBy,
72
+ ...parameters,
73
+ as: transform.as
74
+ }
75
+ };
76
+ }
@@ -0,0 +1,326 @@
1
+ import { DEFAULT_TICK_COUNT } from "../guides/tickValues.js";
2
+ import { findLayer } from "../../selectors/layers.js";
3
+ import { findSemanticScale } from "../../selectors/scales.js";
4
+ import { resolvePositionScaleDefinition } from "../scales/definitions.js";
5
+
6
+ const POSITION_CHANNELS = Object.freeze(["x", "y", "x2", "y2"]);
7
+
8
+ export function resolveDistributionScalePlan(program, {
9
+ channel,
10
+ fieldType,
11
+ requested,
12
+ fallback,
13
+ defaults
14
+ }) {
15
+ const options = requested === undefined
16
+ ? { id: fallback }
17
+ : typeof requested === "string"
18
+ ? { id: requested }
19
+ : { ...requested, id: requested.id ?? fallback };
20
+ const existing = findSemanticScale(program, options.id);
21
+ return {
22
+ id: options.id,
23
+ definition: resolvePositionScaleDefinition(
24
+ program,
25
+ channel,
26
+ fieldType,
27
+ options,
28
+ defaults
29
+ ),
30
+ create: existing === undefined,
31
+ edit: existing !== undefined && typeof requested === "object" &&
32
+ Object.keys(requested).some(key => key !== "id")
33
+ ? options
34
+ : undefined
35
+ };
36
+ }
37
+
38
+ export function setCartesianPosition(program, id, channel, {
39
+ field,
40
+ fieldType,
41
+ scale,
42
+ title
43
+ }) {
44
+ let next = program
45
+ .editSemantic({
46
+ property: `layer[${id}].encoding.${channel}.field`,
47
+ value: field
48
+ })
49
+ .editSemantic({
50
+ property: `layer[${id}].encoding.${channel}.fieldType`,
51
+ value: fieldType
52
+ })
53
+ .editSemantic({
54
+ property: `layer[${id}].encoding.${channel}.scale`,
55
+ value: scale
56
+ });
57
+ if (title !== undefined) {
58
+ next = next.editSemantic({
59
+ property: `layer[${id}].encoding.${channel}.title`,
60
+ value: title
61
+ });
62
+ }
63
+ return next;
64
+ }
65
+
66
+ export function setCartesianRange(
67
+ program,
68
+ id,
69
+ channel,
70
+ lower,
71
+ upper,
72
+ scale,
73
+ title
74
+ ) {
75
+ return setCartesianPosition(program, id, channel, {
76
+ field: lower,
77
+ fieldType: "quantitative",
78
+ scale,
79
+ title
80
+ })
81
+ .editSemantic({
82
+ property: `layer[${id}].encoding.${channel}2.field`,
83
+ value: upper
84
+ })
85
+ .editSemantic({
86
+ property: `layer[${id}].encoding.${channel}2.fieldType`,
87
+ value: "quantitative"
88
+ })
89
+ .editSemantic({
90
+ property: `layer[${id}].encoding.${channel}2.scale`,
91
+ value: scale
92
+ });
93
+ }
94
+
95
+ function axisMethods(channel) {
96
+ const prefix = channel === "x" ? "X" : "Y";
97
+ return {
98
+ ticks: `edit${prefix}AxisTicks`,
99
+ labels: `edit${prefix}AxisLabels`,
100
+ title: `edit${prefix}AxisTitle`
101
+ };
102
+ }
103
+
104
+ function isDiscretePosition(scale) {
105
+ return ["ordinal", "band", "point"].includes(scale?.type);
106
+ }
107
+
108
+ function normalizeTickConfig(config, scale) {
109
+ if (config === undefined) return undefined;
110
+ if (isDiscretePosition(scale)) {
111
+ if (config.mode === "count" || config.inferredValues === true) {
112
+ const next = {
113
+ ...config,
114
+ mode: "values",
115
+ values: scale.domain,
116
+ inferredValues: true
117
+ };
118
+ delete next.count;
119
+ return next;
120
+ }
121
+ return config;
122
+ }
123
+ if (config.mode === "values" && config.inferredValues === true) {
124
+ const next = {
125
+ ...config,
126
+ mode: "count",
127
+ count: DEFAULT_TICK_COUNT,
128
+ inferredValues: true
129
+ };
130
+ delete next.values;
131
+ return next;
132
+ }
133
+ return config;
134
+ }
135
+
136
+ function outsideConsumer(program, owned, channel, scale) {
137
+ return program.semanticSpec.layers.some(layer =>
138
+ !owned.has(layer.id) && layer.encoding?.[channel]?.scale === scale
139
+ );
140
+ }
141
+
142
+ export function assertDistributionScaleHandoff(program, {
143
+ owned,
144
+ oldXScale,
145
+ oldYScale,
146
+ newXScale,
147
+ newYScale
148
+ }) {
149
+ const ids = new Set(owned);
150
+ for (const [channel, previous, next] of [
151
+ ["x", oldXScale, newXScale],
152
+ ["y", oldYScale, newYScale]
153
+ ]) {
154
+ if (
155
+ previous !== next &&
156
+ outsideConsumer(program, ids, channel, previous)
157
+ ) {
158
+ throw new Error(
159
+ `Cannot hand off ${channel} scale "${previous}" while unrelated consumers remain.`
160
+ );
161
+ }
162
+ }
163
+ }
164
+
165
+ export function clearCartesianPositions(program, id) {
166
+ const layer = findLayer(program, id);
167
+ if (layer === undefined) return program;
168
+ let next = program;
169
+ for (const channel of POSITION_CHANNELS) {
170
+ if (layer.encoding?.[channel] === undefined) continue;
171
+ next = next.editSemantic({
172
+ property: `layer[${id}].encoding.${channel}`,
173
+ remove: true
174
+ });
175
+ }
176
+ return next;
177
+ }
178
+
179
+ function rebindAxis(program, channel, {
180
+ previousScale,
181
+ nextScale,
182
+ previousTitle,
183
+ nextTitle
184
+ }) {
185
+ const semantic = program.semanticSpec.guides.axis?.[channel];
186
+ if (semantic?.scale !== previousScale) return program;
187
+ let next = program.editSemantic({
188
+ property: `guide.axis.${channel}.scale`,
189
+ value: nextScale
190
+ });
191
+ if (semantic.title === previousTitle) {
192
+ next = next.editSemantic({
193
+ property: `guide.axis.${channel}.title`,
194
+ value: nextTitle
195
+ });
196
+ }
197
+ const scale = next.resolvedScales[nextScale];
198
+ const ticks = normalizeTickConfig(
199
+ next.guideConfigs.axis?.[channel]?.ticks,
200
+ scale
201
+ );
202
+ if (ticks !== undefined) {
203
+ next = next._withGuideConfig(channel, "ticks", {
204
+ ...ticks,
205
+ scale: nextScale
206
+ });
207
+ }
208
+ const labels = next.guideConfigs.axis?.[channel]?.labels;
209
+ if (labels !== undefined) {
210
+ const tickConfig = next.guideConfigs.axis[channel].ticks;
211
+ next = next._withGuideConfig(channel, "labels", {
212
+ ...labels,
213
+ scale: nextScale,
214
+ mode: tickConfig.mode,
215
+ inferredValues: tickConfig.inferredValues,
216
+ ...(tickConfig.mode === "values"
217
+ ? { values: tickConfig.values }
218
+ : { count: tickConfig.count })
219
+ });
220
+ if (tickConfig.mode === "values") {
221
+ const config = next.guideConfigs.axis[channel].labels;
222
+ if (Object.hasOwn(config, "count")) {
223
+ const { count: _count, ...withoutCount } = config;
224
+ next = next._withGuideConfig(channel, "labels", withoutCount);
225
+ }
226
+ } else {
227
+ const config = next.guideConfigs.axis[channel].labels;
228
+ if (Object.hasOwn(config, "values")) {
229
+ const { values: _values, ...withoutValues } = config;
230
+ next = next._withGuideConfig(channel, "labels", withoutValues);
231
+ }
232
+ }
233
+ }
234
+ const title = next.guideConfigs.axis?.[channel]?.title;
235
+ if (title !== undefined) {
236
+ next = next._withGuideConfig(channel, "title", {
237
+ ...title,
238
+ scale: nextScale
239
+ });
240
+ }
241
+ const methods = axisMethods(channel);
242
+ if (ticks !== undefined) next = next[methods.ticks]();
243
+ if (labels !== undefined) next = next[methods.labels]();
244
+ if (title !== undefined) next = next[methods.title]();
245
+ return next;
246
+ }
247
+
248
+ function gridDirection(channel) {
249
+ return channel === "x" ? "vertical" : "horizontal";
250
+ }
251
+
252
+ function rebindGrid(program, {
253
+ previousMeasureChannel,
254
+ nextMeasureChannel,
255
+ previousScale,
256
+ nextScale
257
+ }) {
258
+ const previousDirection = gridDirection(previousMeasureChannel);
259
+ const nextDirection = gridDirection(nextMeasureChannel);
260
+ const stored = program.guideConfigs.grid?.[previousDirection];
261
+ const semantic = program.semanticSpec.guides.grid?.[previousDirection];
262
+ if (stored === undefined || semantic?.scale !== previousScale) return program;
263
+ if (previousDirection === nextDirection) {
264
+ return program
265
+ .editSemantic({
266
+ property: `guide.grid.${previousDirection}.scale`,
267
+ value: nextScale
268
+ })
269
+ ._withGridConfig(previousDirection, {
270
+ ...stored,
271
+ scale: nextScale
272
+ })[`rematerialize${previousDirection === "horizontal" ? "Horizontal" : "Vertical"}Grid`]();
273
+ }
274
+ if (program.semanticSpec.guides.grid?.[nextDirection] !== undefined) {
275
+ throw new Error(
276
+ `Cannot hand off the ${previousDirection} grid because a ${nextDirection} grid already exists.`
277
+ );
278
+ }
279
+ const options = {
280
+ scale: nextScale,
281
+ coordinate: stored.coordinate,
282
+ color: stored.color,
283
+ lineWidth: stored.lineWidth,
284
+ strokeDash: stored.strokeDash,
285
+ ...(stored.inferredValues === true
286
+ ? {}
287
+ : stored.mode === "values"
288
+ ? { values: stored.values }
289
+ : { count: stored.count })
290
+ };
291
+ return program
292
+ .removeGrid({ [previousDirection]: true })
293
+ [`create${nextDirection === "horizontal" ? "Horizontal" : "Vertical"}Grid`](options);
294
+ }
295
+
296
+ export function rebindDistributionGuides(program, {
297
+ oldXScale,
298
+ oldYScale,
299
+ newXScale,
300
+ newYScale,
301
+ oldXTitle,
302
+ oldYTitle,
303
+ newXTitle,
304
+ newYTitle,
305
+ oldMeasureChannel,
306
+ newMeasureChannel
307
+ }) {
308
+ let next = rebindAxis(program, "x", {
309
+ previousScale: oldXScale,
310
+ nextScale: newXScale,
311
+ previousTitle: oldXTitle,
312
+ nextTitle: newXTitle
313
+ });
314
+ next = rebindAxis(next, "y", {
315
+ previousScale: oldYScale,
316
+ nextScale: newYScale,
317
+ previousTitle: oldYTitle,
318
+ nextTitle: newYTitle
319
+ });
320
+ return rebindGrid(next, {
321
+ previousMeasureChannel: oldMeasureChannel,
322
+ nextMeasureChannel: newMeasureChannel,
323
+ previousScale: oldMeasureChannel === "x" ? oldXScale : oldYScale,
324
+ nextScale: newMeasureChannel === "x" ? newXScale : newYScale
325
+ });
326
+ }
@@ -9,7 +9,10 @@ import {
9
9
  resolveAppearanceScaleDefinition,
10
10
  resolveOpacityScaleDefinition
11
11
  } from "../scales/definitions.js";
12
- import { findLayer } from "../../selectors/layers.js";
12
+ import {
13
+ findLayer,
14
+ resolveEligibleLayer
15
+ } from "../../selectors/layers.js";
13
16
  import { applyMaterializationPlan } from "../../materialization/dependencies.js";
14
17
  import { planEncodingRematerialization } from "../../materialization/encodings.js";
15
18
  import {
@@ -20,6 +23,7 @@ import {
20
23
  } from "./shared.js";
21
24
 
22
25
  const RADIUS_OPTIONS = Object.freeze(["value", "target"]);
26
+ const REMOVE_RADIUS_OPTIONS = Object.freeze(["target"]);
23
27
  const OPACITY_OPTIONS = Object.freeze([
24
28
  "value", "field", "target", "fieldType", "scale"
25
29
  ]);
@@ -129,6 +133,35 @@ const encodePointRadius = action(
129
133
  }
130
134
  );
131
135
 
136
+ const removePointRadius = action(
137
+ {
138
+ op: "removePointRadius",
139
+ description: "Remove a constant point radius and restore the theme default."
140
+ },
141
+ function (args = {}) {
142
+ validateOptions(args, REMOVE_RADIUS_OPTIONS, "removePointRadius");
143
+ const requested = args.target === undefined
144
+ ? undefined
145
+ : args.target;
146
+ const layer = resolveEligibleLayer(this, {
147
+ target: requested,
148
+ predicate: candidate =>
149
+ candidate.mark?.type === "point" &&
150
+ this.markConfigs[candidate.id]?.radius !== undefined,
151
+ label: "point mark with an explicit radius"
152
+ });
153
+ const next = this
154
+ ._withoutMaterializationConfig(["marks", layer.id, "radius"]);
155
+ const graphic = next.graphicSpec.objects[layer.id];
156
+ const baseline = graphic === undefined
157
+ ? next
158
+ : graphic.type === "collection"
159
+ ? next.editGraphics({ target: layer.id, property: "items", value: [] })
160
+ : next.editGraphics({ target: layer.id, property: "length", value: 0 });
161
+ return baseline.rematerializePointMark({ id: layer.id });
162
+ }
163
+ );
164
+
132
165
  const encodeSize = action(
133
166
  {
134
167
  op: "encodeSize",
@@ -247,6 +280,7 @@ const encodeOpacity = action(
247
280
  export function registerAppearanceEncodingAction(ProgramClass) {
248
281
  ProgramClass.prototype.encodeRadius = encodeRadius;
249
282
  ProgramClass.prototype.encodePointRadius = encodePointRadius;
283
+ ProgramClass.prototype.removePointRadius = removePointRadius;
250
284
  ProgramClass.prototype.encodeSize = encodeSize;
251
285
  ProgramClass.prototype.encodeShape = encodeShape;
252
286
  ProgramClass.prototype.encodeOpacity = encodeOpacity;