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.
Files changed (155) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +6 -2
  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/package.json +1 -1
  7. package/src/actions/canvas/fitting.js +6 -2
  8. package/src/actions/charts/heatmap.js +4 -3
  9. package/src/actions/data/derived.js +11 -1
  10. package/src/actions/data/edit.js +2 -0
  11. package/src/actions/data/index.js +4 -0
  12. package/src/actions/data/regression.js +24 -16
  13. package/src/actions/data/regressionStatistics.js +73 -0
  14. package/src/actions/data/shared.js +5 -2
  15. package/src/actions/distributions/revision.js +24 -18
  16. package/src/actions/encodings/remove.js +11 -6
  17. package/src/actions/encodings/shared.js +26 -24
  18. package/src/actions/facets/actions.js +18 -7
  19. package/src/actions/facets/guides.js +2 -1
  20. package/src/actions/guides/axes/axes.js +30 -11
  21. package/src/actions/guides/axes/axis.js +58 -8
  22. package/src/actions/guides/axes/components.js +13 -13
  23. package/src/actions/guides/axes/edit.js +36 -24
  24. package/src/actions/guides/axes/labels.js +37 -27
  25. package/src/actions/guides/axes/lines.js +22 -20
  26. package/src/actions/guides/axes/remove.js +10 -6
  27. package/src/actions/guides/axes/tickGroups.js +8 -7
  28. package/src/actions/guides/axes/ticks.js +31 -22
  29. package/src/actions/guides/axes/titles.js +53 -60
  30. package/src/actions/guides/facadeAxes.js +33 -24
  31. package/src/actions/guides/facadeLegend.js +3 -3
  32. package/src/actions/guides/grids/grid.js +1 -1
  33. package/src/actions/guides/grids/resolve.js +48 -51
  34. package/src/actions/guides/legends/blocks.js +43 -19
  35. package/src/actions/guides/legends/categorical/actions.js +30 -7
  36. package/src/actions/guides/legends/categorical/components.js +3 -10
  37. package/src/actions/guides/legends/categorical/index.js +2 -0
  38. package/src/actions/guides/legends/categorical/layout.js +9 -13
  39. package/src/actions/guides/legends/categorical/options.js +8 -2
  40. package/src/actions/guides/legends/categorical/recipes.js +7 -3
  41. package/src/actions/guides/legends/categorical/resolve.js +9 -2
  42. package/src/actions/guides/legends/categorical/symbols.js +22 -8
  43. package/src/actions/guides/legends/colorSize.js +110 -0
  44. package/src/actions/guides/legends/combine.js +37 -0
  45. package/src/actions/guides/legends/continuous/colorSymbols.js +1 -1
  46. package/src/actions/guides/legends/continuous/common.js +1 -1
  47. package/src/actions/guides/legends/continuous/gradient.js +27 -13
  48. package/src/actions/guides/legends/continuous/opacity.js +16 -11
  49. package/src/actions/guides/legends/creation.js +2 -0
  50. package/src/actions/guides/legends/edit.js +1 -1
  51. package/src/actions/guides/legends/focused.js +6 -5
  52. package/src/actions/guides/legends/lane.js +1 -1
  53. package/src/actions/guides/legends/lifecycle.js +2 -1
  54. package/src/actions/guides/legends/remove.js +6 -3
  55. package/src/actions/guides/legends/size.js +16 -6
  56. package/src/actions/guides/legends/strokeWidth.js +2 -0
  57. package/src/actions/guides/legends/target.js +2 -79
  58. package/src/actions/guides/legends/transition.js +24 -5
  59. package/src/actions/guides/tickValues.js +8 -5
  60. package/src/actions/marks/bar/edit.js +23 -12
  61. package/src/actions/marks/bar/materialize.js +6 -1
  62. package/src/actions/marks/point/materialize.js +5 -1
  63. package/src/actions/marks/remove.js +10 -10
  64. package/src/actions/primitives/createGraphics.js +3 -2
  65. package/src/actions/primitives/editGraphics.js +15 -11
  66. package/src/actions/primitives/graphicProperties.js +1 -6
  67. package/src/actions/primitives/semanticAction.js +27 -1
  68. package/src/actions/primitives/semanticValidation/guide.js +11 -1
  69. package/src/actions/primitives/semanticValidation/layer.js +4 -0
  70. package/src/actions/regression/edit.js +15 -3
  71. package/src/actions/scales/channels.js +2 -2
  72. package/src/actions/scales/create.js +3 -3
  73. package/src/actions/scales/definitions.js +1 -1
  74. package/src/actions/scales/edit.js +1 -1
  75. package/src/actions/scales/editPolicy.js +3 -0
  76. package/src/actions/scales/patch.js +1 -1
  77. package/src/actions/scales/quantitativeColor.js +8 -3
  78. package/src/actions/theme/reconcile.js +50 -23
  79. package/src/core/compositionState.js +2 -2
  80. package/src/core/graphicProperties.js +1 -0
  81. package/src/core/programState.js +4 -0
  82. package/src/core/resourceReferences.js +2 -2
  83. package/src/core/validation.js +9 -0
  84. package/src/core/vocabulary.js +3 -0
  85. package/src/grammar/axisTextPlacement.js +1 -0
  86. package/src/grammar/colorSizeLegend.js +39 -0
  87. package/src/grammar/computed.js +23 -1
  88. package/src/grammar/datasetSchema.js +10 -2
  89. package/src/grammar/displayLabels.js +8 -0
  90. package/src/grammar/facets/guides.js +54 -31
  91. package/src/grammar/facets/legendIntent.js +65 -0
  92. package/src/grammar/guideDash.js +1 -0
  93. package/src/grammar/legendBlocks.js +1 -0
  94. package/src/grammar/legendBorder.js +9 -0
  95. package/src/grammar/legendClip.js +19 -0
  96. package/src/grammar/legendCombinations.js +45 -0
  97. package/src/grammar/legendGradient.js +9 -0
  98. package/src/grammar/legendGroups.js +1 -1
  99. package/src/grammar/legendOpacity.js +6 -0
  100. package/src/grammar/pointShapes.js +16 -2
  101. package/src/grammar/regression/derive.js +9 -1
  102. package/src/grammar/regression/models.js +60 -0
  103. package/src/grammar/regression/parameters.js +9 -1
  104. package/src/grammar/regression/statistics.js +64 -0
  105. package/src/grammar/scales/color.js +1 -1
  106. package/src/grammar/scales/colorConsumers.js +1 -1
  107. package/src/grammar/scales/definition.js +1 -1
  108. package/src/grammar/scales/types.js +1 -1
  109. package/src/grammar/schemas/concreteGraphic.js +3 -0
  110. package/src/grammar/schemas/graphic.js +3 -0
  111. package/src/grammar/schemas/graphicBounds.js +31 -7
  112. package/src/grammar/schemas/semanticPath.js +17 -2
  113. package/src/grammar/summary.js +1 -1
  114. package/src/grammar/timeUnit.js +6 -4
  115. package/src/grammar/transformTopology.js +1 -0
  116. package/src/grammar/transforms.js +11 -1
  117. package/src/inspectionDescriptors.js +1 -1
  118. package/src/layout/guideCollisions.js +9 -1
  119. package/src/layout/legendGroup.js +1 -1
  120. package/src/materialization/compositionSnapshot.js +2 -7
  121. package/src/materialization/coordinateBounds.js +17 -4
  122. package/src/materialization/facetGuides/edit.js +30 -0
  123. package/src/materialization/facetGuides/layout.js +1 -1
  124. package/src/materialization/facetGuides/owner.js +125 -0
  125. package/src/materialization/facetGuides/ownerLayout.js +71 -0
  126. package/src/materialization/facetGuides/placement.js +52 -6
  127. package/src/materialization/facetGuides/preparation.js +7 -0
  128. package/src/materialization/facets.js +1 -1
  129. package/src/materialization/graphicHierarchy.js +5 -1
  130. package/src/materialization/graphicTranslation.js +73 -0
  131. package/src/materialization/guides/layout.js +1 -1
  132. package/src/materialization/guides/resources.js +1 -1
  133. package/src/materialization/legendBorder.js +28 -0
  134. package/src/materialization/legendClip.js +76 -0
  135. package/src/materialization/legendGroups.js +1 -1
  136. package/src/materialization/legends.js +1 -1
  137. package/src/materialization/scaleGuideDependencies.js +13 -14
  138. package/src/materialization/scales/map.js +3 -0
  139. package/src/materialization/scales/resolve.js +2 -0
  140. package/src/materialization/typography.js +10 -2
  141. package/src/persistence/validation.js +174 -1
  142. package/src/persistence.js +1 -1
  143. package/src/renderers/canvas/index.js +20 -10
  144. package/src/renderers/canvas/isolated.js +166 -0
  145. package/src/renderers/canvas/limits.js +3 -0
  146. package/src/renderers/canvas/rect.js +1 -1
  147. package/src/renderers/pdf.js +2 -2
  148. package/src/renderers/png.js +2 -2
  149. package/src/renderers/svg.js +10 -3
  150. package/src/selectors/axes.js +1 -0
  151. package/src/selectors/graphicCapacity.js +38 -0
  152. package/src/version.js +1 -1
  153. package/types/basic.d.ts +2 -0
  154. package/types/index.d.ts +5 -0
  155. package/types/program.d.ts +206 -61
