ggaction 0.0.22 → 0.0.24

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 (56) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +82 -90
  3. package/knowledge/action-card.schema.json +1 -1
  4. package/knowledge/action-cards.json +1 -1
  5. package/knowledge/intent-taxonomy.json +1 -1
  6. package/knowledge/mcp-resources.json +1 -1
  7. package/package.json +6 -3
  8. package/src/actions/charts/heatmap.js +1 -1
  9. package/src/actions/charts/interval-regression.js +2 -2
  10. package/src/actions/data/basicBin2d.js +1 -1
  11. package/src/actions/data/bin2d.js +2 -2
  12. package/src/actions/data/edit.js +1 -1
  13. package/src/actions/data/timeUnit.js +1 -1
  14. package/src/actions/encodings/ranged.js +2 -2
  15. package/src/actions/facets/actions.js +1 -1
  16. package/src/actions/guides/axes/components.js +5 -0
  17. package/src/actions/guides/axes/edit.js +1 -188
  18. package/src/actions/guides/axes/labels.js +1 -537
  19. package/src/actions/guides/axes/tickGroups.js +16 -1
  20. package/src/actions/guides/axes/ticks.js +33 -24
  21. package/src/actions/guides/axes/titles.js +1 -389
  22. package/src/actions/guides/legends/categorical/components.js +1 -4
  23. package/src/actions/regression/create.js +6 -1
  24. package/src/actions/regression/edit.js +10 -1
  25. package/src/actions/titles/resolve.js +11 -1
  26. package/src/core/compositionState.js +9 -4
  27. package/src/core/facetAxisPolicy.js +30 -0
  28. package/src/core/facetGap.js +44 -0
  29. package/src/grammar/axisTextPlacement.js +1 -1
  30. package/src/grammar/bin2d.js +34 -4
  31. package/src/grammar/curveCommands.js +35 -2
  32. package/src/grammar/datasetSchema.js +1 -1
  33. package/src/grammar/facetHeaders.js +1 -1
  34. package/src/grammar/facets/guides.js +7 -5
  35. package/src/grammar/regression/parameters.js +7 -0
  36. package/src/grammar/timeUnit.js +9 -3
  37. package/src/inspectionDescriptors.js +1 -1
  38. package/src/layout/composition.js +2 -6
  39. package/src/layout/facetContentFlow.js +15 -7
  40. package/src/layout/facets.js +1 -1
  41. package/src/layout/guideCollisions.js +9 -3
  42. package/src/layout/title.js +1 -1
  43. package/src/materialization/facetContentBounds.js +96 -0
  44. package/src/materialization/facetGuides/layout.js +1 -1
  45. package/src/materialization/facetGuides/placement.js +16 -0
  46. package/src/materialization/facetHeaders.js +1 -1
  47. package/src/materialization/facets.js +1 -1
  48. package/src/materialization/graphicHierarchy.js +25 -1
  49. package/src/materialization/guides/layout.js +1 -1
  50. package/src/materialization/guides/resources.js +1 -1
  51. package/src/materialization/planner.js +19 -4
  52. package/src/persistence/validation.js +25 -1
  53. package/src/version.js +1 -1
  54. package/types/basic.d.ts +2 -0
  55. package/types/index.d.ts +3 -0
  56. package/types/program.d.ts +36 -18
@@ -141,6 +141,38 @@ function geometry(program, channel, config, axisId = channel) {
141
141
  return resolved;
142
142
  }
143
143
 
