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.
Files changed (32) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +2 -1
  3. package/knowledge/action-cards.json +1 -1
  4. package/knowledge/intent-taxonomy.json +1 -1
  5. package/knowledge/mcp-resources.json +1 -1
  6. package/knowledge/task-resolver.js +1 -1
  7. package/package.json +1 -1
  8. package/src/actions/encodings/offset.js +33 -21
  9. package/src/actions/encodings/position/apply.js +30 -0
  10. package/src/actions/encodings/position/policies/arc.js +23 -2
  11. package/src/actions/encodings/position/resolve.js +12 -2
  12. package/src/actions/errorBars/create.js +138 -7
  13. package/src/actions/errorBars/edit.js +10 -0
  14. package/src/actions/marks/point/materialize.js +33 -13
  15. package/src/actions/marks/rule/actions.js +26 -1
  16. package/src/core/vocabulary.js +2 -2
  17. package/src/grammar/arcs.js +156 -65
  18. package/src/grammar/positionCompatibility.js +1 -1
  19. package/src/grammar/rules.js +2 -0
  20. package/src/grammar/scales/ordinal.js +23 -0
  21. package/src/layout/facets.js +45 -16
  22. package/src/materialization/facetGuides/legacyCategorical.js +7 -1
  23. package/src/materialization/facetGuides/placement.js +16 -1
  24. package/src/materialization/facetGuides/preparation.js +26 -1
  25. package/src/materialization/facets.js +3 -1
  26. package/src/materialization/marks/capabilities.js +10 -2
  27. package/src/materialization/marks/index.js +5 -2
  28. package/src/materialization/rowEncoding.js +57 -1
  29. package/src/materialization/scales/policies/bar.js +0 -43
  30. package/src/materialization/scales/policies/offset.js +54 -0
  31. package/src/materialization/scales/resolve.js +2 -4
  32. package/types/program.d.ts +18 -0
@@ -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 (theta === undefined || !["nominal", "ordinal"].includes(theta.fieldType)) {
21
- throw new Error(`Arc mark "${layer.id}" requires categorical theta.`);
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
- export function readArcThetaWeights(rows, field, layerId = "arc") {
49
- if (!Array.isArray(rows)) throw new TypeError("Arc theta weights require rows.");
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("Arc theta weight must be a non-empty string.");
69
+ throw new TypeError(`Arc theta ${role} must be a non-empty string.`);
52
70
  }
53
- const weights = rows.map((row, index) => {
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 weight field "${field}" must contain non-negative finite numbers at row ${index}.`
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 (weights.every(value => value === 0)) {
63
- throw new Error(`Arc mark "${layerId}" theta weights must have a positive total.`);
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 cloneAndFreeze(weights);
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
- if (!Number.isFinite(frame?.availableRadius) || frame.availableRadius < 0) {
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 total = aggregateValues.reduce((sum, value) => sum + value, 0);
107
- const normalizedTotal = Number.isFinite(total)
108
- ? undefined
109
- : normalizedFiniteSum(
110
- aggregateValues,
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 normalizedValue = normalizedTotal === undefined
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: cursor,
204
+ startTheta,
161
205
  endTheta,
162
- innerRadius,
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 = ["count", "sum"].includes(theta.aggregate)
235
- ? proportionalSectors(rows, layer, thetaScale, frame, innerRadiusRatio)
236
- : radialSectors(rows, layer, thetaScale, radiusScale);
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({
@@ -11,6 +11,8 @@ import {
11
11
  const ENDPOINT_CHANNELS = Object.freeze(["x", "y", "x2", "y2"]);
12
12
  const VALUE_CHANNELS = Object.freeze([
13
13
  ...ENDPOINT_CHANNELS,
14
+ "xOffset",
15
+ "yOffset",
14
16
  "strokeDash",
15
17
  "strokeWidth",
16
18
  "opacity"
@@ -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
+ }
@@ -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] + alignedOffset(
87
- columnWidths[column] - child.width,
88
- resolvedAlign
89
- ),
90
- y: rowStarts[row] + alignedOffset(
91
- rowHeights[row] - child.height,
92
- resolvedAlign
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 + (sharedLegend
115
- ? legendGap + legendWidth
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
- x: gridWidth + legendGap,
122
- y: resolvedTitleHeight + resolvedPadding.top + 30,
123
- width: legendWidth
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: { gap: 18, width: 132 },
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 - properties.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 : Math.max(132, Math.ceil(bounds.right - bounds.left))
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 { POSITION_CHANNELS } from "../../core/vocabulary.js";
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 (POSITION_CHANNELS.includes(channel)) {
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
+ }