@@ -1,4 +1,7 @@
1
+ import { cartesianAxisOwners } from "../../../selectors/axes.js";
2
+ import { axisGraphicIds } from "../../../materialization/guides/resources.js";
1
3
  import { isNominalValue } from "../../../grammar/scales/fields.js";
4
+ import { patchGuideDash, validateGuideDash, normalizeGuideDashMap, resolveGuideDashes } from "../../../grammar/guideDash.js";
2
5
  import { isSourceOwnedText } from "../../../grammar/text.js";
3
6
  import { validateUserId } from "../../../core/identifiers.js";
4
7
  import {
@@ -27,10 +30,10 @@ import {
27
30
  } from "../../../grammar/schemas/graphicTree.js";
28
31
 
29
32
  const GRID_OPTIONS = Object.freeze([
30
- "scale", "coordinate", "count", "values", "color", "lineWidth", "strokeDash", "zIndex", "bandPosition", "alongOffset"
33
+ "scale", "coordinate", "count", "values", "color", "lineWidth", "strokeDash", "strokeDashMap", "zIndex", "bandPosition", "alongOffset"
31
34
  ]);
32
35
  const GRID_EDIT_OPTIONS = Object.freeze([
33
- "count", "values", "color", "lineWidth", "strokeDash", "zIndex", "bandPosition", "alongOffset"
36
+ "count", "values", "color", "lineWidth", "strokeDash", "strokeDashMap", "zIndex", "bandPosition", "alongOffset"
34
37
  ]);
35
38
  const DEFAULT_STYLE = Object.freeze({
36
39
  color: DEFAULT_COLORS.grid,
@@ -49,19 +52,11 @@ export function gridNames(direction) {
49
52
  }
50
53
 
51
54
  function validateStrokeDash(value) {
52
- if (
53
- !Array.isArray(value) ||
54
- value.length % 2 !== 0 ||
55
- !value.every(item => Number.isFinite(item) && item >= 0)
56
- ) {
57
- throw new TypeError(
58
- "Grid strokeDash must be an even-length array of non-negative finite numbers."
59
- );
60
- }
61
- return value;
55
+ return validateGuideDash(value, "Grid strokeDash");
62
56
  }
63
57
 
64
58
  export function validateGridCreateArgs(args, operation) {
59
+ if (Object.hasOwn(args, "strokeDashMap")) normalizeGuideDashMap(args.strokeDashMap);
65
60
  validateOptionObject(args, GRID_OPTIONS, operation);
66
61
  if (Array.isArray(args.values)) {
67
62
  validateGeneratedItemLimit(args.values.length, "Grid value count");
@@ -95,6 +90,7 @@ export function validateGridCreateArgs(args, operation) {
95
90
  }
96
91
 
97
92
  export function validateGridEditArgs(args, operation) {
93
+ if (Object.hasOwn(args, "strokeDashMap")) normalizeGuideDashMap(args.strokeDashMap);
98
94
  validateOptionObject(args, GRID_EDIT_OPTIONS, operation, {
99
95
  allowEmpty: false
100
96
  });
@@ -138,39 +134,25 @@ export function resolveGridResources(program, direction, args) {
138
134
  let layers = program.semanticSpec.layers.filter(
139
135
  layer => !isSourceOwnedText(layer) && layer.encoding?.[channel]?.scale !== undefined
140
136
  );
141
- if (layers.length === 0) throw new Error(`${direction} grid requires a ${channel} encoding.`);
142
- const requestedCoordinate = args.coordinate === undefined
143
- ? undefined
144
- : validateUserId(args.coordinate, "Grid coordinate id");
145
- const requestedScale = args.scale === undefined
146
- ? undefined
147
- : validateUserId(args.scale, "Grid scale id");
148
- if (requestedCoordinate !== undefined) {
149
- layers = layers.filter(layer => layer.coordinate === requestedCoordinate);
150
- }
151
- if (requestedScale !== undefined) {
152
- layers = layers.filter(layer => layer.encoding[channel].scale === requestedScale);
153
- }
154
- if (layers.length === 0) throw new Error(`${direction} grid found no matching encoded layers.`);
155
- const coordinateIds = [...new Set(layers.map(layer => layer.coordinate).filter(Boolean))];
156
- if (layers.some(layer => layer.coordinate === undefined)) {
157
- throw new Error(`${direction} grid requires a stored coordinate.`);
158
- }
159
- if (coordinateIds.length === 0) throw new Error(`Unknown grid coordinate "${requestedCoordinate}".`);
160
- if (coordinateIds.length > 1) {
161
- throw new Error(`${direction} grid found multiple coordinates; provide coordinate explicitly.`);
162
- }
137
+ const requestedCoordinate = args.coordinate === undefined ? undefined : validateUserId(args.coordinate, "Grid coordinate id");
138
+ const requestedScale = args.scale === undefined ? undefined : validateUserId(args.scale, "Grid scale id");
139
+ const axes = cartesianAxisOwners(program).filter(owner => owner.channel === channel && owner.scale !== undefined && owner.coordinate !== undefined);
140
+ const bindings = [...layers.map(layer => ({ coordinate: layer.coordinate, scale: layer.encoding[channel].scale })),
141
+ ...axes.map(owner => ({ coordinate: owner.coordinate, scale: owner.scale }))]
142
+ .filter(binding => (requestedCoordinate === undefined || binding.coordinate === requestedCoordinate) &&
143
+ (requestedScale === undefined || binding.scale === requestedScale));
144
+ if (bindings.length === 0) throw new Error(layers.length === 0
145
+ ? `${direction} grid requires a ${channel} encoding or bound Cartesian axis.`
146
+ : `${direction} grid found no matching encoded layers.`);
147
+ if (bindings.some(binding => binding.coordinate === undefined)) throw new Error(`${direction} grid requires a stored coordinate.`);
148
+ const coordinateIds = [...new Set(bindings.map(binding => binding.coordinate))];
149
+ if (coordinateIds.length > 1) throw new Error(`${direction} grid found multiple coordinates; provide coordinate explicitly.`);
163
150
  const coordinate = requestedCoordinate ?? coordinateIds[0];
164
- const definition = findCoordinate(program, coordinate);
165
- if (definition?.type !== "cartesian") {
166
- throw new Error(`${direction} grid requires a Cartesian coordinate.`);
167
- }
168
- const scaleIds = [...new Set(layers.map(layer => layer.encoding[channel].scale))];
169
- if (scaleIds.length === 0) throw new Error(`Unknown grid scale "${requestedScale}".`);
170
- if (scaleIds.length > 1) {
171
- throw new Error(`${direction} grid found multiple ${channel} scales; provide scale explicitly.`);
172
- }
151
+ if (findCoordinate(program, coordinate)?.type !== "cartesian") throw new Error(`${direction} grid requires a Cartesian coordinate.`);
152
+ const scaleIds = [...new Set(bindings.map(binding => binding.scale))];
153
+ if (scaleIds.length > 1) throw new Error(`${direction} grid found multiple ${channel} scales; provide scale explicitly.`);
173
154
  const scale = requestedScale ?? scaleIds[0];
155
+ layers = layers.filter(layer => layer.coordinate === coordinate && layer.encoding[channel].scale === scale);
174
156
  if (
175
157
  !["linear", "time", "band", "point"].includes(program.resolvedScales[scale]?.type) &&
176
158
  !isTransformedScaleType(program.resolvedScales[scale]?.type)
@@ -185,6 +167,11 @@ export function resolveGridResources(program, direction, args) {
185
167
  const leader = program.materializationConfigs.labelLayouts?.[layer.id]?.leaderId;
186
168
  return leader === undefined ? [layer.id] : [leader, layer.id];
187
169
  }));
170
+ if (related.size === 0) {
171
+ for (const owner of axes.filter(owner => owner.scale === scale && owner.coordinate === coordinate)) {
172
+ for (const id of axisGraphicIds(channel, owner.id)) if (program.graphicSpec.objects[id] !== undefined) related.add(id);
173
+ }
174
+ }
188
175
  let before;
189
176
  let after;
190
177
  walkGraphicDrawOrder(program.graphicSpec, ({ id }) => {
@@ -210,7 +197,7 @@ export function resolveGridConfig(program, direction, args, resources) {
210
197
  : Object.hasOwn(args, "count")
211
198
  ? { mode: "count", count: args.count }
212
199
  : inferGridTickConfig(program, resources.channel, resources.scale);
213
- return {
200
+ return patchGuideDash({
214
201
  direction,
215
202
  ...(args.bandPosition === undefined ? {} : { bandPosition: args.bandPosition }),
216
203
  ...(args.zIndex === undefined ? {} : { zIndex: args.zIndex }),
@@ -222,7 +209,7 @@ export function resolveGridConfig(program, direction, args, resources) {
222
209
  color: args.color ?? DEFAULT_STYLE.color,
223
210
  lineWidth: args.lineWidth ?? DEFAULT_STYLE.lineWidth,
224
211
  strokeDash: args.strokeDash ?? DEFAULT_STYLE.strokeDash
225
- };
212
+ }, args);
226
213
  }
227
214
 
228
215
  export function refreshGridConfig(program, config) {
@@ -257,7 +244,7 @@ export function editGridConfig(previous, args) {
257
244
  } else if (args.values === "auto") {
258
245
  config.inferredValues = true;
259
246
  }
260
- return config;
247
+ return patchGuideDash(config, args);
261
248
  }
262
249
 
263
250
  export function resolveGridGeometry(program, config) {
@@ -281,11 +268,21 @@ export function resolveGridGeometry(program, config) {
281
268
  if (!values.every(value => scale.domain.includes(value))) throw new RangeError("Grid values must be inside the scale domain.");
282
269
  const centers = mapOrdinalPositionValues(values, scale);
283
270
  const direction = scale.range[1] >= scale.range[0] ? 1 : -1;
271
+ const owners = new Map();
272
+ if (config.bandPosition === "boundary") {
273
+ centers.forEach((center, index) => {
274
+ for (const edge of [center - direction * scale.bandwidth / 2, center + direction * scale.bandwidth / 2]) {
275
+ if (!owners.has(edge)) owners.set(edge, values[index]);
276
+ }
277
+ });
278
+ }
284
279
  const positions = config.bandPosition === "boundary"
285
- ? [...new Set(centers.flatMap(center => [center - direction * scale.bandwidth / 2, center + direction * scale.bandwidth / 2]))].sort((a, b) => direction * (a - b))
286
- : centers;
280
+ ? [...owners.keys()].sort((a, b) => direction * (a - b)) : centers;
287
281
  validateGeneratedItemLimit(positions.length, "Grid value count");
288
- return resolveGridLineGeometry({ direction: config.direction, values: positions, positions: positions.map(value => value + (config.alongOffset ?? 0)), bounds });
282
+ return {
283
+ ...resolveGridLineGeometry({ direction: config.direction, values: positions, positions: positions.map(value => value + (config.alongOffset ?? 0)), bounds }),
284
+ strokeDash: resolveGuideDashes(config.bandPosition === "boundary" ? positions.map(value => owners.get(value)) : values, config)
285
+ };
289
286
  }
290
287
  const values = valuesFromTickConfig(program, config);
291
288
  validateGeneratedItemLimit(values.length, "Grid value count");
@@ -295,12 +292,12 @@ export function resolveGridGeometry(program, config) {
295
292
  throw new RangeError("Grid values must be inside the scale domain.");
296
293
  }
297
294
  const positions = mapContinuousScaleValues(values, scale);
298
- return resolveGridLineGeometry({
295
+ return { ...resolveGridLineGeometry({
299
296
  direction: config.direction,
300
297
  values,
301
298
  positions: positions.map(value => value + (config.alongOffset ?? 0)),
302
299
  bounds
303
- });
300
+ }), strokeDash: resolveGuideDashes(values, config) };
304
301
  }
305
302
 
306
303
 
@@ -1,3 +1,7 @@
1
+ import { editFacetParentLegend } from "../../../materialization/facetGuides/edit.js";
2
+
3
+ import {patchLegendClipHeight, validateLegendClip} from "../../../grammar/legendClip.js";
4
+ import { LEGEND_POSITIONS } from "../../../core/vocabulary.js";
1
5
  import { action } from "../../../core/action.js";
2
6
  import { isPlainObject } from "../../../core/immutable.js";
3
7
  import {
@@ -23,11 +27,11 @@ import {
23
27
  resolveLegendBlock
24
28
  } from "./target.js";
25
29
  import { validateFontWeight } from "./categorical/validation.js";
26
- import { normalizeDisplayLabelMap } from "../../../grammar/displayLabels.js";
30
+ import { normalizeDisplayLabelMap, normalizeNumericDisplayLabelMap } from "../../../grammar/displayLabels.js";
27
31
 
28
32
  const OPTIONS = Object.freeze([
29
33
  "target", "channel", "title", "values", "count", "order", "gap", "text", "symbol",
30
- "labelMap"
34
+ "labelMap", "position", "clipHeight"
31
35
  ]);
32
36
  const TEXT_OPTIONS = Object.freeze(["fontSize", "fontFamily", "fontWeight", "color"]);
33
37
  const SYMBOL_OPTIONS = Object.freeze(["size", "fill", "stroke", "strokeWidth", "opacity"]);
@@ -63,10 +67,12 @@ function categoricalSupports(config, property) {
63
67
 
64
68
  function validateSymbolSupport(descriptor, property) {
65
69
  const { channels, family, kind, config } = descriptor;
70
+ if(kind === "colorSize" && ["fill","size"].includes(property)) throw new Error("Color-size symbols cannot override mapped fill or size.");
66
71
  if (family === "gradient") {
67
72
  throw new Error(`editLegendBlock symbol.${property} is unsupported for gradient blocks.`);
68
73
  }
69
74
  if (family === "categorical") {
75
+ if (property === "size" && channels.includes("size")) throw new Error("Combined size-shape block cannot override mapped size.");
70
76
  if (property === "fill" && channels.includes("color")) {
71
77
  throw new Error("editLegendBlock symbol.fill conflicts with the color mapping.");
72
78
  }
@@ -125,13 +131,21 @@ function normalizeSymbolPatch(value, descriptor) {
125
131
  return { ...value };
126
132
  }
127
133
 
134
+ function normalizeBlockLabelMap(descriptor, map) {
135
+ if (["size","colorSize"].includes(descriptor.kind) && descriptor.family === "sampled") {
136
+ return normalizeNumericDisplayLabelMap(map, "editLegendBlock labelMap");
137
+ }
138
+ if (descriptor.family !== "categorical") {
139
+ throw new Error("editLegendBlock labelMap requires a categorical or continuous size block.");
140
+ }
141
+ return normalizeDisplayLabelMap(map, "editLegendBlock labelMap");
142
+ }
143
+
128
144
  export function validateLegendBlockOverride(descriptor, override) {
145
+ if (override.position !== undefined && !LEGEND_POSITIONS.includes(override.position)) throw new Error("Invalid local legend position.");
129
146
  if (override.symbol !== undefined) normalizeSymbolPatch(override.symbol, descriptor);
130
147
  if (override.labelMap !== undefined) {
131
- if (descriptor.family !== "categorical") {
132
- throw new Error("editLegendBlock labelMap requires a categorical block.");
133
- }
134
- normalizeDisplayLabelMap(override.labelMap, "editLegendBlock labelMap");
148
+ normalizeBlockLabelMap(descriptor, override.labelMap);
135
149
  }
136
150
  return override;
137
151
  }
@@ -176,6 +190,8 @@ export function patchLegendBlockOverride(config, key, patch) {
176
190
  entry = withoutEmptyNested(entry, "symbol", patch.symbol);
177
191
  }
178
192
  if (Object.hasOwn(patch, "labelMap")) entry.labelMap = patch.labelMap;
193
+ if (Object.hasOwn(patch, "position")) entry.position = patch.position;
194
+ if (patch.removePosition === true) delete entry.position;
179
195
  if (patch.removeLabelMap === true) delete entry.labelMap;
180
196
  const blockOverrides = { ...(config.blockOverrides ?? {}) };
181
197
  if (Object.keys(entry).length === 0) delete blockOverrides[key];
@@ -208,7 +224,7 @@ function applyCategoricalSymbol(symbol, patch) {
208
224
  }
209
225
 
210
226
  export function resolveEffectiveLegendBlockConfig(program, kind, config) {
211
- const channels = kind === "series" || kind === "color" || kind === "stroke"
227
+ const channels = kind === "colorSize" ? ["color","size"] : kind === "series" || kind === "color" || kind === "stroke"
212
228
  ? config.channels
213
229
  : [{ size: "size", opacity: "opacity", strokeWidth: "strokeWidth",
214
230
  gradient: "color", interval: "color", strokeGradient: "stroke",
@@ -233,6 +249,7 @@ export function resolveEffectiveLegendBlockConfig(program, kind, config) {
233
249
  : {})
234
250
  };
235
251
  if (override.symbol === undefined) return effective;
252
+ if(kind === "colorSize") return {...effective,symbol:{...config.symbol,...override.symbol}};
236
253
  if (["series", "color", "stroke"].includes(kind)) {
237
254
  return {
238
255
  ...effective,
@@ -290,6 +307,7 @@ export function reconcileLegendBlockTitleGraphic(program, kind, visible) {
290
307
  }
291
308
 
292
309
  const SPECIALIZED_SYMBOLS = Object.freeze({
310
+ colorSize: Object.freeze({id:"colorSizeLegendSymbols",type:"circle"}),
293
311
  gradient: Object.freeze({ id: "colorGradientSymbols", type: "circle" }),
294
312
  size: Object.freeze({ id: "sizeLegendSymbols", type: "circle" }),
295
313
  opacity: Object.freeze({ id: "opacityLegendSymbols", type: "circle" }),
@@ -326,6 +344,13 @@ function recreateSymbolGraphics(program, descriptor) {
326
344
 
327
345
  function normalizeBlockPatch(args, descriptor) {
328
346
  const patch = {};
347
+ if (Object.hasOwn(args, "position")) {
348
+ if (args.position === "inherit") patch.removePosition = true;
349
+ else {
350
+ if (!LEGEND_POSITIONS.includes(args.position)) throw new Error("Legend block position requires an anchor or inherit.");
351
+ patch.position = args.position;
352
+ }
353
+ }
329
354
  if (Object.hasOwn(args, "title")) {
330
355
  if (typeof args.title !== "string") {
331
356
  throw new TypeError("editLegendBlock title must be a string.");
@@ -341,26 +366,21 @@ function normalizeBlockPatch(args, descriptor) {
341
366
  patch.symbol = normalizeSymbolPatch(args.symbol, descriptor);
342
367
  }
343
368
  if (Object.hasOwn(args, "labelMap")) {
344
- if (descriptor.family !== "categorical") {
345
- throw new Error("editLegendBlock labelMap requires a categorical block.");
346
- }
369
+ // Validate eligibility even for a reset, without introducing an empty map owner.
370
+ const map = normalizeBlockLabelMap(descriptor, args.labelMap === "auto" ? [] : args.labelMap);
347
371
  if (args.labelMap === "auto") patch.removeLabelMap = true;
348
- else {
349
- patch.labelMap = normalizeDisplayLabelMap(
350
- args.labelMap,
351
- "editLegendBlock labelMap"
352
- );
353
- }
372
+ else patch.labelMap = map;
354
373
  }
355
374
  return patch;
356
375
  }
357
376
 
358
377
  export const editLegendBlock = /* @__PURE__ */ action(
359
378
  {
360
- op: "editLegendBlock",
379
+ op: "editLegendBlock", scope: "any",
361
380
  description: "Edit one logical legend block selected by channel."
362
381
  },
363
382
  withGuideLayoutValidation(function (args = {}) {
383
+ if (this.compositionSpec !== undefined) return editFacetParentLegend(this, "editLegendBlock", args);
364
384
  validateOptionObject(args, OPTIONS, "editLegendBlock");
365
385
  if (!Object.hasOwn(args, "target") || typeof args.target !== "string") {
366
386
  throw new TypeError("editLegendBlock target must be an explicit legend target id.");
@@ -377,7 +397,9 @@ export const editLegendBlock = /* @__PURE__ */ action(
377
397
  throw new Error("editLegendBlock values and count require a sampled legend block.");
378
398
  }
379
399
  const patch = normalizeBlockPatch(args, descriptor);
380
- let config = descriptor.config;
400
+ let config = Object.hasOwn(args, "clipHeight")
401
+ ? patchLegendClipHeight(descriptor.config, args.clipHeight) : descriptor.config;
402
+ validateLegendClip(descriptor.kind, config);
381
403
  const blockOverrides = patchLegendBlockOverride(config, descriptor.key, patch);
382
404
  if (blockOverrides === undefined) {
383
405
  const { blockOverrides: _removed, ...rest } = config;
@@ -423,6 +445,8 @@ export const editLegendBlock = /* @__PURE__ */ action(
423
445
  }
424
446
  next = reconcileLegendBlockTitleGraphic(next, descriptor.kind,
425
447
  effective.titleVisible !== false);
426
- return next.rematerializeLegend();
448
+ return Object.hasOwn(args, "position")
449
+ ? next.editLegend({target: args.target, channel: args.channel, position: args.position})
450
+ : next.rematerializeLegend();
427
451
  })
428
452
  );
@@ -1,3 +1,6 @@
1
+ import {resolveColorSizeConfig,createColorSizeLegendFromConfig,materializeColorSizeLegend} from "../colorSize.js";
2
+ import {resetLegendClipGraphics,applyLegendClipGraphics,restoreLegendClipContext}from"../../../../materialization/legendClip.js";
3
+ import {validateLegendCombine,resolveSizeShapeCreation} from "../combine.js";
1
4
  import { recordLegendOffsetIntent } from "../group.js";
2
5
  import { prepareLegendGroup, creationLegendTarget, validateLegendGroupOptions } from "../group.js";
3
6
  import { PLOT_LEGEND_POSITIONS } from "../../../../layout/legend.js";
@@ -37,7 +40,7 @@ import {
37
40
  } from "../blocks.js";
38
41
 
39
42
  function finishLegend(program) {
40
- return hasLegendLane(program) || Object.keys(program.guideConfigs.legendGroups ?? {}).length > 0
43
+ return Object.values(program.guideConfigs.legend??{}).some(config=>config.clipHeight!==undefined)||hasLegendLane(program) || Object.keys(program.guideConfigs.legendGroups ?? {}).length > 0
41
44
  ? program.rematerializeLegend() : program;
42
45
  }
43
46
 
@@ -59,7 +62,8 @@ export const rematerializeLegend = /* @__PURE__ */ action(
59
62
  { op: "rematerializeLegend", description: "Rematerialize every existing legend component." },
60
63
  withGuideLayoutValidation(function (args = {}) {
61
64
  noOptions(args, "rematerializeLegend");
62
- let next = this;
65
+ const oldClipContext=this.context.deferLegendClip;
66
+ let next = resetLegendClipGraphics(this)._withContext({deferLegendClip:true});
63
67
  const categoricalKinds = ["series", "color", "stroke"].filter(
64
68
  kind => this.guideConfigs.legend?.[kind] !== undefined
65
69
  );
@@ -85,6 +89,7 @@ export const rematerializeLegend = /* @__PURE__ */ action(
85
89
  !sameValues(stored.domain, definition.domain) ||
86
90
  !sameValues(stored.scales, definition.scales) ||
87
91
  stored.field !== definition.field ||
92
+ !sameValues(stored.sizeAreas ?? [], definition.sizeAreas ?? []) ||
88
93
  stored.title !== definition.title;
89
94
  next = changed
90
95
  ? next._withLegendConfig(kind, {
@@ -93,6 +98,7 @@ export const rematerializeLegend = /* @__PURE__ */ action(
93
98
  channels: definition.channels,
94
99
  scales: definition.scales,
95
100
  field: definition.field,
101
+ ...(definition.combine === undefined ? {} : {combine: definition.combine, sizeAreas: definition.sizeAreas}),
96
102
  title: definition.title,
97
103
  domain: definition.domain
98
104
  })
@@ -152,6 +158,7 @@ export const rematerializeLegend = /* @__PURE__ */ action(
152
158
  ).some(highlight => highlight.target === config.target);
153
159
  if (hasHighlight) next = next.rematerializeLegendHighlights({ kind });
154
160
  }
161
+ if(next.guideConfigs.legend?.colorSize !== undefined) next=materializeColorSizeLegend(next,next.guideConfigs.legend.colorSize);
155
162
  for (const policy of legendResourcePolicies()) {
156
163
  if (
157
164
  policy.rematerializeOp !== undefined &&
@@ -167,6 +174,10 @@ export const rematerializeLegend = /* @__PURE__ */ action(
167
174
  next = next[policy.rematerializeOp]();
168
175
  }
169
176
  }
177
+ const clipAnchors = Object.fromEntries(categoricalKinds.filter(kind => next.guideConfigs.legend[kind].clipHeight !== undefined)
178
+ .map(kind => [kind, resolveLayout(next, resolveEffectiveLegendBlockConfig(next, kind, next.guideConfigs.legend[kind])).itemY]));
179
+ next=applyLegendClipGraphics(next,{force:true,anchors:clipAnchors});
180
+ next=restoreLegendClipContext(next,oldClipContext);
170
181
  if (hasHorizontalLegendLane(next)) {
171
182
  next = next.rematerializeHorizontalLegendLane();
172
183
  }
@@ -210,6 +221,7 @@ export function resolveCategoricalLegendConfig(program, args = {}) {
210
221
  titleStyle: options.titleStyle,
211
222
  itemGap: options.itemGap,
212
223
  layout: options.layout,
224
+ ...(options.clipHeight === undefined ? {} : {clipHeight:options.clipHeight}),
213
225
  ...(options.overflow === undefined ? {} : { overflow: options.overflow }),
214
226
  border: options.border
215
227
  };
@@ -231,7 +243,18 @@ export const createCategoricalLegend = /* @__PURE__ */ action(
231
243
  })
232
244
  );
233
245
 
234
- function resolveRawLegendCreationPlan(program, args = {}, layers = program.semanticSpec.layers) {
246
+ function resolveRawLegendCreationPlan(program, args = {}, layers = program.semanticSpec.layers, internalChannels) {
247
+ if (Object.hasOwn(args, "combine")) {
248
+ validateLegendCombine(args.combine);
249
+ if (args.combine === "color-size") {
250
+ const config=resolveColorSizeConfig(program,args);
251
+ const prepared=prepareLegendGroup(program,{...args,blockLayout:args.blockLayout??(["left","right"].includes(args.position??"right")?"column":"row")},config.target,{creation:true,nativeOffset:30});
252
+ return {steps:[{op:"createColorSizeLegend",args}],finish:"always",legendGroup:{target:config.target,config:prepared.program.guideConfigs.legendGroups[config.target]}};
253
+ }
254
+ if (args.combine === "size-shape") return resolveSizeShapeCreation(program, args, layers, resolveRawLegendCreationPlan, internalChannels);
255
+ const {combine: _combine, ...separate} = args;
256
+ return resolveRawLegendCreationPlan(program, separate, layers);
257
+ }
235
258
  validateOptionObject(args, undefined, "createLegend");
236
259
  const candidates = args.target === undefined ? layers : layers.filter(layer => layer.id === args.target);
237
260
  const channels = args.channels;
@@ -403,7 +426,7 @@ function resolveRawLegendCreationPlan(program, args = {}, layers = program.seman
403
426
  return { steps: [{ op: "createCategoricalLegend", args }], finish: "auto" };
404
427
  }
405
428
 
406
- export function resolveLegendCreationPlan(program, args = {}, layers = program.semanticSpec.layers) {
429
+ export function resolveLegendCreationPlan(program, args = {}, layers = program.semanticSpec.layers, internalChannels) {
407
430
  validateLegendGroupOptions(args);
408
431
  const { blockLayout: _layout, at: _at, ...raw } = args;
409
432
  const groups = program.guideConfigs.legendGroups ?? {};
@@ -418,7 +441,7 @@ export function resolveLegendCreationPlan(program, args = {}, layers = program.s
418
441
  }
419
442
  if (raw.position === undefined && args.direction === "horizontal" &&
420
443
  (args.at !== undefined || Object.keys(groups).length > 0)) raw.position = "top";
421
- const initial = resolveRawLegendCreationPlan(program, raw, layers);
444
+ const initial = resolveRawLegendCreationPlan(program, raw, layers, internalChannels);
422
445
  const inherited = Object.keys(program.guideConfigs.legendGroups ?? {}).length > 0;
423
446
  const corner = PLOT_LEGEND_POSITIONS.includes(args.position) &&
424
447
  (initial.steps.length > 1 || initial.steps[0]?.op !== "createCategoricalLegend" ||
@@ -434,7 +457,7 @@ export function resolveLegendCreationPlan(program, args = {}, layers = program.s
434
457
  if (prepared.options.position === undefined && raw.position !== undefined) {
435
458
  prepared.options.position = raw.position;
436
459
  }
437
- return { ...resolveRawLegendCreationPlan(prepared.program, prepared.options, layers),
460
+ return { ...resolveRawLegendCreationPlan(prepared.program, prepared.options, layers, internalChannels),
438
461
  legendGroup: { target, config: prepared.program.guideConfigs.legendGroups[target] } };
439
462
  }
440
463
 
@@ -449,7 +472,7 @@ export function applyLegendCreationPlan(program, plan) {
449
472
  }
450
473
  // The plan already validated that a retained size block is compatible.
451
474
  if (!(plan.combined && step.op === "createSizeLegend" && existingSize !== undefined)) {
452
- next = next[step.op](step.args);
475
+ next = step.op === "createColorSizeLegend" ? createColorSizeLegendFromConfig(next,resolveColorSizeConfig(next,step.args)) : next[step.op](step.args);
453
476
  }
454
477
  }
455
478
  return plan.finish === "always" ? next.rematerializeLegend()
@@ -1,3 +1,4 @@
1
+ import { materializeLegendBorder } from "../../../../materialization/legendBorder.js";
1
2
  import { editGraphicProperties } from "../../../primitives/graphicProperties.js";
2
3
  import { materializeLegendTitleLines } from "../../../../materialization/legendText.js";
3
4
  import { action } from "../../../../core/action.js";
@@ -122,19 +123,11 @@ export const rematerializeLegendBackground = /* @__PURE__ */ action(
122
123
  throw new Error("rematerializeLegendBackground requires border configuration.");
123
124
  }
124
125
  const id = graphic(config, "Background");
125
- if (this.graphicSpec.objects[id]?.type !== "rect") {
126
+ if (!["rect", "path"].includes(this.graphicSpec.objects[id]?.type)) {
126
127
  throw new Error("rematerializeLegendBackground requires an existing background.");
127
128
  }
128
129
  const layout = resolveLayout(this, config).background;
129
- return editGraphicProperties(this, id, {
130
- x: layout.x,
131
- y: layout.y,
132
- width: layout.width,
133
- height: layout.height,
134
- fill: config.border.background,
135
- stroke: config.border.color,
136
- strokeWidth: config.border.lineWidth
137
- });
130
+ return materializeLegendBorder(this, id, layout, config.border);
138
131
  }
139
132
  );
140
133
 
@@ -1,3 +1,4 @@
1
+ import{hasLegendClipGraphics}from"../../../../materialization/legendClip.js";
1
2
  import { action } from "../../../../core/action.js";
2
3
  import { editLegend } from "../edit.js";
3
4
  import { resolveStoredSelection } from
@@ -69,6 +70,7 @@ function createLegendHighlightAction() {
69
70
  },
70
71
  function (args = {}) {
71
72
  noOptions(args, "rematerializeLegendHighlights");
73
+ if(hasLegendClipGraphics(this))return this.rematerializeLegend();
72
74
  if (this.context.deferGuideLayoutValidation !== true &&
73
75
  Object.values(this.guideConfigs.legend ?? {}).some(config => ["left", "right"].includes(config.position))) {
74
76
  return this.rematerializeLegend();
@@ -1,3 +1,6 @@
1
+ import{validateLegendClip,clippedSampleBounds}from"../../../../grammar/legendClip.js";
2
+ import {categoricalLegendSymbolId as symbolGraphic}from"../../../../grammar/legendBlocks.js";
3
+ export {symbolGraphic};
1
4
  import { findLayer } from "../../../../selectors/layers.js";
2
5
  import { isLinearGradientPaint } from "../../../../grammar/paint.js";
3
6
  import { intrinsicLegendConfig } from "../../../../materialization/legendGroups.js";
@@ -60,14 +63,6 @@ function prefix(config) {
60
63
  return `${config.kind}Legend`;
61
64
  }
62
65
 
63
- export function symbolGraphic(config, type) {
64
- const onlyDefault = config.symbol.layers.length === 1 &&
65
- ((config.kind === "series" && type === "line") ||
66
- (["color", "stroke"].includes(config.kind) && type === "swatch"));
67
- if (onlyDefault) return `${prefix(config)}Symbols`;
68
- const suffix = { line: "Lines", point: "Points", swatch: "Swatches" }[type];
69
- return `${prefix(config)}Symbol${suffix}`;
70
- }
71
66
 
72
67
  export function graphic(config, component) {
73
68
  return `${prefix(config)}${component}`;
@@ -76,7 +71,7 @@ export function graphic(config, component) {
76
71
  export function symbolWidth(config) {
77
72
  return Math.max(...config.symbol.layers.map(layer => {
78
73
  if (layer.type === "line") return layer.length;
79
- if (layer.type === "point") return layer.size * 2;
74
+ if (layer.type === "point") return config.combine === "size-shape" ? 2 * Math.sqrt(Math.max(...config.sizeAreas) / Math.PI) : layer.size * 2;
80
75
  return layer.width;
81
76
  }));
82
77
  }
@@ -123,7 +118,7 @@ function resolveSampleBounds(program, config, width) {
123
118
  }
124
119
  if (config.channels.includes("shape")) {
125
120
  return createPointShapeGraphic({ shape: appearance.shapes[index], x: width / 2, y: 0,
126
- area: Math.PI * layer.size ** 2, fill: layer.fill ?? appearance.colors[index],
121
+ area: appearance.areas?.[index] ?? Math.PI * layer.size ** 2, fill: layer.fill ?? appearance.colors[index],
127
122
  stroke: layer.stroke, strokeWidth: layer.strokeWidth, opacity: layer.opacity ?? appearance.opacity,
128
123
  ...requestedStrokeDetails(layer, "Legend point symbol") });
129
124
  }
@@ -134,7 +129,7 @@ function resolveSampleBounds(program, config, width) {
134
129
  strokeWidth: layer.strokeWidth, opacity: layer.opacity ?? appearance.opacity,
135
130
  ...requestedStrokeDetails(layer, "Legend point symbol") } };
136
131
  });
137
- return items.map((item, itemIndex) => {
132
+ return clippedSampleBounds(items.map((item, itemIndex) => {
138
133
  const bounds = resolveConcreteGraphicBounds({
139
134
  objects: { sample: item },
140
135
  order: ["sample"]
@@ -153,11 +148,12 @@ function resolveSampleBounds(program, config, width) {
153
148
  right: Math.max(result.right, bounds.right),
154
149
  top: Math.min(result.top, bounds.top),
155
150
  bottom: Math.max(result.bottom, bounds.bottom)
156
- }, undefined);
151
+ }, undefined),config.clipHeight);
157
152
  });
158
153
  }
159
154
 
160
155
  export function resolveLayout(program, config) {
156
+ validateLegendClip(config.kind,config);
161
157
  config = intrinsicLegendConfig(program, config);
162
158
  config = categoricalLegendView(config);
163
159
  const { plot, canvas } = resolveContinuousBounds(program,
@@ -192,7 +188,7 @@ export function resolveAppearance(program, config) {
192
188
  if (config.channels[index] === "strokeDash") dashes = values;
193
189
  if (config.channels[index] === "shape") shapes = values;
194
190
  }
195
- return { colors, strokes, dashes, shapes,
191
+ return { colors, strokes, dashes, shapes, ...(config.combine === "size-shape" ? {areas: config.sizeAreas} : {}),
196
192
  ...(gradientFallback ? { opacity: program.markConfigs[target.id].opacity } : {}) };
197
193
  }
198
194
 
@@ -1,3 +1,5 @@
1
+ import{normalizeLegendClipHeight}from"../../../../grammar/legendClip.js";
2
+ import { normalizeLegendBorderDash } from "../../../../grammar/legendBorder.js";
1
3
  import { LEGEND_TITLE_STYLE_KEYS, validateLegendTitleAlignment } from "../../../../grammar/legendTitleStyle.js";
2
4
  import { PLOT_LEGEND_POSITIONS, isPlotLegend } from "../../../../layout/legend.js";
3
5
  import { validateOptionObject } from "../../../../core/validation.js";
@@ -16,6 +18,7 @@ import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY, DEFAULT_VERTICAL_LEGEND_ITEM_GAP }
16
18
 
17
19
  export const CHANNELS = CATEGORICAL_LEGEND_CHANNELS;
18
20
  const OPTIONS = Object.freeze([
21
+ "clipHeight",
19
22
  "target",
20
23
  "channels",
21
24
  "order",
@@ -59,7 +62,8 @@ const BORDER_OPTIONS = Object.freeze([
59
62
  "color",
60
63
  "lineWidth",
61
64
  "padding",
62
- "background"
65
+ "background",
66
+ "strokeDash"
63
67
  ]);
64
68
  const COMMON_DEFAULTS = Object.freeze({
65
69
  labels: Object.freeze({
@@ -95,7 +99,8 @@ function normalizeBorder(border) {
95
99
  nonEmptyString(normalized.background, "Legend border background");
96
100
  nonNegative(normalized.lineWidth, "Legend border lineWidth");
97
101
  nonNegative(normalized.padding, "Legend border padding");
98
- return normalized;
102
+ const dash = normalizeLegendBorderDash(normalized.strokeDash);
103
+ return { ...normalized, ...(dash === undefined ? {} : { strokeDash: dash }) };
99
104
  }
100
105
 
101
106
  export function normalizeOptions(args, kind, { internalSymbol = false } = {}) {
@@ -192,6 +197,7 @@ export function normalizeOptions(args, kind, { internalSymbol = false } = {}) {
192
197
 
193
198
  const overflow = normalizeLegendOverflow(args.overflow);
194
199
  return {
200
+ ...(args.clipHeight===undefined?{}:{clipHeight:normalizeLegendClipHeight(args.clipHeight)}),
195
201
  ...(overflow === undefined ? {} : { overflow }),
196
202
  target: args.target,
197
203
  channels: args.channels,