ggaction 0.0.11 → 0.0.12
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 +31 -0
- package/README.md +2 -1
- package/knowledge/action-cards.json +1 -1
- package/knowledge/intent-taxonomy.json +1 -1
- package/knowledge/mcp-resources.json +1 -1
- package/knowledge/task-resolver.js +1 -1
- package/package.json +1 -1
- package/src/actions/encodings/offset.js +33 -21
- package/src/actions/encodings/position/apply.js +30 -0
- package/src/actions/encodings/position/policies/arc.js +23 -2
- package/src/actions/encodings/position/resolve.js +12 -2
- package/src/actions/errorBars/create.js +138 -7
- package/src/actions/errorBars/edit.js +10 -0
- package/src/actions/marks/point/materialize.js +33 -13
- package/src/actions/marks/rule/actions.js +26 -1
- package/src/core/vocabulary.js +2 -2
- package/src/grammar/arcs.js +156 -65
- package/src/grammar/positionCompatibility.js +1 -1
- package/src/grammar/rules.js +2 -0
- package/src/grammar/scales/ordinal.js +23 -0
- package/src/layout/facets.js +45 -16
- package/src/materialization/facetGuides/legacyCategorical.js +7 -1
- package/src/materialization/facetGuides/placement.js +16 -1
- package/src/materialization/facetGuides/preparation.js +26 -1
- package/src/materialization/facets.js +3 -1
- package/src/materialization/marks/capabilities.js +10 -2
- package/src/materialization/marks/index.js +5 -2
- package/src/materialization/rowEncoding.js +57 -1
- package/src/materialization/scales/policies/bar.js +0 -43
- package/src/materialization/scales/policies/offset.js +54 -0
- package/src/materialization/scales/resolve.js +2 -4
- package/types/program.d.ts +18 -0
package/src/grammar/arcs.js
CHANGED
|
@@ -17,12 +17,30 @@ function requireArcLayer(layer) {
|
|
|
17
17
|
throw new Error("Arc derivation requires a semantic arc mark.");
|
|
18
18
|
}
|
|
19
19
|
const theta = layer.encoding?.theta;
|
|
20
|
-
if (
|
|
21
|
-
|
|
20
|
+
if (
|
|
21
|
+
theta === undefined ||
|
|
22
|
+
!["quantitative", "nominal", "ordinal"].includes(theta.fieldType)
|
|
23
|
+
) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Arc mark "${layer.id}" requires quantitative or categorical theta.`
|
|
26
|
+
);
|
|
22
27
|
}
|
|
23
28
|
return theta;
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
function requireContinuousThetaScale(scale, label) {
|
|
32
|
+
if (
|
|
33
|
+
scale?.type !== "linear" ||
|
|
34
|
+
!Array.isArray(scale.range) ||
|
|
35
|
+
scale.range.length !== 2 ||
|
|
36
|
+
!scale.range.every(Number.isFinite) ||
|
|
37
|
+
scale.range[0] === scale.range[1]
|
|
38
|
+
) {
|
|
39
|
+
throw new Error(`${label} requires a resolved linear scale.`);
|
|
40
|
+
}
|
|
41
|
+
return scale;
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
function requireBandScale(scale, label) {
|
|
27
45
|
if (
|
|
28
46
|
scale?.type !== "band" ||
|
|
@@ -45,24 +63,90 @@ function colorValues(rows, encoding) {
|
|
|
45
63
|
: readNominalField(rows, encoding.field);
|
|
46
64
|
}
|
|
47
65
|
|
|
48
|
-
|
|
49
|
-
if (!Array.isArray(rows)) throw new TypeError(
|
|
66
|
+
function readNonNegativeArcThetaValues(rows, field, layerId, role) {
|
|
67
|
+
if (!Array.isArray(rows)) throw new TypeError(`Arc theta ${role}s require rows.`);
|
|
50
68
|
if (typeof field !== "string" || field.length === 0) {
|
|
51
|
-
throw new TypeError(
|
|
69
|
+
throw new TypeError(`Arc theta ${role} must be a non-empty string.`);
|
|
52
70
|
}
|
|
53
|
-
const
|
|
71
|
+
const values = rows.map((row, index) => {
|
|
54
72
|
const value = row?.[field];
|
|
55
73
|
if (!Number.isFinite(value) || value < 0) {
|
|
56
74
|
throw new TypeError(
|
|
57
|
-
`Arc theta
|
|
75
|
+
`Arc theta ${role} field "${field}" must contain non-negative finite numbers at row ${index}.`
|
|
58
76
|
);
|
|
59
77
|
}
|
|
60
78
|
return value;
|
|
61
79
|
});
|
|
62
|
-
if (
|
|
63
|
-
throw new Error(`Arc mark "${layerId}" theta
|
|
80
|
+
if (values.every(value => value === 0)) {
|
|
81
|
+
throw new Error(`Arc mark "${layerId}" theta ${role}s must have a positive total.`);
|
|
82
|
+
}
|
|
83
|
+
return cloneAndFreeze(values);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readArcThetaWeights(rows, field, layerId = "arc") {
|
|
87
|
+
return readNonNegativeArcThetaValues(rows, field, layerId, "weight");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readArcThetaValues(rows, field, layerId = "arc") {
|
|
91
|
+
return readNonNegativeArcThetaValues(rows, field, layerId, "value");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveProportionalRanges(values, range, labelAt) {
|
|
95
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
96
|
+
const normalizedTotal = Number.isFinite(total)
|
|
97
|
+
? undefined
|
|
98
|
+
: normalizedFiniteSum(values, "Arc theta values");
|
|
99
|
+
if (total === 0 || normalizedTotal?.total <= 0) {
|
|
100
|
+
throw new Error("Arc theta values must have a positive total.");
|
|
101
|
+
}
|
|
102
|
+
const span = range[1] - range[0];
|
|
103
|
+
let cursor = range[0];
|
|
104
|
+
let cumulative = 0;
|
|
105
|
+
let cumulativeCorrection = 0;
|
|
106
|
+
return values.map((value, index) => {
|
|
107
|
+
const normalizedValue = normalizedTotal === undefined
|
|
108
|
+
? value
|
|
109
|
+
: value / normalizedTotal.scale;
|
|
110
|
+
const next = cumulative + normalizedValue;
|
|
111
|
+
if (normalizedTotal !== undefined) {
|
|
112
|
+
cumulativeCorrection += Math.abs(cumulative) >= Math.abs(normalizedValue)
|
|
113
|
+
? cumulative - next + normalizedValue
|
|
114
|
+
: normalizedValue - next + cumulative;
|
|
115
|
+
}
|
|
116
|
+
cumulative = next;
|
|
117
|
+
const directEnd = cursor + value / total * span;
|
|
118
|
+
const endTheta = index === values.length - 1
|
|
119
|
+
? range[1]
|
|
120
|
+
: normalizedTotal === undefined && Number.isFinite(directEnd)
|
|
121
|
+
? directEnd
|
|
122
|
+
: interpolateNumber(
|
|
123
|
+
range[0],
|
|
124
|
+
range[1],
|
|
125
|
+
(cumulative + cumulativeCorrection) /
|
|
126
|
+
(normalizedTotal?.total ?? total)
|
|
127
|
+
);
|
|
128
|
+
if (!Number.isFinite(endTheta) || endTheta === cursor) {
|
|
129
|
+
throw new RangeError(
|
|
130
|
+
`Arc theta ${labelAt(index)} cannot resolve a distinct finite sector.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const result = { startTheta: cursor, endTheta };
|
|
134
|
+
cursor = endTheta;
|
|
135
|
+
return result;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveProportionalRadii(layer, frame, innerRadiusRatio) {
|
|
140
|
+
if (!Number.isFinite(frame?.availableRadius) || frame.availableRadius < 0) {
|
|
141
|
+
throw new RangeError(`Arc mark "${layer.id}" requires a finite available radius.`);
|
|
64
142
|
}
|
|
65
|
-
return
|
|
143
|
+
return {
|
|
144
|
+
innerRadius: requireFiniteResult(
|
|
145
|
+
frame.availableRadius * innerRadiusRatio,
|
|
146
|
+
`Arc mark "${layer.id}" inner radius`
|
|
147
|
+
),
|
|
148
|
+
outerRadius: frame.availableRadius
|
|
149
|
+
};
|
|
66
150
|
}
|
|
67
151
|
|
|
68
152
|
function proportionalSectors(rows, layer, thetaScale, frame, innerRadiusRatio) {
|
|
@@ -87,14 +171,7 @@ function proportionalSectors(rows, layer, thetaScale, frame, innerRadiusRatio) {
|
|
|
87
171
|
weight: weights[index]
|
|
88
172
|
});
|
|
89
173
|
}
|
|
90
|
-
|
|
91
|
-
throw new RangeError(`Arc mark "${layer.id}" requires a finite available radius.`);
|
|
92
|
-
}
|
|
93
|
-
const innerRadius = requireFiniteResult(
|
|
94
|
-
frame.availableRadius * innerRadiusRatio,
|
|
95
|
-
`Arc mark "${layer.id}" inner radius`
|
|
96
|
-
);
|
|
97
|
-
let cursor = thetaScale.range[0];
|
|
174
|
+
const radii = resolveProportionalRadii(layer, frame, innerRadiusRatio);
|
|
98
175
|
const sectors = [];
|
|
99
176
|
const positiveValues = thetaScale.domain.filter(value =>
|
|
100
177
|
groups.get(value).some(item => item.weight > 0)
|
|
@@ -103,48 +180,15 @@ function proportionalSectors(rows, layer, thetaScale, frame, innerRadiusRatio) {
|
|
|
103
180
|
groups.get(value).map(item => item.weight),
|
|
104
181
|
`Arc theta group "${String(value)}" aggregate`
|
|
105
182
|
));
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
`Arc mark "${layer.id}" theta aggregates`
|
|
112
|
-
);
|
|
113
|
-
if (total === 0 || normalizedTotal?.total <= 0) {
|
|
114
|
-
throw new Error(`Arc mark "${layer.id}" has no positive theta weight.`);
|
|
115
|
-
}
|
|
116
|
-
const span = thetaScale.range[1] - thetaScale.range[0];
|
|
117
|
-
let cumulative = 0;
|
|
118
|
-
let cumulativeCorrection = 0;
|
|
183
|
+
const ranges = resolveProportionalRanges(
|
|
184
|
+
aggregateValues,
|
|
185
|
+
thetaScale.range,
|
|
186
|
+
index => `group "${String(positiveValues[index])}"`
|
|
187
|
+
);
|
|
119
188
|
for (const [positiveIndex, value] of positiveValues.entries()) {
|
|
120
189
|
const group = groups.get(value);
|
|
121
190
|
const aggregateValue = aggregateValues[positiveIndex];
|
|
122
|
-
const
|
|
123
|
-
? aggregateValue
|
|
124
|
-
: aggregateValue / normalizedTotal.scale;
|
|
125
|
-
const next = cumulative + normalizedValue;
|
|
126
|
-
if (normalizedTotal !== undefined) {
|
|
127
|
-
cumulativeCorrection += Math.abs(cumulative) >= Math.abs(normalizedValue)
|
|
128
|
-
? cumulative - next + normalizedValue
|
|
129
|
-
: normalizedValue - next + cumulative;
|
|
130
|
-
}
|
|
131
|
-
cumulative = next;
|
|
132
|
-
const directEnd = cursor + aggregateValue / total * span;
|
|
133
|
-
const endTheta = positiveIndex === positiveValues.length - 1
|
|
134
|
-
? thetaScale.range[1]
|
|
135
|
-
: normalizedTotal === undefined && Number.isFinite(directEnd)
|
|
136
|
-
? directEnd
|
|
137
|
-
: interpolateNumber(
|
|
138
|
-
thetaScale.range[0],
|
|
139
|
-
thetaScale.range[1],
|
|
140
|
-
(cumulative + cumulativeCorrection) /
|
|
141
|
-
(normalizedTotal?.total ?? total)
|
|
142
|
-
);
|
|
143
|
-
if (!Number.isFinite(endTheta) || endTheta === cursor) {
|
|
144
|
-
throw new RangeError(
|
|
145
|
-
`Arc theta group "${String(value)}" cannot resolve a distinct finite sector.`
|
|
146
|
-
);
|
|
147
|
-
}
|
|
191
|
+
const { startTheta, endTheta } = ranges[positiveIndex];
|
|
148
192
|
const distinctColors = [...new Set(group.map(item => item.color))];
|
|
149
193
|
if (distinctColors.length > 1) {
|
|
150
194
|
throw new Error(
|
|
@@ -157,17 +201,46 @@ function proportionalSectors(rows, layer, thetaScale, frame, innerRadiusRatio) {
|
|
|
157
201
|
count: group.length,
|
|
158
202
|
aggregateValue,
|
|
159
203
|
color: distinctColors[0],
|
|
160
|
-
startTheta
|
|
204
|
+
startTheta,
|
|
161
205
|
endTheta,
|
|
162
|
-
|
|
163
|
-
outerRadius: frame.availableRadius,
|
|
206
|
+
...radii,
|
|
164
207
|
sourceIndices: group.map(item => item.sourceIndex)
|
|
165
208
|
});
|
|
166
|
-
cursor = endTheta;
|
|
167
209
|
}
|
|
168
210
|
return sectors;
|
|
169
211
|
}
|
|
170
212
|
|
|
213
|
+
function quantitativeSectors(rows, layer, thetaScale, frame, innerRadiusRatio) {
|
|
214
|
+
const theta = layer.encoding.theta;
|
|
215
|
+
if (layer.encoding?.radius !== undefined) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Arc mark "${layer.id}" cannot combine quantitative theta with radius.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const values = readArcThetaValues(rows, theta.field, layer.id);
|
|
221
|
+
const colors = colorValues(rows, layer.encoding?.color);
|
|
222
|
+
const positiveIndices = values
|
|
223
|
+
.map((value, index) => value > 0 ? index : undefined)
|
|
224
|
+
.filter(index => index !== undefined);
|
|
225
|
+
const positiveValues = positiveIndices.map(index => values[index]);
|
|
226
|
+
const ranges = resolveProportionalRanges(
|
|
227
|
+
positiveValues,
|
|
228
|
+
thetaScale.range,
|
|
229
|
+
index => `row ${positiveIndices[index]}`
|
|
230
|
+
);
|
|
231
|
+
const radii = resolveProportionalRadii(layer, frame, innerRadiusRatio);
|
|
232
|
+
return positiveIndices.map((sourceIndex, index) => ({
|
|
233
|
+
key: `${String(values[sourceIndex])}:${sourceIndex}`,
|
|
234
|
+
theta: values[sourceIndex],
|
|
235
|
+
count: 1,
|
|
236
|
+
aggregateValue: values[sourceIndex],
|
|
237
|
+
color: colors[sourceIndex],
|
|
238
|
+
...ranges[index],
|
|
239
|
+
...radii,
|
|
240
|
+
sourceIndices: [sourceIndex]
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
171
244
|
function radialSectors(rows, layer, thetaScale, radiusScale) {
|
|
172
245
|
const theta = layer.encoding.theta;
|
|
173
246
|
const radius = layer.encoding?.radius;
|
|
@@ -223,7 +296,6 @@ export function deriveArcSectors(rows, layer, {
|
|
|
223
296
|
} = {}) {
|
|
224
297
|
if (!Array.isArray(rows)) throw new TypeError("Arc derivation requires rows.");
|
|
225
298
|
const theta = requireArcLayer(layer);
|
|
226
|
-
requireBandScale(thetaScale, `Arc mark "${layer.id}" theta`);
|
|
227
299
|
if (
|
|
228
300
|
!Number.isFinite(innerRadiusRatio) ||
|
|
229
301
|
innerRadiusRatio < 0 ||
|
|
@@ -231,8 +303,27 @@ export function deriveArcSectors(rows, layer, {
|
|
|
231
303
|
) {
|
|
232
304
|
throw new RangeError("Arc innerRadius must be from 0 (inclusive) to 1 (exclusive).");
|
|
233
305
|
}
|
|
234
|
-
const sectors =
|
|
235
|
-
?
|
|
236
|
-
|
|
306
|
+
const sectors = theta.fieldType === "quantitative"
|
|
307
|
+
? quantitativeSectors(
|
|
308
|
+
rows,
|
|
309
|
+
layer,
|
|
310
|
+
requireContinuousThetaScale(thetaScale, `Arc mark "${layer.id}" theta`),
|
|
311
|
+
frame,
|
|
312
|
+
innerRadiusRatio
|
|
313
|
+
)
|
|
314
|
+
: ["count", "sum"].includes(theta.aggregate)
|
|
315
|
+
? proportionalSectors(
|
|
316
|
+
rows,
|
|
317
|
+
layer,
|
|
318
|
+
requireBandScale(thetaScale, `Arc mark "${layer.id}" theta`),
|
|
319
|
+
frame,
|
|
320
|
+
innerRadiusRatio
|
|
321
|
+
)
|
|
322
|
+
: radialSectors(
|
|
323
|
+
rows,
|
|
324
|
+
layer,
|
|
325
|
+
requireBandScale(thetaScale, `Arc mark "${layer.id}" theta`),
|
|
326
|
+
radiusScale
|
|
327
|
+
);
|
|
237
328
|
return cloneAndFreeze({ sectors });
|
|
238
329
|
}
|
|
@@ -16,7 +16,7 @@ export const POSITION_FIELD_COMPATIBILITY = Object.freeze({
|
|
|
16
16
|
y: Object.freeze(["quantitative", "temporal"])
|
|
17
17
|
}),
|
|
18
18
|
arc: Object.freeze({
|
|
19
|
-
theta: Object.freeze(["ordinal", "nominal"]),
|
|
19
|
+
theta: Object.freeze(["quantitative", "ordinal", "nominal"]),
|
|
20
20
|
radius: Object.freeze(["quantitative"])
|
|
21
21
|
}),
|
|
22
22
|
rule: Object.freeze({
|
package/src/grammar/rules.js
CHANGED
|
@@ -278,3 +278,26 @@ export function mapOrdinalPositionValues(values, scale) {
|
|
|
278
278
|
);
|
|
279
279
|
}));
|
|
280
280
|
}
|
|
281
|
+
|
|
282
|
+
export function mapOrdinalOffsetValues(values, scale) {
|
|
283
|
+
const indices = new Map(scale.domain.map((value, index) => [value, index]));
|
|
284
|
+
const direction = Math.sign(scale.step) || 1;
|
|
285
|
+
const denominator = Math.max(
|
|
286
|
+
1,
|
|
287
|
+
scale.domain.length - scale.paddingInner + scale.paddingOuter * 2
|
|
288
|
+
);
|
|
289
|
+
return cloneAndFreeze(values.map(value => {
|
|
290
|
+
const index = indices.get(value);
|
|
291
|
+
if (index === undefined) {
|
|
292
|
+
throw new Error(`Value "${value}" is outside the ordinal domain.`);
|
|
293
|
+
}
|
|
294
|
+
const direct = scale.start + index * scale.step +
|
|
295
|
+
direction * scale.bandwidth / 2;
|
|
296
|
+
return finitePosition(
|
|
297
|
+
direct,
|
|
298
|
+
scale,
|
|
299
|
+
(scale.paddingOuter + index + (1 - scale.paddingInner) / 2) /
|
|
300
|
+
denominator
|
|
301
|
+
);
|
|
302
|
+
}));
|
|
303
|
+
}
|
package/src/layout/facets.js
CHANGED
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
|
|
10
10
|
export const DEFAULT_FACET_LEGEND_GAP = 18;
|
|
11
11
|
export const DEFAULT_FACET_LEGEND_WIDTH = 132;
|
|
12
|
+
export const DEFAULT_FACET_LEGEND_HEIGHT = 0;
|
|
13
|
+
|
|
14
|
+
const FACET_LEGEND_POSITIONS = Object.freeze([
|
|
15
|
+
"right", "left", "top", "bottom"
|
|
16
|
+
]);
|
|
12
17
|
|
|
13
18
|
function resolveColumns(columns, count) {
|
|
14
19
|
const value = columns ?? count;
|
|
@@ -33,7 +38,9 @@ export function resolveFacetLayout({
|
|
|
33
38
|
titleHeight = 0,
|
|
34
39
|
sharedLegend = false,
|
|
35
40
|
sharedLegendGap = DEFAULT_FACET_LEGEND_GAP,
|
|
36
|
-
sharedLegendWidth = DEFAULT_FACET_LEGEND_WIDTH
|
|
41
|
+
sharedLegendWidth = DEFAULT_FACET_LEGEND_WIDTH,
|
|
42
|
+
sharedLegendHeight = DEFAULT_FACET_LEGEND_HEIGHT,
|
|
43
|
+
sharedLegendPosition = "right"
|
|
37
44
|
} = {}) {
|
|
38
45
|
const values = children?.map(child => child?.value);
|
|
39
46
|
const resolvedChildren = normalizeCompositionChildren(children?.map(
|
|
@@ -58,6 +65,20 @@ export function resolveFacetLayout({
|
|
|
58
65
|
sharedLegendWidth,
|
|
59
66
|
"Facet shared legend width"
|
|
60
67
|
);
|
|
68
|
+
const legendHeight = validateCompositionSpacing(
|
|
69
|
+
sharedLegendHeight,
|
|
70
|
+
"Facet shared legend height"
|
|
71
|
+
);
|
|
72
|
+
if (!FACET_LEGEND_POSITIONS.includes(sharedLegendPosition)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Unknown facet shared legend position "${sharedLegendPosition}".`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const sideLegend = ["right", "left"].includes(sharedLegendPosition);
|
|
78
|
+
const beforeGrid = ["left", "top"].includes(sharedLegendPosition);
|
|
79
|
+
const legendLane = sharedLegend
|
|
80
|
+
? legendGap + (sideLegend ? legendWidth : legendHeight)
|
|
81
|
+
: 0;
|
|
61
82
|
const rowCount = Math.ceil(resolvedChildren.length / resolvedColumns);
|
|
62
83
|
const columnWidths = Array(resolvedColumns).fill(-Infinity);
|
|
63
84
|
const rowHeights = Array(rowCount).fill(-Infinity);
|
|
@@ -83,14 +104,16 @@ export function resolveFacetLayout({
|
|
|
83
104
|
value: values[index],
|
|
84
105
|
column,
|
|
85
106
|
row,
|
|
86
|
-
x: columnStarts[column] +
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
x: columnStarts[column] + (beforeGrid && sideLegend ? legendLane : 0) +
|
|
108
|
+
alignedOffset(
|
|
109
|
+
columnWidths[column] - child.width,
|
|
110
|
+
resolvedAlign
|
|
111
|
+
),
|
|
112
|
+
y: rowStarts[row] + (beforeGrid && !sideLegend ? legendLane : 0) +
|
|
113
|
+
alignedOffset(
|
|
114
|
+
rowHeights[row] - child.height,
|
|
115
|
+
resolvedAlign
|
|
116
|
+
),
|
|
94
117
|
width: child.width,
|
|
95
118
|
height: child.height
|
|
96
119
|
};
|
|
@@ -111,16 +134,22 @@ export function resolveFacetLayout({
|
|
|
111
134
|
padding: resolvedPadding,
|
|
112
135
|
titleHeight: resolvedTitleHeight,
|
|
113
136
|
gridWidth,
|
|
114
|
-
width: gridWidth + (
|
|
115
|
-
|
|
116
|
-
: 0),
|
|
117
|
-
height: gridHeight,
|
|
137
|
+
width: gridWidth + (sideLegend ? legendLane : 0),
|
|
138
|
+
height: gridHeight + (sideLegend ? 0 : legendLane),
|
|
118
139
|
children: placements,
|
|
119
140
|
...(sharedLegend ? {
|
|
120
141
|
legend: {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
142
|
+
position: sharedLegendPosition,
|
|
143
|
+
x: sharedLegendPosition === "right"
|
|
144
|
+
? gridWidth + legendGap
|
|
145
|
+
: 0,
|
|
146
|
+
y: sharedLegendPosition === "bottom"
|
|
147
|
+
? gridHeight + legendGap
|
|
148
|
+
: sharedLegendPosition === "top"
|
|
149
|
+
? resolvedTitleHeight
|
|
150
|
+
: resolvedTitleHeight + resolvedPadding.top + 30,
|
|
151
|
+
width: legendWidth,
|
|
152
|
+
height: legendHeight
|
|
124
153
|
}
|
|
125
154
|
} : {})
|
|
126
155
|
});
|
|
@@ -69,7 +69,13 @@ export function resolveLegacyCategoricalLegend(program) {
|
|
|
69
69
|
return {
|
|
70
70
|
mode: "legacyCategorical",
|
|
71
71
|
compatibility,
|
|
72
|
-
reservation: {
|
|
72
|
+
reservation: {
|
|
73
|
+
position: "right",
|
|
74
|
+
align: "center",
|
|
75
|
+
gap: 18,
|
|
76
|
+
width: 132,
|
|
77
|
+
height: 0
|
|
78
|
+
},
|
|
73
79
|
legend: {
|
|
74
80
|
field: config.field,
|
|
75
81
|
mark: encodings[0].layer.mark.type,
|
|
@@ -33,12 +33,27 @@ function removeNamespacedGraphics(program, namespace, ids) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function legendTranslation(prepared, plot, layout) {
|
|
36
|
+
const position = prepared.reservation.position;
|
|
37
|
+
if (["top", "bottom"].includes(position)) {
|
|
38
|
+
const width = prepared.bounds.right - prepared.bounds.left;
|
|
39
|
+
const left = prepared.reservation.align === "left"
|
|
40
|
+
? plot.x
|
|
41
|
+
: prepared.reservation.align === "right"
|
|
42
|
+
? plot.x + plot.width - width
|
|
43
|
+
: plot.x + (plot.width - width) / 2;
|
|
44
|
+
return {
|
|
45
|
+
x: left - prepared.bounds.left,
|
|
46
|
+
y: layout.legend.y - prepared.bounds.top
|
|
47
|
+
};
|
|
48
|
+
}
|
|
36
49
|
const strips = prepared.source.graphicSpec.objects.colorGradientStrips;
|
|
37
50
|
if (strips?.items?.[0] !== undefined) {
|
|
38
51
|
const properties = strips.items[0].properties;
|
|
39
52
|
const length = prepared.source.guideConfigs.legend.gradient.gradient.length;
|
|
40
53
|
return {
|
|
41
|
-
x: layout.legend.x -
|
|
54
|
+
x: layout.legend.x - (position === "left"
|
|
55
|
+
? prepared.bounds.left
|
|
56
|
+
: properties.x),
|
|
42
57
|
y: plot.y + (plot.height - length) / 2 - properties.y
|
|
43
58
|
};
|
|
44
59
|
}
|
|
@@ -149,6 +149,27 @@ function compatibleLegendChildren(program, source, inferred) {
|
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
function promotedLegendPlacement(source, kinds) {
|
|
153
|
+
const positioned = kinds.map(kind => source.guideConfigs.legend[kind])
|
|
154
|
+
.filter(config => config.position !== undefined);
|
|
155
|
+
const positions = new Set(positioned.map(config => config.position));
|
|
156
|
+
if (positions.size > 1) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"Shared facet legends must use one common edge position."
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const aligns = new Set(positioned.map(config => config.align ?? "center"));
|
|
162
|
+
if (aligns.size > 1) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
"Shared facet legends must use one common alignment."
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
position: positioned[0]?.position ?? "right",
|
|
169
|
+
align: positioned[0]?.align ?? "center"
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
152
173
|
export function prepareSharedFacetLegend(program) {
|
|
153
174
|
if (program.compositionSpec.facet.guides.legend !== "shared") {
|
|
154
175
|
return undefined;
|
|
@@ -176,6 +197,8 @@ export function prepareSharedFacetLegend(program) {
|
|
|
176
197
|
throw new Error("Shared facet legend has no measurable concrete bounds.");
|
|
177
198
|
}
|
|
178
199
|
const gradient = kinds.includes("gradient");
|
|
200
|
+
const placement = promotedLegendPlacement(source, kinds);
|
|
201
|
+
const width = Math.ceil(bounds.right - bounds.left);
|
|
179
202
|
return {
|
|
180
203
|
mode: "promoted",
|
|
181
204
|
source,
|
|
@@ -185,8 +208,10 @@ export function prepareSharedFacetLegend(program) {
|
|
|
185
208
|
bounds,
|
|
186
209
|
compatibility,
|
|
187
210
|
reservation: {
|
|
211
|
+
...placement,
|
|
188
212
|
gap: gradient ? 24 : 18,
|
|
189
|
-
width: gradient ? 112 :
|
|
213
|
+
width: Math.max(gradient ? 112 : 132, width),
|
|
214
|
+
height: Math.ceil(bounds.bottom - bounds.top)
|
|
190
215
|
}
|
|
191
216
|
};
|
|
192
217
|
}
|
|
@@ -175,7 +175,9 @@ export function resolveFacetProgramLayout(program, preparedLegend) {
|
|
|
175
175
|
sharedLegend: spec.facet.guides.legend === "shared",
|
|
176
176
|
...(preparedLegend === undefined ? {} : {
|
|
177
177
|
sharedLegendGap: preparedLegend.reservation.gap,
|
|
178
|
-
sharedLegendWidth: preparedLegend.reservation.width
|
|
178
|
+
sharedLegendWidth: preparedLegend.reservation.width,
|
|
179
|
+
sharedLegendHeight: preparedLegend.reservation.height,
|
|
180
|
+
sharedLegendPosition: preparedLegend.reservation.position
|
|
179
181
|
})
|
|
180
182
|
});
|
|
181
183
|
const plots = spec.children.map(id => ({
|
|
@@ -108,11 +108,19 @@ export function isIntentionallyEmptyArea(program, layer) {
|
|
|
108
108
|
export function canMaterializeArc(_program, layer) {
|
|
109
109
|
if (
|
|
110
110
|
layer.mark?.type !== "arc" ||
|
|
111
|
-
layer.encoding?.theta?.scale === undefined
|
|
112
|
-
!["nominal", "ordinal"].includes(layer.encoding.theta.fieldType)
|
|
111
|
+
layer.encoding?.theta?.scale === undefined
|
|
113
112
|
) {
|
|
114
113
|
return false;
|
|
115
114
|
}
|
|
115
|
+
if (layer.encoding.theta.fieldType === "quantitative") {
|
|
116
|
+
return (
|
|
117
|
+
layer.encoding.theta.aggregate === undefined &&
|
|
118
|
+
layer.encoding?.radius === undefined
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (!["nominal", "ordinal"].includes(layer.encoding.theta.fieldType)) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
116
124
|
if (["count", "sum"].includes(layer.encoding.theta.aggregate)) {
|
|
117
125
|
return layer.encoding?.radius === undefined;
|
|
118
126
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
POSITION_CHANNELS,
|
|
3
|
+
POSITION_ENCODING_CHANNELS
|
|
4
|
+
} from "../../core/vocabulary.js";
|
|
2
5
|
import { getMarkMaterializationPolicy } from "./policies.js";
|
|
3
6
|
|
|
4
7
|
export {
|
|
@@ -75,7 +78,7 @@ export function getPositionEncodingMaterializationSteps(program, layer, scaleId)
|
|
|
75
78
|
export function getScaleConsumerMaterializationMode(layer, channel) {
|
|
76
79
|
const policy = getMarkMaterializationPolicy(layer);
|
|
77
80
|
if (policy === undefined) return "direct";
|
|
78
|
-
if (
|
|
81
|
+
if (POSITION_ENCODING_CHANNELS.includes(channel)) {
|
|
79
82
|
return policy.scaleApplication.position ?? policy.scaleApplication.default;
|
|
80
83
|
}
|
|
81
84
|
if (policy.scaleApplication.deferredChannels?.includes(channel)) {
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import { POSITION_CHANNELS } from "../core/vocabulary.js";
|
|
2
1
|
import {
|
|
2
|
+
OFFSET_POSITION_CHANNELS,
|
|
3
|
+
POSITION_CHANNELS
|
|
4
|
+
} from "../core/vocabulary.js";
|
|
5
|
+
import {
|
|
6
|
+
mapOrdinalOffsetValues,
|
|
3
7
|
mapOrdinalPositionValues,
|
|
4
8
|
readNominalField,
|
|
5
9
|
readQuantitativeField,
|
|
6
10
|
readScaleField,
|
|
7
11
|
readTemporalField
|
|
8
12
|
} from "../grammar/scales/index.js";
|
|
13
|
+
import {
|
|
14
|
+
finiteMidpoint,
|
|
15
|
+
requireFiniteResult
|
|
16
|
+
} from "../grammar/numeric.js";
|
|
9
17
|
import { mapScaleConsumerValues } from "./scales/map.js";
|
|
10
18
|
|
|
11
19
|
export function resolveRowEncodingValues(program, layer, dataset, channel) {
|
|
@@ -27,7 +35,55 @@ export function resolveRowEncodingValues(program, layer, dataset, channel) {
|
|
|
27
35
|
: encoding.fieldType === "temporal"
|
|
28
36
|
? readTemporalField(dataset.values, encoding.field)
|
|
29
37
|
: readQuantitativeField(dataset.values, encoding.field);
|
|
38
|
+
if (categorical && OFFSET_POSITION_CHANNELS.includes(channel)) {
|
|
39
|
+
return mapOrdinalOffsetValues(values, scale);
|
|
40
|
+
}
|
|
30
41
|
return categorical && POSITION_CHANNELS.includes(channel)
|
|
31
42
|
? mapOrdinalPositionValues(values, scale)
|
|
32
43
|
: mapScaleConsumerValues(values, scale, channel);
|
|
33
44
|
}
|
|
45
|
+
|
|
46
|
+
export function applyOffsetPositionValues({
|
|
47
|
+
base,
|
|
48
|
+
offset,
|
|
49
|
+
parentScale,
|
|
50
|
+
offsetScale,
|
|
51
|
+
channel
|
|
52
|
+
}) {
|
|
53
|
+
if (base.length !== offset.length) {
|
|
54
|
+
throw new Error(`${channel} offset values must match the base position grain.`);
|
|
55
|
+
}
|
|
56
|
+
const midpoint = finiteMidpoint(offsetScale.range[0], offsetScale.range[1]);
|
|
57
|
+
const direction = Math.sign(parentScale.step) ||
|
|
58
|
+
Math.sign(parentScale.range[1] - parentScale.range[0]) || 1;
|
|
59
|
+
return Object.freeze(base.map((value, index) => {
|
|
60
|
+
if (!Number.isFinite(value) || !Number.isFinite(offset[index])) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return requireFiniteResult(
|
|
64
|
+
value + direction * (offset[index] - midpoint),
|
|
65
|
+
`${channel} offset position`
|
|
66
|
+
);
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveRowPositionValues(program, layer, dataset, channel) {
|
|
71
|
+
const base = resolveRowEncodingValues(program, layer, dataset, channel);
|
|
72
|
+
if (base === undefined) return undefined;
|
|
73
|
+
const offsetChannel = channel === "x" ? "xOffset" : "yOffset";
|
|
74
|
+
const offsetEncoding = layer.encoding?.[offsetChannel];
|
|
75
|
+
if (offsetEncoding === undefined) return base;
|
|
76
|
+
const offset = resolveRowEncodingValues(
|
|
77
|
+
program,
|
|
78
|
+
layer,
|
|
79
|
+
dataset,
|
|
80
|
+
offsetChannel
|
|
81
|
+
);
|
|
82
|
+
return applyOffsetPositionValues({
|
|
83
|
+
base,
|
|
84
|
+
offset,
|
|
85
|
+
parentScale: program.resolvedScales[layer.encoding[channel].scale],
|
|
86
|
+
offsetScale: program.resolvedScales[offsetEncoding.scale],
|
|
87
|
+
channel
|
|
88
|
+
});
|
|
89
|
+
}
|