144
+ export function resolveAxisTickEditConfig(program, channel, args, axisId = channel) {
145
+ const op = channel === "x" ? "editXAxisTicks" : "editYAxisTicks";
146
+ validateOptions(args, op, false);
147
+ const previous = program.guideConfigs.axis?.[axisId]?.ticks;
148
+ if (!previous) throw new Error(`${op} requires tick configuration.`);
149
+ const explicitMode = Object.hasOwn(args, "values") || Object.hasOwn(args, "count");
150
+ const inferredValues = !explicitMode && previous.inferredValues === true
151
+ ? inferHistogramBoundaries(program, channel, previous.scale) ??
152
+ (["ordinal", "band", "point"].includes(
153
+ program.resolvedScales[previous.scale]?.type
154
+ )
155
+ ? program.resolvedScales[previous.scale].domain
156
+ : undefined)
157
+ : undefined;
158
+ const mode = Object.hasOwn(args, "values") || inferredValues !== undefined
159
+ ? "values"
160
+ : Object.hasOwn(args, "count") ? "count" : previous.mode;
161
+ const config = patchGuideDash({
162
+ ...previous,
163
+ ...args,
164
+ ...(inferredValues === undefined ? {} : { values: inferredValues }),
165
+ ...(explicitMode ? { inferredValues: false } : {}),
166
+ mode
167
+ }, args);
168
+ if (mode === "values") {
169
+ delete config.count;
170
+ if (!Object.hasOwn(args, "rounding")) delete config.rounding;
171
+ } else delete config.values;
172
+ validateConfig(channel, config);
173
+ return config;
174
+ }
175
+
144
176
  function makeEdit(channel) {
145
177
  const op = channel === "x" ? "editXAxisTicks" : "editYAxisTicks";
146
178
  return action({ op, description: `Edit concrete ${channel}-axis ticks.` }, withGuideLayoutValidation(function (args = {}) {
@@ -149,32 +181,9 @@ function makeEdit(channel) {
149
181
  const axisId = selected.id; args = selected.options;
150
182
  const id = axisGraphicIds(channel, axisId)[1];
151
183
  if (this.graphicSpec.objects[id]?.type !== "line") throw new Error(`${op} requires existing axis ticks.`);
184
+ const config = resolveAxisTickEditConfig(this, channel, args, axisId);
152
185
  const previous = this.guideConfigs.axis?.[axisId]?.ticks;
153
- if (!previous) throw new Error(`${op} requires tick configuration.`);
154
186
  const explicitMode = Object.hasOwn(args, "values") || Object.hasOwn(args, "count");
155
- const inferredValues = !explicitMode && previous.inferredValues === true
156
- ? inferHistogramBoundaries(this, channel, previous.scale) ??
157
- (["ordinal", "band", "point"].includes(
158
- this.resolvedScales[previous.scale]?.type
159
- )
160
- ? this.resolvedScales[previous.scale].domain
161
- : undefined)
162
- : undefined;
163
- const mode = Object.hasOwn(args, "values") || inferredValues !== undefined
164
- ? "values"
165
- : Object.hasOwn(args, "count") ? "count" : previous.mode;
166
- const config = patchGuideDash({
167
- ...previous,
168
- ...args,
169
- ...(inferredValues === undefined ? {} : { values: inferredValues }),
170
- ...(explicitMode ? { inferredValues: false } : {}),
171
- mode
172
- }, args);
173
- if (mode === "values") {
174
- delete config.count;
175
- if (!Object.hasOwn(args, "rounding")) delete config.rounding;
176
- } else delete config.values;
177
- validateConfig(channel, config);
178
187
  const resolved = geometry(this, channel, config, axisId);
179
188
  let next = this._withGuideConfig(axisId, config).editGraphics({ target: id, property: "length", value: resolved.values.length });
180
189
  for (const property of ["x1", "y1", "x2", "y2"]) next = next.editGraphics({ target: id, property, value: resolved[property] });
@@ -1,389 +1 @@
1
- import { selectCartesianAxis, axisTargetArgs } from "../../../selectors/axes.js";
2
- import { axisGraphicIds } from "../../../materialization/guides/resources.js";
3
- import { materializeTextLines } from "../../../materialization/textLines.js";
4
- import { axisTitleTextLines as titleLines, validateAxisTextPlacement, axisTitleAllowsLabelOverlap } from "../../../grammar/axisTextPlacement.js";
5
- import { axisThemeTypography } from "../../theme/state.js";
6
- import { canvasOverflowError } from "../../../layout/canvas.js";
7
- import { isSourceOwnedText } from "../../../grammar/text.js";
8
- import { withGuideLayoutValidation } from "../../../materialization/guides/layout.js";
9
- import { action } from "../../../core/action.js";
10
- import { validateUserId } from "../../../core/identifiers.js";
11
- import {
12
- validateKeys,
13
- validateNonEmptyString
14
- } from "../../../core/validation.js";
15
- import { axisScaleBindings, resolveCartesianAxisFrameBounds } from "../../../materialization/coordinateBounds.js";
16
- import {
17
- isTransformedScaleType,
18
- mapContinuousScaleValues,
19
- mapOrdinalPositionValues
20
- } from "../../../grammar/scales/index.js";
21
- import { AXIS_TITLE_FONT_SIZE, DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
22
- "../../../theme/defaults.js";
23
- import { findDataset } from "../../../selectors/datasets.js";
24
- import { findUpstreamTransform } from "../../../materialization/dataProvenance.js";
25
- import { formatAggregateTitle } from "../../../grammar/aggregate.js";
26
- import { finiteMidpoint } from "../../../grammar/numeric.js";
27
- import {
28
- defaultAxisPosition,
29
- defaultAxisTitleRotation,
30
- resolveAxisTitleGeometry,
31
- validateAxisPosition
32
- } from "./policy.js";
33
- import { findCanvasGraphic, resolvePlotGraphicPlacement } from
34
- "../../../materialization/graphicHierarchy.js";
35
- import {
36
- resolveTextBounds,
37
- textBoundsFitCanvas,
38
- textBoundsIntersect
39
- } from "../../../core/textMetrics.js";
40
- import { resolveConcreteGraphicBounds } from
41
- "../../../grammar/schemas/graphicBounds.js";
42
- import { validateAxisTextStyle } from "./labels.js";
43
- import { resolveRotation } from "../../../grammar/rotation.js";
44
-
45
- function titleBounds(geometry, config, text, profile) {
46
- return resolveTextBounds({
47
- ...geometry,
48
- text,
49
- lines: titleLines(text, config),
50
- fontSize: config.fontSize,
51
- fontFamily: config.fontFamily,
52
- fontWeight: config.fontWeight,
53
- textAlign: config.align ?? "center",
54
- textBaseline: config.baseline ?? "middle",
55
- rotation: config.rotation
56
- }, profile);
57
- }
58
-
59
- function resolveTitleAlong(scale, config) {
60
- const discrete = ["ordinal", "band", "point"].includes(scale?.type);
61
- let along;
62
- if (config.at === "center") {
63
- along = finiteMidpoint(scale.range[0], scale.range[1]);
64
- } else if (config.at === "start" || config.at === "end") {
65
- along = scale.range[config.at === "start" ? 0 : 1];
66
- } else {
67
- if (discrete) {
68
- if (!scale.domain.includes(config.at)) throw new RangeError("Axis title at value must be inside the scale domain.");
69
- along = mapOrdinalPositionValues([config.at], scale)[0];
70
- } else {
71
- const low = Math.min(...scale.domain), high = Math.max(...scale.domain);
72
- if (config.at < low || config.at > high) throw new RangeError("Axis title at value must be inside the scale domain.");
73
- along = mapContinuousScaleValues([config.at], scale)[0];
74
- }
75
- }
76
- return along + (config.alongOffset ?? 0);
77
- }
78
-
79
- function axisLabelBounds(program, channel, axisId = channel) {
80
- const id = axisGraphicIds(channel, axisId)[2], labels = program.graphicSpec.objects[id];
81
- const bounds = target => resolveConcreteGraphicBounds(
82
- program.graphicSpec, target, program.materializationConfigs.textMetrics
83
- );
84
- return labels?.items?.map(item => bounds(item.id)) ?? (labels ? [bounds(id)] : []);
85
- }
86
-
87
- function inferredTitleOffset(program, channel, config, axisId = channel) {
88
- const plot = resolveCartesianAxisFrameBounds(program, channel, config.position, config.scale, axisId);
89
- const canvas = findCanvasGraphic(program)?.properties;
90
- const labels = program.graphicSpec.objects[axisGraphicIds(channel, axisId)[2]]
91
- ? resolveConcreteGraphicBounds(program.graphicSpec, axisGraphicIds(channel, axisId)[2], program.materializationConfigs.textMetrics)
92
- : undefined;
93
- const preferred = channel === "x" ? 42 : 52;
94
- if (!plot || !canvas || !labels) return preferred;
95
- const horizontal = channel === "x";
96
- const positive = config.position === (horizontal ? "bottom" : "right");
97
- const near = horizontal ? "top" : "left";
98
- const far = horizontal ? "bottom" : "right";
99
- const bounds = titleBounds(resolveAxisTitleGeometry({
100
- bounds: plot,
101
- channel,
102
- position: config.position,
103
- along: 0,
104
- offset: preferred
105
- }), config, program.semanticSpec.guides.axis?.[axisId]?.title ?? "", program.materializationConfigs.textMetrics);
106
- const needed = positive
107
- ? labels[far] + 4 - bounds[near]
108
- : bounds[far] - labels[near] + 4;
109
- const available = positive
110
- ? canvas[horizontal ? "height" : "width"] - bounds[far]
111
- : bounds[near];
112
- // Font ink can extend beyond the one-em layout box, especially after
113
- // rotating a middle-baseline title. Keep inferred edge-clamped placement
114
- // inside the Canvas; explicit offsets retain their exact geometry.
115
- const inkClearance = channel === "y" &&
116
- Math.abs(Math.cos(config.rotation)) < 1e-10
117
- ? config.fontSize / 4 : 0;
118
- if (program.materializationConfigs.canvas?.plot !== undefined) {
119
- return preferred + Math.max(0, needed);
120
- }
121
- const legacyAdjustment = Math.min(available, Math.max(0, needed));
122
- if (inkClearance === 0) return preferred + legacyAdjustment;
123
- const adjustment = Math.min(available - inkClearance, Math.max(0, needed));
124
- const candidateBounds = titleBounds(resolveAxisTitleGeometry({
125
- bounds: plot, channel, position: config.position,
126
- along: resolveTitleAlong(program.resolvedScales[config.scale], config),
127
- offset: preferred + adjustment
128
- }), config, program.semanticSpec.guides.axis?.[axisId]?.title ?? "", program.materializationConfigs.textMetrics);
129
- // Optional ink reserve must not introduce a label collision.
130
- return preferred + (axisLabelBounds(program, channel, axisId).some(label => label && textBoundsIntersect(candidateBounds, label))
131
- ? legacyAdjustment : adjustment);
132
- }
133
-
134
- const CREATE_OPTIONS = ["target",
135
- "text", "scale", "position", "at", "offset", "rotation", "color",
136
- "fontSize", "fontFamily", "fontWeight", "alongOffset", "align", "baseline", "overlap"
137
- ];
138
- const EDIT_OPTIONS = CREATE_OPTIONS.filter(key => key !== "scale");
139
- const DEFAULTS = {
140
- color: DEFAULT_COLORS.text,
141
- fontSize: AXIS_TITLE_FONT_SIZE,
142
- fontFamily: DEFAULT_FONT_FAMILY,
143
- fontWeight: 600
144
- };
145
-
146
- function validateText(text) {
147
- return validateNonEmptyString(text, "Axis title text");
148
- }
149
-
150
- function validateConfig(channel, config) {
151
- validateAxisPosition(channel, config.position);
152
- 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.");
153
- validateAxisTextPlacement(config, { title: true });
154
- if (!Number.isFinite(config.rotation)) throw new TypeError("Axis title rotation must be finite radians.");
155
- validateAxisTextStyle(config, "Axis title");
156
- }
157
-
158
- export function inferAxisTitleText(program, channel, scaleId) {
159
- const titles = new Set();
160
- const primaryTitles = new Set();
161
- for (const layer of program.semanticSpec.layers) {
162
- if (isSourceOwnedText(layer)) continue;
163
- // Reference statistics reuse the source axis title.
164
- if (program.markConfigs[layer.id]?.statisticalReference !== undefined) continue;
165
- const primary = layer.encoding?.[channel];
166
- const encoding = layer.mark?.type === "area" && Object.hasOwn(primary ?? {}, "datum")
167
- ? layer.encoding?.[`${channel}2`] : primary;
168
- if (channel === "radius" && layer.mark?.type === "arc" &&
169
- encoding?.scale === scaleId && encoding.aggregate === "count" && encoding.field === undefined) {
170
- titles.add(encoding.title ?? "count");
171
- continue;
172
- }
173
- if (
174
- encoding?.scale === scaleId &&
175
- typeof encoding.field === "string" &&
176
- encoding.field.length
177
- ) {
178
- const dataset = findDataset(program, layer.data);
179
- const transform = findUpstreamTransform(program, dataset,
180
- ["density", "ecdf", "interval", "boxSummary", "gradientProfile", "summary"]);
181
- let derivedTitle;
182
- if (transform?.type === "density") {
183
- derivedTitle = encoding.field === transform.as?.[0]
184
- ? transform.field
185
- : encoding.field === transform.as?.[1]
186
- ? "Density"
187
- : undefined;
188
- } else if (transform?.type === "ecdf") {
189
- derivedTitle = encoding.field === transform.as.value ? transform.field
190
- : encoding.field === transform.as.probability ? "Cumulative probability"
191
- : encoding.field === transform.as.cumulative ? "Cumulative count" : undefined;
192
- } else if (transform?.type === "interval" &&
193
- Object.values(transform.as).includes(encoding.field)) {
194
- derivedTitle = `${transform.center}(${transform.field})`;
195
- } else if (transform?.type === "boxSummary" &&
196
- Object.values(transform.as).includes(encoding.field)) {
197
- derivedTitle = transform.field;
198
- } else if (transform?.type === "gradientProfile" &&
199
- [transform.as.lower, transform.as.upper, transform.as.center].includes(encoding.field)) {
200
- // Profile bounds and the optional center share one source measure.
201
- derivedTitle = transform.field;
202
- } else if (transform?.type === "summary") {
203
- const aggregate = transform.aggregates.find(value => value.as === encoding.field);
204
- if (aggregate) derivedTitle = formatAggregateTitle(aggregate.op, aggregate.field);
205
- }
206
- const title = encoding.title ?? derivedTitle ?? (encoding.aggregate === undefined
207
- ? encoding.field
208
- : formatAggregateTitle(encoding.aggregate, encoding.field));
209
- titles.add(title);
210
- if (layer.mark?.type === "point") primaryTitles.add(title);
211
- }
212
- }
213
- if (primaryTitles.size === 1) return [...primaryTitles][0];
214
- if (titles.size === 0 && axisScaleBindings(program, scaleId).some(binding => binding.channel === channel)) return scaleId;
215
- if (titles.size !== 1) throw new Error(`Axis title text cannot be inferred for scale "${scaleId}".`);
216
- return [...titles][0];
217
- }
218
-
219
- function resolveGeometry(program, channel, config, axisId = channel) {
220
- const scale = program.resolvedScales[config.scale];
221
- const bounds = resolveCartesianAxisFrameBounds(program, channel, config.position, config.scale, axisId);
222
- if ((
223
- !["linear", "time", "ordinal", "band", "point"].includes(scale?.type) &&
224
- !isTransformedScaleType(scale?.type)
225
- ) || !bounds) throw new Error("Axis title requires a supported resolved scale and Canvas bounds.");
226
- const along = resolveTitleAlong(scale, config);
227
- const geometry = resolveAxisTitleGeometry({
228
- bounds,
229
- channel,
230
- position: config.position,
231
- along,
232
- offset: config.offset
233
- });
234
- const resolvedBounds = titleBounds(
235
- geometry,
236
- config,
237
- program.semanticSpec.guides.axis?.[axisId]?.title ?? ""
238
- , program.materializationConfigs.textMetrics);
239
- const canvas = findCanvasGraphic(program)?.properties;
240
- if (!canvas || !textBoundsFitCanvas(resolvedBounds, canvas)) {
241
- throw canvasOverflowError(`The ${channel}-axis title does not fit the Canvas margin.`, [resolvedBounds], canvas);
242
- }
243
- const labelBounds = axisLabelBounds(program, channel, axisId);
244
- if (!axisTitleAllowsLabelOverlap(config) && labelBounds.some(bounds => bounds && textBoundsIntersect(resolvedBounds, bounds))) {
245
- throw new Error(`The ${channel}-axis title overlaps the axis labels.`);
246
- }
247
- return geometry;
248
- }
249
-
250
- function names(channel) {
251
- const prefix = channel === "x" ? "X" : "Y";
252
- return { create: `create${prefix}AxisTitle`, edit: `edit${prefix}AxisTitle` };
253
- }
254
-
255
- function makeEdit(channel) {
256
- const operation = names(channel);
257
- return action({ op: operation.edit, description: `Edit the ${channel}-axis title.` }, withGuideLayoutValidation(function (args = {}) {
258
- validateKeys(args, EDIT_OPTIONS, operation.edit);
259
- const selected = selectCartesianAxis(this, channel, args, { create: false });
260
- const axisId = selected.id; args = selected.options;
261
- const graphic = axisGraphicIds(channel, axisId)[3];
262
- validateAxisTextPlacement(args, { title: true });
263
- if (this.graphicSpec.objects[graphic]?.type !== "text") throw new Error(`${operation.edit} requires an existing axis title.`);
264
- const previous = this.guideConfigs.axis?.[axisId]?.title;
265
- if (!previous) throw new Error(`${operation.edit} requires title configuration.`);
266
- const { text, rotation: requestedRotation, ...appearance } = args;
267
- const explicitText = Object.hasOwn(args, "text");
268
- const explicitRotation = Object.hasOwn(args, "rotation");
269
- const position = appearance.position ?? previous.position;
270
- const inferredRotation = explicitRotation
271
- ? false
272
- : previous.inferredRotation === true;
273
- const config = {
274
- ...previous,
275
- ...appearance,
276
- position,
277
- rotation: inferredRotation
278
- ? defaultAxisTitleRotation(channel, position)
279
- : explicitRotation
280
- ? resolveRotation(requestedRotation, "Axis title rotation")
281
- : previous.rotation,
282
- inferredRotation,
283
- inferredOffset: Object.hasOwn(args, "offset")
284
- ? false
285
- : previous.inferredOffset === true,
286
- ...(explicitText ? { inferredText: false } : {})
287
- };
288
- validateConfig(channel, config);
289
- let next = this;
290
- const resolvedTitle = explicitText
291
- ? validateText(text)
292
- : config.inferredText === true
293
- ? inferAxisTitleText(this, channel, config.scale)
294
- : this.semanticSpec.guides.axis?.[axisId]?.title;
295
- if (resolvedTitle !== this.semanticSpec.guides.axis?.[axisId]?.title) {
296
- next = next.editSemantic({
297
- property: `guide.axis.${axisId}.title`,
298
- value: validateText(resolvedTitle)
299
- });
300
- }
301
- const resolvedText = validateText(next.semanticSpec.guides.axis?.[axisId]?.title);
302
- if (config.inferredOffset) {
303
- config.offset = inferredTitleOffset(next, channel, config, axisId);
304
- }
305
- const geometry = resolveGeometry(next, channel, config, axisId);
306
- next = next._withGuideConfig(axisId, "title", config);
307
- const properties = {
308
- x: geometry.x, y: geometry.y, text: resolvedText, fill: config.color,
309
- fontSize: config.fontSize, fontFamily: config.fontFamily,
310
- fontWeight: config.fontWeight, textAlign: config.align ?? "center", textBaseline: config.baseline ?? "middle",
311
- rotation: config.rotation
312
- };
313
- for (const [property, value] of Object.entries(properties)) next = next.editGraphics({ target: graphic, property, value });
314
- return materializeTextLines(next, graphic, titleLines(resolvedText, config));
315
- }));
316
- }
317
-
318
- const editXAxisTitle = /* @__PURE__ */ makeEdit("x");
319
- const editYAxisTitle = /* @__PURE__ */ makeEdit("y");
320
-
321
- function makeCreate(channel) {
322
- const operation = names(channel);
323
- return action({ op: operation.create, description: `Create the ${channel}-axis title.` }, withGuideLayoutValidation(function (args = {}) {
324
- validateKeys(args, CREATE_OPTIONS, operation.create);
325
- const selected = selectCartesianAxis(this, channel, args, { create: true });
326
- const axisId = selected.id; args = selected.options;
327
- const graphic = axisGraphicIds(channel, axisId)[3];
328
- validateAxisTextPlacement(args, { title: true });
329
- const {
330
- text: requestedText,
331
- scale: requestedScale,
332
- position: requestedPosition,
333
- rotation: requestedRotation,
334
- ...appearance
335
- } = args;
336
- const scale = validateUserId(requestedScale ?? channel, "Scale id");
337
- const guideScale = this.semanticSpec.guides.axis?.[axisId]?.scale;
338
- if (guideScale && guideScale !== scale) throw new Error(`${operation.create} conflicts with the existing axis scale.`);
339
- if (this.graphicSpec.objects[graphic]) throw new Error(`${operation.create} requires a missing axis title.`);
340
- const inferredText = !Object.hasOwn(args, "text");
341
- const text = validateText(
342
- requestedText ?? inferAxisTitleText(this, channel, scale)
343
- );
344
- const position = requestedPosition ?? defaultAxisPosition(channel);
345
- const inferredRotation = !Object.hasOwn(args, "rotation");
346
- const inferredOffset = !Object.hasOwn(args, "offset");
347
- const config = {
348
- scale,
349
- inferredText,
350
- position,
351
- at: "center",
352
- offset: channel === "x" ? 42 : 52,
353
- rotation: inferredRotation
354
- ? defaultAxisTitleRotation(channel, position)
355
- : resolveRotation(requestedRotation, "Axis title rotation"),
356
- inferredRotation,
357
- inferredOffset,
358
- color: DEFAULTS.color,
359
- fontSize: DEFAULTS.fontSize,
360
- fontFamily: DEFAULTS.fontFamily,
361
- fontWeight: DEFAULTS.fontWeight,
362
- ...axisThemeTypography(this.materializationConfigs.theme, "title"),
363
- ...appearance
364
- };
365
- let next = this
366
- .editSemantic({ property: `guide.axis.${axisId}.scale`, value: scale })
367
- .editSemantic({ property: `guide.axis.${axisId}.title`, value: text });
368
- if (inferredOffset) config.offset = inferredTitleOffset(next, channel, config, axisId);
369
- validateConfig(channel, config);
370
- resolveGeometry(next, channel, config, axisId);
371
- return next
372
- .createGraphics({
373
- id: graphic,
374
- type: "text",
375
- ...resolvePlotGraphicPlacement(this)
376
- })
377
- ._withGuideConfig(axisId, "title", config)[operation.edit](axisId === channel ? undefined : axisTargetArgs(axisId, channel));
378
- }));
379
- }
380
-
381
- const createXAxisTitle = /* @__PURE__ */ makeCreate("x");
382
- const createYAxisTitle = /* @__PURE__ */ makeCreate("y");
383
-
384
- export function registerAxisTitleActions(Class) {
385
- Class.prototype.createXAxisTitle = createXAxisTitle;
386
- Class.prototype.createYAxisTitle = createYAxisTitle;
387
- Class.prototype.editXAxisTitle = editXAxisTitle;
388
- Class.prototype.editYAxisTitle = editYAxisTitle;
389
- }
1
+ import{selectCartesianAxis as E,axisTargetArgs as J}from"../../../selectors/axes.js";import{axisGraphicIds as h}from"../../../materialization/guides/resources.js";import{materializeTextLines as Q}from"../../../materialization/textLines.js";import{axisTitleTextLines as z,validateAxisTextPlacement as g,axisTitleAllowsLabelOverlap as I,isLabelRelativeTitleOffset as M,axisLabelHasText as tt}from"../../../grammar/axisTextPlacement.js";import{axisThemeTypography as et}from"../../theme/state.js";import{canvasOverflowError as it}from"../../../layout/canvas.js";import{isSourceOwnedText as ot}from"../../../grammar/text.js";import{withGuideLayoutValidation as F}from"../../../materialization/guides/layout.js";import{action as $}from"../../../core/action.js";import{validateUserId as st}from"../../../core/identifiers.js";import{validateKeys as j,validateNonEmptyString as rt}from"../../../core/validation.js";import{axisScaleBindings as nt,resolveCartesianAxisFrameBounds as L}from"../../../materialization/coordinateBounds.js";import{isTransformedScaleType as at,mapContinuousScaleValues as lt,mapOrdinalPositionValues as ft}from"../../../grammar/scales/index.js";import{AXIS_TITLE_FONT_SIZE as dt,DEFAULT_COLORS as ct,DEFAULT_FONT_FAMILY as ut}from"../../../theme/defaults.js";import{findDataset as pt}from"../../../selectors/datasets.js";import{findUpstreamTransform as mt}from"../../../materialization/dataProvenance.js";import{formatAggregateTitle as R}from"../../../grammar/aggregate.js";import{finiteMidpoint as xt}from"../../../grammar/numeric.js";import{defaultAxisPosition as bt,defaultAxisTitleRotation as B,resolveAxisTitleGeometry as w,validateAxisPosition as yt}from"./policy.js";import{findCanvasGraphic as q,resolvePlotGraphicPlacement as vt}from"../../../materialization/graphicHierarchy.js";import{resolveTextBounds as Tt,textBoundsFitCanvas as ht,textBoundsIntersect as G}from"../../../core/textMetrics.js";import{resolveConcreteGraphicBounds as C}from"../../../grammar/schemas/graphicBounds.js";import{validateAxisTextStyle as wt}from"./labels.js";import{resolveRotation as P}from"../../../grammar/rotation.js";function S(t,e,i,o){return Tt({...t,text:i,lines:z(i,e),fontSize:e.fontSize,fontFamily:e.fontFamily,fontWeight:e.fontWeight,textAlign:e.align??"center",textBaseline:e.baseline??"middle",rotation:e.rotation},o)}function _(t,e){const i=["ordinal","band","point"].includes(t?.type);let o;if(e.at==="center")o=xt(t.range[0],t.range[1]);else if(e.at==="start"||e.at==="end")o=t.range[e.at==="start"?0:1];else if(i){if(!t.domain.includes(e.at))throw new RangeError("Axis title at value must be inside the scale domain.");o=ft([e.at],t)[0]}else{const s=Math.min(...t.domain),a=Math.max(...t.domain);if(e.at<s||e.at>a)throw new RangeError("Axis title at value must be inside the scale domain.");o=lt([e.at],t)[0]}return o+(e.alongOffset??0)}function N(t,e,i=e){const o=h(e,i)[2],s=t.graphicSpec.objects[o],a=f=>C(t.graphicSpec,f,t.materializationConfigs.textMetrics);return s?.items?.map(f=>a(f.id))??(s?[a(o)]:[])}function W(t,e,i,o=e){const s=L(t,e,i.position,i.scale,o),a=q(t)?.properties,f=t.graphicSpec.objects[h(e,o)[2]]?C(t.graphicSpec,h(e,o)[2],t.materializationConfigs.textMetrics):void 0,n=e==="x"?42:52;if(!s||!a||!f)return n;const u=e==="x",r=i.position===(u?"bottom":"right"),d=u?"top":"left",c=u?"bottom":"right",p=S(w({bounds:s,channel:e,position:i.position,along:0,offset:n}),i,t.semanticSpec.guides.axis?.[o]?.title??"",t.materializationConfigs.textMetrics),x=r?f[c]+4-p[d]:p[c]-f[d]+4,l=r?a[u?"height":"width"]-p[c]:p[d],m=e==="y"&&Math.abs(Math.cos(i.rotation))<1e-10?i.fontSize/4:0;if(t.materializationConfigs.canvas?.plot!==void 0)return n+Math.max(0,x);const y=Math.min(l,Math.max(0,x));if(m===0)return n+y;const v=Math.min(l-m,Math.max(0,x)),b=S(w({bounds:s,channel:e,position:i.position,along:_(t.resolvedScales[i.scale],i),offset:n+v}),i,t.semanticSpec.guides.axis?.[o]?.title??"",t.materializationConfigs.textMetrics);return n+(N(t,e,o).some(T=>T&&G(b,T))?y:v)}function St(t,e,i,o,s,a){const f=t.graphicSpec.objects[h(e,o)[2]];if(t.guideConfigs.axis?.[o]?.labels?.position!==i.position)throw new Error("Label-relative title offset requires labels on the same axis side.");const u=(f?.items??(f?[f]:[])).filter(l=>tt(l.properties)).map(l=>C(t.graphicSpec,l.id,t.materializationConfigs.textMetrics));if(u.length===0)throw new Error("Label-relative title offset requires nonempty axis labels.");const r=e==="x"?"top":"left",d=e==="x"?"bottom":"right",c=i.position===(e==="x"?"bottom":"right"),p=S(w({bounds:s,channel:e,position:i.position,along:a,offset:0}),i,t.semanticSpec.guides.axis?.[o]?.title??"",t.materializationConfigs.textMetrics),x=c?Math.max(...u.map(l=>l[d]))+i.offset.gap-p[r]:p[d]-Math.min(...u.map(l=>l[r]))+i.offset.gap;if(!Number.isFinite(x))throw new RangeError("Label-relative title offset must resolve to a finite distance.");return x}const k=["target","text","scale","position","at","offset","rotation","color","fontSize","fontFamily","fontWeight","alongOffset","align","baseline","overlap"],At=k.filter(t=>t!=="scale"),A={color:ct.text,fontSize:dt,fontFamily:ut,fontWeight:600};function O(t){return rt(t,"Axis title text")}function D(t,e){if(yt(t,e.position),!["start","center","end"].includes(e.at)&&!Number.isFinite(e.at))throw new TypeError("Axis title at must be start, center, end, or a finite number.");if(g(e,{title:!0}),!Number.isFinite(e.rotation))throw new TypeError("Axis title rotation must be finite radians.");wt(e,"Axis title")}function X(t,e,i){const o=new Set,s=new Set;for(const a of t.semanticSpec.layers){if(ot(a)||t.markConfigs[a.id]?.statisticalReference!==void 0)continue;const f=a.encoding?.[e],n=a.mark?.type==="area"&&Object.hasOwn(f??{},"datum")?a.encoding?.[`${e}2`]:f;if(e==="radius"&&a.mark?.type==="arc"&&n?.scale===i&&n.aggregate==="count"&&n.field===void 0){o.add(n.title??"count");continue}if(n?.scale===i&&typeof n.field=="string"&&n.field.length){const u=pt(t,a.data),r=mt(t,u,["density","ecdf","interval","boxSummary","gradientProfile","summary"]);let d;if(r?.type==="density")d=n.field===r.as?.[0]?r.field:n.field===r.as?.[1]?"Density":void 0;else if(r?.type==="ecdf")d=n.field===r.as.value?r.field:n.field===r.as.probability?"Cumulative probability":n.field===r.as.cumulative?"Cumulative count":void 0;else if(r?.type==="interval"&&Object.values(r.as).includes(n.field))d=`${r.center}(${r.field})`;else if(r?.type==="boxSummary"&&Object.values(r.as).includes(n.field))d=r.field;else if(r?.type==="gradientProfile"&&[r.as.lower,r.as.upper,r.as.center].includes(n.field))d=r.field;else if(r?.type==="summary"){const p=r.aggregates.find(x=>x.as===n.field);p&&(d=R(p.op,p.field))}const c=n.title??d??(n.aggregate===void 0?n.field:R(n.aggregate,n.field));o.add(c),a.mark?.type==="point"&&s.add(c)}}if(s.size===1)return[...s][0];if(o.size===0&&nt(t,i).some(a=>a.channel===e))return i;if(o.size!==1)throw new Error(`Axis title text cannot be inferred for scale "${i}".`);return[...o][0]}function Y(t,e,i,o=e){const s=t.resolvedScales[i.scale],a=L(t,e,i.position,i.scale,o);if(!["linear","time","ordinal","band","point"].includes(s?.type)&&!at(s?.type)||!a)throw new Error("Axis title requires a supported resolved scale and Canvas bounds.");const f=_(s,i),n=w({bounds:a,channel:e,position:i.position,along:f,offset:M(i.offset)?St(t,e,i,o,a,f):i.offset}),u=S(n,i,t.semanticSpec.guides.axis?.[o]?.title??"",t.materializationConfigs.textMetrics),r=q(t)?.properties;if(!r||!ht(u,r))throw M(i.offset)?new RangeError(`The ${e}-axis label-relative title does not fit the Canvas margin.`):it(`The ${e}-axis title does not fit the Canvas margin.`,[u],r);const d=N(t,e,o);if(!I(i)&&d.some(c=>c&&G(u,c)))throw new Error(`The ${e}-axis title overlaps the axis labels.`);return n}function U(t){const e=t==="x"?"X":"Y";return{create:`create${e}AxisTitle`,edit:`edit${e}AxisTitle`}}function V(t){const e=U(t);return $({op:e.edit,description:`Edit the ${t}-axis title.`},F(function(i={}){j(i,At,e.edit);const o=E(this,t,i,{create:!1}),s=o.id;i=o.options;const a=h(t,s)[3];if(g(i,{title:!0}),this.graphicSpec.objects[a]?.type!=="text")throw new Error(`${e.edit} requires an existing axis title.`);const f=this.guideConfigs.axis?.[s]?.title;if(!f)throw new Error(`${e.edit} requires title configuration.`);const{text:n,rotation:u,...r}=i,d=Object.hasOwn(i,"text"),c=Object.hasOwn(i,"rotation"),p=r.position??f.position,x=c?!1:f.inferredRotation===!0,l={...f,...r,position:p,rotation:x?B(t,p):c?P(u,"Axis title rotation"):f.rotation,inferredRotation:x,inferredOffset:Object.hasOwn(i,"offset")?!1:f.inferredOffset===!0,...d?{inferredText:!1}:{}};D(t,l);let m=this;const y=d?O(n):l.inferredText===!0?X(this,t,l.scale):this.semanticSpec.guides.axis?.[s]?.title;y!==this.semanticSpec.guides.axis?.[s]?.title&&(m=m.editSemantic({property:`guide.axis.${s}.title`,value:O(y)}));const v=O(m.semanticSpec.guides.axis?.[s]?.title);l.inferredOffset&&(l.offset=W(m,t,l,s));const b=Y(m,t,l,s);m=m._withGuideConfig(s,"title",l);const T={x:b.x,y:b.y,text:v,fill:l.color,fontSize:l.fontSize,fontFamily:l.fontFamily,fontWeight:l.fontWeight,textAlign:l.align??"center",textBaseline:l.baseline??"middle",rotation:l.rotation};for(const[K,Z]of Object.entries(T))m=m.editGraphics({target:a,property:K,value:Z});return Q(m,a,z(v,l))}))}const Ot=V("x"),gt=V("y");function H(t){const e=U(t);return $({op:e.create,description:`Create the ${t}-axis title.`},F(function(i={}){j(i,k,e.create);const o=E(this,t,i,{create:!0}),s=o.id;i=o.options;const a=h(t,s)[3];g(i,{title:!0});const{text:f,scale:n,position:u,rotation:r,...d}=i,c=st(n??t,"Scale id"),p=this.semanticSpec.guides.axis?.[s]?.scale;if(p&&p!==c)throw new Error(`${e.create} conflicts with the existing axis scale.`);if(this.graphicSpec.objects[a])throw new Error(`${e.create} requires a missing axis title.`);const x=!Object.hasOwn(i,"text"),l=O(f??X(this,t,c)),m=u??bt(t),y=!Object.hasOwn(i,"rotation"),v=!Object.hasOwn(i,"offset"),b={scale:c,inferredText:x,position:m,at:"center",offset:t==="x"?42:52,rotation:y?B(t,m):P(r,"Axis title rotation"),inferredRotation:y,inferredOffset:v,color:A.color,fontSize:A.fontSize,fontFamily:A.fontFamily,fontWeight:A.fontWeight,...et(this.materializationConfigs.theme,"title"),...d};let T=this.editSemantic({property:`guide.axis.${s}.scale`,value:c}).editSemantic({property:`guide.axis.${s}.title`,value:l});return v&&(b.offset=W(T,t,b,s)),D(t,b),Y(T,t,b,s),T.createGraphics({id:a,type:"text",...vt(this)})._withGuideConfig(s,"title",b)[e.edit](s===t?void 0:J(s,t))}))}const Ct=H("x"),Et=H("y");function Qt(t){t.prototype.createXAxisTitle=Ct,t.prototype.createYAxisTitle=Et,t.prototype.editXAxisTitle=Ot,t.prototype.editYAxisTitle=gt}export{X as inferAxisTitleText,Qt as registerAxisTitleActions};
@@ -85,10 +85,7 @@ export const rematerializeLegendTitle = /* @__PURE__ */ action(
85
85
  .editGraphics({
86
86
  target: id,
87
87
  property: "textAlign",
88
- value: config.titleStyle.align ?? (["top", "bottom"].includes(config.position) &&
89
- config.titlePosition === "top"
90
- ? "center"
91
- : "left")
88
+ value: layout.title.align
92
89
  })
93
90
  .editGraphics({ target: id, property: "textBaseline", value: "middle" });
94
91
  return materializeLegendTitleLines(next, id, config.title, config.titleStyle);
@@ -1,3 +1,5 @@
1
+ import { reconcileRegressionPlacement } from "../../materialization/graphicHierarchy.js";
2
+ import { validateRegressionPlacement } from "../../grammar/regression/parameters.js";
1
3
  import { closedAction } from "../../core/action.js";
2
4
  import { validateKeys } from "../../core/validation.js";
3
5
  import { normalizeRegressionParameters, normalizeRegressionPredict } from "../../grammar/regression/index.js";
@@ -12,7 +14,7 @@ import {
12
14
  const REGRESSION_OPTIONS = Object.freeze([
13
15
  "target", "x", "y", "groupBy", "method", "degree", "span", "robustIterations",
14
16
  "confidenceMethod", "level", "confidence", "interval", "band", "line"
15
- , "predict", "sourceBinding", "missing"
17
+ , "predict", "sourceBinding", "missing", "placement"
16
18
  ]);
17
19
 
18
20
  export const createRegression = /* @__PURE__ */ closedAction(
@@ -21,6 +23,7 @@ export const createRegression = /* @__PURE__ */ closedAction(
21
23
  description: "Fit and layer regression lines with optional interval bands."
22
24
  }, REGRESSION_OPTIONS,
23
25
  function (args = {}) {
26
+ const placement = validateRegressionPlacement(args.placement);
24
27
  const sourceBinding = args.sourceBinding ?? "fixed";
25
28
  if (!["fixed", "follow"].includes(sourceBinding)) {
26
29
  throw new Error('Regression sourceBinding must be "fixed" or "follow".');
@@ -134,9 +137,11 @@ export const createRegression = /* @__PURE__ */ closedAction(
134
137
  dataId,
135
138
  bandId: band === false ? undefined : bandId,
136
139
  lineId,
140
+ ...(Object.hasOwn(args, "placement") ? { placement } : {}),
137
141
  parameters: regressionDefinition
138
142
  }
139
143
  });
144
+ if (placement === "plot-last") next = reconcileRegressionPlacement(next, point.id);
140
145
  return sourceBinding === "follow"
141
146
  ? next.editSemantic({
142
147
  property: `layer[${point.id}].derivedBindings.regression`,
@@ -1,3 +1,5 @@
1
+ import { reconcileRegressionPlacement } from "../../materialization/graphicHierarchy.js";
2
+ import { validateRegressionPlacement } from "../../grammar/regression/parameters.js";
1
3
  import { closedAction } from "../../core/action.js";
2
4
  import { isPlainObject } from "../../core/immutable.js";
3
5
  import { validateUserId } from "../../core/identifiers.js";
@@ -24,7 +26,7 @@ import { buildSourceRevisionPlan, applyRevisionPlan } from "../data/edit.js";
24
26
  const OPTIONS = Object.freeze([
25
27
  "target", "data", "x", "y", "groupBy", "method", "degree", "span", "robustIterations",
26
28
  "confidenceMethod", "level", "confidence", "interval", "band", "line"
27
- , "predict", "sourceBinding", "missing"
29
+ , "predict", "sourceBinding", "missing", "placement"
28
30
  ]);
29
31
  const BAND_OPTIONS = Object.freeze([
30
32
  "color", "opacity", "stroke", "strokeWidth", "curve",
@@ -259,6 +261,7 @@ export const editRegression = /* @__PURE__ */ closedAction(
259
261
  }
260
262
  const owner = resolveRegressionOwner(this, args.target);
261
263
  const current = this.markConfigs[owner.id].regression;
264
+ const placement = validateRegressionPlacement(Object.hasOwn(args, "placement") ? args.placement : current.placement);
262
265
  const currentBinding = owner.derivedBindings?.regression?.mode === "follow" ? "follow" : "fixed";
263
266
  const sourceBinding = args.sourceBinding ?? currentBinding;
264
267
  if (!["fixed", "follow"].includes(sourceBinding)) throw new Error('Regression sourceBinding must be "fixed" or "follow".');
@@ -350,6 +353,8 @@ export const editRegression = /* @__PURE__ */ closedAction(
350
353
  }
351
354
  }
352
355
 
356
+ next = next._withMarkConfig(owner.id, { ...next.markConfigs[owner.id],
357
+ regression: { ...current, dataId } });
353
358
  let bandId = current.bandId;
354
359
  if (hadBand && !wantsBand) {
355
360
  next = removeOwnedMark(next, current.bandId);
@@ -411,6 +416,7 @@ export const editRegression = /* @__PURE__ */ closedAction(
411
416
  ...next.markConfigs[owner.id],
412
417
  regression: {
413
418
  ...current,
419
+ ...(Object.hasOwn(args, "placement") ? { placement } : {}),
414
420
  source,
415
421
  x,
416
422
  y,
@@ -432,6 +438,9 @@ export const editRegression = /* @__PURE__ */ closedAction(
432
438
  } else if (sourceBinding === "fixed" && binding !== undefined) {
433
439
  next = next.editSemantic({ property: `layer[${owner.id}].derivedBindings.regression`, remove: true });
434
440
  }
441
+ if (placement === "plot-last" || Object.hasOwn(args, "placement")) {
442
+ next = reconcileRegressionPlacement(next, owner.id);
443
+ }
435
444
  return next;
436
445
  };
437
446
  return applyEdit(this);
@@ -24,7 +24,7 @@ import { findCanvasGraphic } from
24
24
 
25
25
  const OPTIONS = Object.freeze([
26
26
  "text", "subtitle", "position", "align", "offset", "alongOffset", "gap", "titleStyle",
27
- "subtitleStyle", "maxWidth", "wrap", "lineHeight"
27
+ "subtitleStyle", "maxWidth", "wrap", "lineHeight", "subtitleLineHeight"
28
28
  ]);
29
29
  const STYLE_OPTIONS = Object.freeze([
30
30
  "color", "fontSize", "fontFamily", "fontWeight", "fontStyle"
@@ -101,6 +101,9 @@ function normalizeConfig(args, defaults, { hasSubtitle }) {
101
101
  const maxWidth = args.maxWidth ?? defaults.maxWidth;
102
102
  const wrap = args.wrap ?? defaults.wrap ?? (maxWidth === undefined ? undefined : "word");
103
103
  const lineHeight = args.lineHeight ?? defaults.lineHeight;
104
+ const subtitleLineHeight = args.subtitleLineHeight === undefined
105
+ ? defaults.subtitleLineHeight
106
+ : args.subtitleLineHeight;
104
107
  if (!POSITIONS.includes(position)) {
105
108
  throw new Error(`Unsupported title position "${position}".`);
106
109
  }
@@ -128,6 +131,12 @@ function normalizeConfig(args, defaults, { hasSubtitle }) {
128
131
  )) {
129
132
  throw new RangeError("Chart title lineHeight must cover every visible fontSize.");
130
133
  }
134
+ if (subtitleLineHeight !== undefined && (
135
+ !Number.isFinite(subtitleLineHeight) ||
136
+ subtitleLineHeight < subtitleStyle.fontSize
137
+ )) {
138
+ throw new RangeError("Chart subtitleLineHeight must cover subtitleStyle.fontSize.");
139
+ }
131
140
  return {
132
141
  position,
133
142
  align,
@@ -136,6 +145,7 @@ function normalizeConfig(args, defaults, { hasSubtitle }) {
136
145
  gap,
137
146
  titleStyle,
138
147
  subtitleStyle,
148
+ ...(subtitleLineHeight === undefined ? {} : { subtitleLineHeight }),
139
149
  ...(maxWidth === undefined ? {} : { maxWidth, wrap, ...(lineHeight === undefined
140
150
  ? {}
141
151
  : { lineHeight }) })