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