ggaction 0.0.13 → 0.0.15

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 (270) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +20 -11
  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 +8 -3
  7. package/src/actions/basic.js +2 -2
  8. package/src/actions/boxPlots/components.js +14 -4
  9. package/src/actions/boxPlots/edit.js +31 -5
  10. package/src/actions/boxPlots/materialize.js +11 -3
  11. package/src/actions/boxPlots/options.js +48 -16
  12. package/src/actions/categoryOrder/index.js +15 -4
  13. package/src/actions/charts/area.js +5 -1
  14. package/src/actions/charts/bar.js +2 -1
  15. package/src/actions/charts/density.js +6 -2
  16. package/src/actions/charts/ecdf.js +4 -1
  17. package/src/actions/charts/endpoints.js +25 -3
  18. package/src/actions/charts/heatmap.js +2 -1
  19. package/src/actions/charts/histogram.js +4 -3
  20. package/src/actions/charts/horizon.js +3 -1
  21. package/src/actions/charts/interval-regression.js +5 -2
  22. package/src/actions/charts/line.js +3 -1
  23. package/src/actions/charts/parallel.js +8 -2
  24. package/src/actions/charts/pie.js +10 -2
  25. package/src/actions/charts/polar.js +15 -4
  26. package/src/actions/charts/radar.js +8 -2
  27. package/src/actions/charts/radial.js +10 -2
  28. package/src/actions/charts/rug-strip.js +6 -2
  29. package/src/actions/charts/scatter.js +3 -1
  30. package/src/actions/composition/actions.js +13 -4
  31. package/src/actions/coordinates/actions.js +1 -1
  32. package/src/actions/coordinates/edit.js +76 -0
  33. package/src/actions/data/bin.js +1 -1
  34. package/src/actions/data/bin2d.js +57 -185
  35. package/src/actions/data/complete.js +26 -0
  36. package/src/actions/data/computed.js +2 -2
  37. package/src/actions/data/create.js +2 -2
  38. package/src/actions/data/density.js +12 -20
  39. package/src/actions/data/derived.js +47 -17
  40. package/src/actions/data/edit.js +539 -0
  41. package/src/actions/data/filter.js +14 -2
  42. package/src/actions/data/impute.js +26 -0
  43. package/src/actions/data/index.js +14 -0
  44. package/src/actions/data/normalize.js +27 -0
  45. package/src/actions/data/regression.js +16 -21
  46. package/src/actions/data/revisionBindings.js +145 -0
  47. package/src/actions/data/shared.js +20 -4
  48. package/src/actions/data/summary.js +1 -1
  49. package/src/actions/data/timeUnit.js +4 -1
  50. package/src/actions/data/window.js +1 -1
  51. package/src/actions/encodings/channels.js +415 -0
  52. package/src/actions/encodings/color/index.js +2 -1
  53. package/src/actions/encodings/density.js +5 -2
  54. package/src/actions/encodings/histogram.js +3 -2
  55. package/src/actions/encodings/index.js +4 -0
  56. package/src/actions/encodings/offset.js +20 -13
  57. package/src/actions/encodings/position/apply.js +53 -7
  58. package/src/actions/encodings/position/index.js +10 -2
  59. package/src/actions/encodings/position/policies/bar.js +9 -0
  60. package/src/actions/encodings/position/resolve.js +24 -7
  61. package/src/actions/encodings/remove.js +9 -4
  62. package/src/actions/encodings/ruleAppearance.js +1 -23
  63. package/src/actions/encodings/seriesLayout.js +1 -0
  64. package/src/actions/encodings/shared.js +82 -14
  65. package/src/actions/encodings/stroke.js +226 -0
  66. package/src/actions/errorBands/create.js +23 -3
  67. package/src/actions/errorBands/edit.js +21 -6
  68. package/src/actions/errorBars/create.js +35 -7
  69. package/src/actions/errorBars/edit.js +19 -4
  70. package/src/actions/facets/actions.js +237 -57
  71. package/src/actions/facets/derive.js +91 -26
  72. package/src/actions/gradientPlots/components.js +10 -2
  73. package/src/actions/gradientPlots/edit.js +14 -4
  74. package/src/actions/gradientPlots/materialize.js +3 -1
  75. package/src/actions/gradientPlots/options.js +10 -2
  76. package/src/actions/guides/applicability.js +8 -2
  77. package/src/actions/guides/axes/labels.js +58 -12
  78. package/src/actions/guides/axes/lines.js +6 -2
  79. package/src/actions/guides/axes/parallel/resolve.js +6 -2
  80. package/src/actions/guides/axes/tickGroups.js +2 -1
  81. package/src/actions/guides/axes/ticks.js +6 -2
  82. package/src/actions/guides/axes/titles.js +11 -3
  83. package/src/actions/guides/grids/resolve.js +7 -2
  84. package/src/actions/guides/guides.js +19 -3
  85. package/src/actions/guides/legends/blocks.js +426 -0
  86. package/src/actions/guides/legends/categorical/actions.js +134 -48
  87. package/src/actions/guides/legends/categorical/components.js +17 -12
  88. package/src/actions/guides/legends/categorical/index.js +37 -33
  89. package/src/actions/guides/legends/categorical/layout.js +97 -15
  90. package/src/actions/guides/legends/categorical/options.js +2 -2
  91. package/src/actions/guides/legends/categorical/recipes.js +111 -20
  92. package/src/actions/guides/legends/categorical/resolve.js +12 -8
  93. package/src/actions/guides/legends/categorical/symbols.js +85 -30
  94. package/src/actions/guides/legends/continuous/common.js +30 -12
  95. package/src/actions/guides/legends/continuous/gradient.js +10 -7
  96. package/src/actions/guides/legends/continuous/index.js +2 -0
  97. package/src/actions/guides/legends/continuous/interval.js +8 -3
  98. package/src/actions/guides/legends/continuous/opacity.js +8 -5
  99. package/src/actions/guides/legends/continuous/stroke.js +525 -0
  100. package/src/actions/guides/legends/creation.js +14 -1
  101. package/src/actions/guides/legends/edit.js +158 -59
  102. package/src/actions/guides/legends/index.js +2 -0
  103. package/src/actions/guides/legends/lane.js +54 -19
  104. package/src/actions/guides/legends/lifecycle.js +19 -8
  105. package/src/actions/guides/legends/remove.js +13 -3
  106. package/src/actions/guides/legends/sampling.js +140 -0
  107. package/src/actions/guides/legends/size.js +93 -40
  108. package/src/actions/guides/legends/strokeWidth.js +34 -28
  109. package/src/actions/guides/legends/target.js +112 -1
  110. package/src/actions/guides/legends/transition.js +165 -16
  111. package/src/actions/guides/polar/axes/facade.js +4 -4
  112. package/src/actions/guides/polar/axes/labels.js +24 -5
  113. package/src/actions/guides/polar/axes/lines.js +10 -4
  114. package/src/actions/guides/polar/axes/shared.js +16 -0
  115. package/src/actions/guides/polar/axes/ticks.js +1 -1
  116. package/src/actions/guides/polar/axes/titles.js +1 -1
  117. package/src/actions/guides/polar/grids.js +1 -1
  118. package/src/actions/guides/polar/resolve.js +12 -8
  119. package/src/actions/index.js +14 -3
  120. package/src/actions/marks/arc/actions.js +77 -19
  121. package/src/actions/marks/area/actions.js +34 -9
  122. package/src/actions/marks/area/materialize.js +17 -1
  123. package/src/actions/marks/bar/create.js +11 -2
  124. package/src/actions/marks/bar/edit.js +20 -3
  125. package/src/actions/marks/bar/materialize.js +22 -2
  126. package/src/actions/marks/index.js +10 -1
  127. package/src/actions/marks/line/actions.js +37 -14
  128. package/src/actions/marks/line/materialize.js +29 -10
  129. package/src/actions/marks/point/create.js +8 -2
  130. package/src/actions/marks/point/edit.js +18 -6
  131. package/src/actions/marks/point/materialize.js +49 -15
  132. package/src/actions/marks/rect/actions.js +33 -12
  133. package/src/actions/marks/references.js +601 -30
  134. package/src/actions/marks/remove.js +71 -1
  135. package/src/actions/marks/rule/actions.js +47 -10
  136. package/src/actions/marks/shared.js +1 -1
  137. package/src/actions/marks/text/actions.js +74 -9
  138. package/src/actions/marks/text/index.js +4 -0
  139. package/src/actions/marks/text/layout.js +154 -6
  140. package/src/actions/marks/text/removal.js +173 -0
  141. package/src/actions/marks/text/selection.js +73 -0
  142. package/src/actions/marks/tick/actions.js +22 -5
  143. package/src/actions/primitives/editGraphics.js +26 -0
  144. package/src/actions/primitives/semantic.js +2 -1
  145. package/src/actions/primitives/semanticAction.js +83 -10
  146. package/src/actions/primitives/semanticValidation/guide.js +1 -1
  147. package/src/actions/primitives/semanticValidation/index.js +9 -1
  148. package/src/actions/primitives/semanticValidation/layer.js +5 -0
  149. package/src/actions/primitives/semanticValidation/scale.js +28 -7
  150. package/src/actions/regression/components.js +26 -11
  151. package/src/actions/regression/create.js +10 -2
  152. package/src/actions/regression/edit.js +21 -7
  153. package/src/actions/resources/index.js +7 -0
  154. package/src/actions/resources/remove.js +172 -0
  155. package/src/actions/scales/channels.js +212 -0
  156. package/src/actions/scales/consumers/common.js +1 -1
  157. package/src/actions/scales/consumers/families.js +3 -2
  158. package/src/actions/scales/consumers/index.js +19 -2
  159. package/src/actions/scales/consumers/seriesLayout.js +28 -14
  160. package/src/actions/scales/create.js +54 -22
  161. package/src/actions/scales/definitions.js +94 -40
  162. package/src/actions/scales/edit.js +66 -11
  163. package/src/actions/scales/editPolicy.js +81 -11
  164. package/src/actions/scales/index.js +6 -0
  165. package/src/actions/scales/materialize.js +4 -3
  166. package/src/actions/scales/offset.js +48 -0
  167. package/src/actions/scales/parallel.js +71 -0
  168. package/src/actions/scales/preview.js +111 -10
  169. package/src/actions/selection/actions.js +154 -30
  170. package/src/actions/selection/index.js +3 -0
  171. package/src/actions/theme/actions.js +68 -13
  172. package/src/actions/theme/composition.js +238 -0
  173. package/src/actions/theme/index.js +9 -2
  174. package/src/actions/theme/reconcile.js +315 -67
  175. package/src/actions/theme/state.js +160 -0
  176. package/src/actions/violinPlots/create.js +5 -2
  177. package/src/actions/violinPlots/edit.js +1 -0
  178. package/src/core/action.js +10 -0
  179. package/src/core/compositionState.js +6 -1
  180. package/src/core/programState.js +20 -0
  181. package/src/core/resourceReferences.js +332 -0
  182. package/src/core/vocabulary.js +16 -3
  183. package/src/grammar/bars/aggregate.js +4 -1
  184. package/src/grammar/bars/geometry.js +44 -0
  185. package/src/grammar/bin.js +54 -9
  186. package/src/grammar/complete.js +279 -0
  187. package/src/grammar/computed.js +265 -64
  188. package/src/grammar/coordinates.js +37 -0
  189. package/src/grammar/density.js +239 -37
  190. package/src/grammar/displayLabels.js +57 -0
  191. package/src/grammar/facets/dependencies.js +133 -1
  192. package/src/grammar/facets/guides.js +3 -0
  193. package/src/grammar/facets/index.js +7 -47
  194. package/src/grammar/facets/scales.js +15 -18
  195. package/src/grammar/impute.js +340 -0
  196. package/src/grammar/markLabelSelection.js +57 -0
  197. package/src/grammar/markLabels.js +18 -1
  198. package/src/grammar/normalize.js +366 -0
  199. package/src/grammar/pathSeries.js +26 -8
  200. package/src/grammar/pointShapes.js +13 -4
  201. package/src/grammar/polar.js +90 -8
  202. package/src/grammar/regression/index.js +1 -0
  203. package/src/grammar/regression/parameters.js +23 -0
  204. package/src/grammar/roundedRect.js +180 -0
  205. package/src/grammar/rules.js +1 -0
  206. package/src/grammar/scales/colorConsumers.js +20 -1
  207. package/src/grammar/scales/continuous.js +2 -2
  208. package/src/grammar/scales/definition.js +27 -4
  209. package/src/grammar/scales/index.js +1 -0
  210. package/src/grammar/scales/ordinal.js +12 -3
  211. package/src/grammar/scales/policies.js +2 -2
  212. package/src/grammar/scales/size.js +456 -0
  213. package/src/grammar/schemas/concreteGraphic.js +14 -2
  214. package/src/grammar/schemas/graphic.js +15 -0
  215. package/src/grammar/schemas/graphicBounds.js +179 -30
  216. package/src/grammar/schemas/semanticPath.js +7 -2
  217. package/src/grammar/statisticalReference.js +103 -0
  218. package/src/grammar/strokeStyle.js +77 -0
  219. package/src/grammar/summary.js +78 -16
  220. package/src/grammar/timeUnit.js +216 -5
  221. package/src/grammar/transformTopology.js +4 -0
  222. package/src/grammar/transforms.js +394 -28
  223. package/src/grammar/weightedStatistics.js +374 -0
  224. package/src/grammar/window.js +221 -16
  225. package/src/layout/aspect.js +80 -0
  226. package/src/layout/facets.js +59 -5
  227. package/src/layout/labels.js +343 -0
  228. package/src/materialization/bars/aggregate.js +25 -2
  229. package/src/materialization/bars/histogram.js +84 -24
  230. package/src/materialization/bars/ranged.js +19 -3
  231. package/src/materialization/bars/resolve.js +7 -4
  232. package/src/materialization/composition.js +3 -1
  233. package/src/materialization/coordinateBounds.js +204 -0
  234. package/src/materialization/dependencies.js +36 -0
  235. package/src/materialization/facetGuides/legacyCategorical.js +3 -0
  236. package/src/materialization/facetGuides/placement.js +8 -2
  237. package/src/materialization/facetGuides/preparation.js +12 -7
  238. package/src/materialization/facetHeaders.js +346 -0
  239. package/src/materialization/facets.js +15 -65
  240. package/src/materialization/guides/resources.js +26 -0
  241. package/src/materialization/legends.js +1 -1
  242. package/src/materialization/marks/index.js +11 -5
  243. package/src/materialization/marks/policies.js +2 -1
  244. package/src/materialization/rect.js +25 -12
  245. package/src/materialization/rectConfig.js +5 -1
  246. package/src/materialization/scales/map.js +5 -3
  247. package/src/materialization/scales/policies/binnedPosition.js +2 -2
  248. package/src/materialization/scales/policies/offset.js +50 -8
  249. package/src/materialization/scales/policies/series.js +4 -1
  250. package/src/materialization/scales/resolve.js +22 -6
  251. package/src/materialization/selection/items/arc.js +28 -4
  252. package/src/materialization/selection/items/bar.js +30 -3
  253. package/src/materialization/selection/items/common.js +19 -4
  254. package/src/materialization/selection/items/rect.js +28 -4
  255. package/src/materialization/selection/path.js +1 -1
  256. package/src/materialization/selection/point.js +3 -1
  257. package/src/materialization/text.js +265 -14
  258. package/src/renderers/canvas/circle.js +2 -3
  259. package/src/renderers/canvas/line.js +2 -3
  260. package/src/renderers/canvas/native.js +4 -4
  261. package/src/renderers/canvas/path.js +2 -3
  262. package/src/renderers/canvas/rect.js +2 -3
  263. package/src/renderers/canvas/stroke.js +11 -0
  264. package/src/renderers/svg.js +19 -0
  265. package/src/selectors/datasets.js +32 -1
  266. package/src/selectors/layers.js +4 -0
  267. package/src/theme/defaults.js +101 -0
  268. package/types/basic.d.ts +6 -0
  269. package/types/index.d.ts +85 -0
  270. package/types/program.d.ts +910 -117
@@ -49,8 +49,13 @@ export function resolveCategoricalLegendRevision(program, kind, previous, channe
49
49
  const config = {
50
50
  ...previous,
51
51
  ...definition,
52
- symbol: normalizeRecipe(resolveLegendSymbol(program, layer, channels,
53
- previous.inferredSymbol ? undefined : previous.symbol), definition.kind)
52
+ symbol: normalizeRecipe(resolveLegendSymbol(
53
+ program,
54
+ layer,
55
+ channels,
56
+ previous.inferredSymbol ? undefined : previous.symbol,
57
+ definition.kind
58
+ ), definition.kind, { internal: previous.inferredSymbol })
54
59
  };
55
60
  if (validateLayout) resolveLayout(program, config);
56
61
  return { kind, config, order };
@@ -76,11 +81,11 @@ export function createCategoricalLegendFromConfig(program, config, order) {
76
81
  } else {
77
82
  next = next
78
83
  .editSemantic({
79
- property: "guide.legend.color.scale",
84
+ property: `guide.legend.${kind}.scale`,
80
85
  value: config.scales[0]
81
86
  })
82
87
  .editSemantic({
83
- property: "guide.legend.color.title",
88
+ property: `guide.legend.${kind}.title`,
84
89
  value: config.title
85
90
  });
86
91
  }
@@ -91,9 +96,15 @@ export function createCategoricalLegendFromConfig(program, config, order) {
91
96
  });
92
97
  }
93
98
  next = next._withLegendConfig(kind, config);
94
- if (config.border !== false) next = next.createLegendBackground();
95
- next = next.createLegendSymbols().createLegendLabels();
96
- return config.titleVisible === false ? next : next.createLegendTitle();
99
+ if (config.border !== false) {
100
+ next = next.createLegendBackground({ kind });
101
+ }
102
+ next = next
103
+ .createLegendSymbols({ kind })
104
+ .createLegendLabels({ kind });
105
+ return config.titleVisible === false
106
+ ? next
107
+ : next.createLegendTitle({ kind });
97
108
  }
98
109
 
99
110
  // Categorical content precedes its sampled companions in both creation and revision.
@@ -125,7 +136,7 @@ export function reconcileCategoricalSymbols(program, previous, config) {
125
136
  for (const layer of config.symbol.layers) {
126
137
  if (next.graphicSpec.objects[symbolGraphic(config, layer.type)] === undefined) {
127
138
  const suffix = { line: "Lines", point: "Points", swatch: "Swatches" }[layer.type];
128
- next = next[`createLegendSymbol${suffix}`]();
139
+ next = next[`createLegendSymbol${suffix}`]({ kind: config.kind });
129
140
  }
130
141
  }
131
142
  return next;
@@ -6,17 +6,20 @@ import {
6
6
  createCategoricalLegendFromConfig
7
7
  } from "./lifecycle.js";
8
8
  import { resolveLegendTarget, validateLegendChannels } from "./target.js";
9
+ import { planLegendBlockTransitions } from "./transition.js";
9
10
 
10
11
  export { removeLegendKinds } from "./lifecycle.js";
11
12
 
12
13
  const OPTIONS = Object.freeze(["target", "channels"]);
13
14
 
14
15
  function legendKindChannels(kind, config) {
15
- if (["series", "color"].includes(kind)) return config.channels;
16
+ if (["series", "color", "stroke"].includes(kind)) return config.channels;
16
17
  return {
17
18
  size: ["size"],
18
19
  gradient: ["color"],
19
20
  interval: ["color"],
21
+ strokeGradient: ["stroke"],
22
+ strokeInterval: ["stroke"],
20
23
  opacity: ["opacity"],
21
24
  strokeWidth: ["strokeWidth"]
22
25
  }[kind];
@@ -46,8 +49,13 @@ function resolveRequestedRemoval(program, target, channels) {
46
49
  // Remove departing sibling configs from the immutable layout preflight view.
47
50
  const view = kinds.reduce((next, kind) =>
48
51
  next._withoutMaterializationConfig(["guides", "legend", kind]), program);
49
- const revision = partial === undefined ? undefined
52
+ const resolved = partial === undefined ? undefined
50
53
  : resolveCategoricalLegendRevision(view, partial.kind, partial.config, partial.channels);
54
+ const revision = resolved === undefined ? undefined : planLegendBlockTransitions(
55
+ program,
56
+ target,
57
+ [{ ...resolved, kind: resolved.config.kind }]
58
+ )[0];
51
59
  return { kinds, revision };
52
60
  }
53
61
 
@@ -81,7 +89,9 @@ export const removeLegend = action(
81
89
  : next.rematerializeLegend();
82
90
  }
83
91
  const remainingTargetKinds = targetKinds.filter(kind => !kinds.includes(kind));
84
- const removedCategorical = kinds.some(kind => ["series", "color"].includes(kind));
92
+ const removedCategorical = kinds.some(kind =>
93
+ ["series", "color", "stroke"].includes(kind)
94
+ );
85
95
  if (
86
96
  removedCategorical && revision === undefined &&
87
97
  remainingTargetKinds.includes("size") &&
@@ -0,0 +1,140 @@
1
+ import { sampleNumericRange } from "../../../grammar/numeric.js";
2
+ import {
3
+ validateGeneratedItemLimit
4
+ } from "../../../core/validation.js";
5
+
6
+ const DEFAULT_COUNT = 5;
7
+ const MAX_EXACT_VALUES = 100;
8
+
9
+ function validateCount(value, label) {
10
+ if (!Number.isInteger(value) || value < 2) {
11
+ throw new RangeError(`${label} count must be an integer of at least 2.`);
12
+ }
13
+ validateGeneratedItemLimit(value, `${label} count`);
14
+ return value;
15
+ }
16
+
17
+ function normalizeExactValues(value, label) {
18
+ if (!Array.isArray(value)) {
19
+ throw new TypeError(`${label} values must be an array or "auto".`);
20
+ }
21
+ if (value.length === 0 || value.length > MAX_EXACT_VALUES) {
22
+ throw new RangeError(
23
+ `${label} values must contain between 1 and ${MAX_EXACT_VALUES} values.`
24
+ );
25
+ }
26
+ const values = [...value];
27
+ for (let index = 0; index < values.length; index += 1) {
28
+ if (!Number.isFinite(values[index])) {
29
+ throw new TypeError(`${label} values must contain only finite numbers.`);
30
+ }
31
+ if (index > 0 && !(values[index] > values[index - 1])) {
32
+ throw new RangeError(
33
+ `${label} values must be unique and strictly increasing.`
34
+ );
35
+ }
36
+ }
37
+ return Object.freeze(values);
38
+ }
39
+
40
+ export function readLegendSampling(config, defaultCount = DEFAULT_COUNT) {
41
+ if (config?.sampling?.mode === "values") {
42
+ return {
43
+ mode: "values",
44
+ values: config.sampling.values,
45
+ count: config.sampling.count ?? config.count ?? defaultCount
46
+ };
47
+ }
48
+ return {
49
+ mode: "auto",
50
+ count: config?.sampling?.count ?? config?.count ?? defaultCount
51
+ };
52
+ }
53
+
54
+ export function normalizeLegendSampling(
55
+ patch,
56
+ { previous, operation = "create", label = "Legend" } = {}
57
+ ) {
58
+ const prior = readLegendSampling(previous);
59
+ const hasValues = Object.hasOwn(patch, "values");
60
+ const hasCount = Object.hasOwn(patch, "count");
61
+ const requestedValues = patch.values;
62
+
63
+ if (hasValues && requestedValues !== "auto" && hasCount) {
64
+ throw new Error(`${label} cannot specify both count and exact values.`);
65
+ }
66
+ if (requestedValues === "auto" && operation === "create") {
67
+ throw new Error(`${label} creation uses omission for automatic values.`);
68
+ }
69
+ if (!hasValues && !hasCount) {
70
+ return Object.freeze({
71
+ ...prior,
72
+ ...(prior.values === undefined
73
+ ? {}
74
+ : { values: Object.freeze([...prior.values]) })
75
+ });
76
+ }
77
+ if (!hasValues) {
78
+ if (prior.mode === "values") {
79
+ throw new Error(
80
+ `${label} count cannot replace exact values; set values to "auto" first.`
81
+ );
82
+ }
83
+ return Object.freeze({ mode: "auto", count: validateCount(patch.count, label) });
84
+ }
85
+ if (requestedValues === "auto") {
86
+ return Object.freeze({
87
+ mode: "auto",
88
+ count: hasCount ? validateCount(patch.count, label) : validateCount(prior.count, label)
89
+ });
90
+ }
91
+ return Object.freeze({
92
+ mode: "values",
93
+ values: normalizeExactValues(requestedValues, label),
94
+ count: validateCount(prior.count, label)
95
+ });
96
+ }
97
+
98
+ export function validateSamplingAgainstScale(sampling, scale, label = "Legend") {
99
+ if (sampling.mode !== "values") return sampling;
100
+ const domain = scale?.domain;
101
+ if (!Array.isArray(domain) || domain.length < 2 || !domain.every(Number.isFinite)) {
102
+ throw new Error(`${label} exact values require a finite quantitative scale domain.`);
103
+ }
104
+ const lower = Math.min(...domain);
105
+ const upper = Math.max(...domain);
106
+ for (const value of sampling.values) {
107
+ if (scale.type === "log" && value <= 0) {
108
+ throw new RangeError(`${label} exact values must be positive for a log scale.`);
109
+ }
110
+ if (value < lower || value > upper) {
111
+ throw new RangeError(
112
+ `${label} exact value ${value} is outside the scale domain [${lower}, ${upper}].`
113
+ );
114
+ }
115
+ }
116
+ return sampling;
117
+ }
118
+
119
+ export function resolveLegendSampleValues(config, scale, label = "Legend") {
120
+ const sampling = validateSamplingAgainstScale(
121
+ readLegendSampling(config),
122
+ scale,
123
+ label
124
+ );
125
+ return sampling.mode === "values"
126
+ ? [...sampling.values]
127
+ : sampleNumericRange(
128
+ scale.domain[0],
129
+ scale.domain[1],
130
+ sampling.count,
131
+ `${label} domain`
132
+ );
133
+ }
134
+
135
+ export function hasExactLegendSamplingForScale(program, scaleId) {
136
+ return ["size", "opacity", "strokeWidth"].some(kind => {
137
+ const config = program.guideConfigs.legend?.[kind];
138
+ return config?.scale === scaleId && readLegendSampling(config).mode === "values";
139
+ });
140
+ }
@@ -1,10 +1,14 @@
1
1
  import { action } from "../../../core/action.js";
2
2
  import {
3
- validateGeneratedItemLimit,
4
3
  validateNonEmptyString,
5
4
  validateKeys
6
5
  } from "../../../core/validation.js";
7
- import { mapLinearValues } from "../../../grammar/scales/index.js";
6
+ import {
7
+ formatDiscretizedIntervals,
8
+ isDiscreteSizeScaleType,
9
+ isSizeScaleType,
10
+ mapSizeValues
11
+ } from "../../../grammar/scales/index.js";
8
12
  import { resolveLegendItemLayout } from "../../../layout/legendItems.js";
9
13
  import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
10
14
  "../../../theme/defaults.js";
@@ -21,13 +25,18 @@ import {
21
25
  resolveContinuousBounds,
22
26
  resolveLegendBackgroundFromBounds,
23
27
  formatContinuousValues,
24
- sampleContinuousValues,
25
28
  selectLegendLayer,
26
29
  styleContinuousText
27
30
  } from "./continuous/common.js";
28
31
  import { legendResourcePolicies } from "../../../materialization/guides/resources.js";
32
+ import {
33
+ normalizeLegendSampling,
34
+ readLegendSampling,
35
+ resolveLegendSampleValues
36
+ } from "./sampling.js";
37
+ import { resolveEffectiveLegendBlockConfig } from "./blocks.js";
29
38
 
30
- const SIZE_OPTIONS = Object.freeze(["target", "count", "position", "layout", "align",
39
+ const SIZE_OPTIONS = Object.freeze(["target", "count", "values", "position", "layout", "align",
31
40
  "direction", "columns", "titlePosition", "offset", "itemGap", "title", "labels", "titleStyle", "border"]);
32
41
 
33
42
  export const SIZE_LEGEND_LABELS = Object.freeze({
@@ -61,21 +70,32 @@ export function resolveSizeLegendPoint(program, requested) {
61
70
  return layer;
62
71
  }
63
72
 
64
- function requireScale(program, id, type) {
73
+ function requireScale(program, id) {
65
74
  const scale = program.resolvedScales[id];
66
- if (scale?.type !== type) {
67
- throw new Error(`Legend requires resolved ${type} scale "${id}".`);
75
+ if (!isSizeScaleType(scale?.type)) {
76
+ throw new Error(`Legend requires resolved size scale "${id}".`);
68
77
  }
69
78
  return scale;
70
79
  }
71
80
 
72
81
  export function resolveSizeLegendLayout(program, config) {
73
- const scale = requireScale(program, config.scale, "linear");
74
- const categorical = [program.guideConfigs.legend?.series, program.guideConfigs.legend?.color]
82
+ const scale = requireScale(program, config.scale);
83
+ const categorical = [
84
+ program.guideConfigs.legend?.series,
85
+ program.guideConfigs.legend?.color,
86
+ program.guideConfigs.legend?.stroke
87
+ ]
75
88
  .find(candidate => candidate?.target === config.target);
76
89
  const inherit = config.inheritAppearance === true && categorical !== undefined;
77
- const labels = inherit ? { ...categorical.labels, offset: config.labels.offset } : config.labels;
78
- const titleStyle = inherit ? categorical.titleStyle : config.titleStyle;
90
+ const inheritedLabels = inherit ? { ...categorical.labels, offset: config.labels.offset } : config.labels;
91
+ const inheritedTitleStyle = inherit ? categorical.titleStyle : config.titleStyle;
92
+ const effective = resolveEffectiveLegendBlockConfig(program, "size", {
93
+ ...config,
94
+ labels: inheritedLabels,
95
+ titleStyle: inheritedTitleStyle
96
+ });
97
+ const labels = effective.labels;
98
+ const titleStyle = effective.titleStyle;
79
99
  const position = categorical?.position ?? config.position;
80
100
  if (categorical?.layout === "legacy-bottom") {
81
101
  throw new Error('Combined size legends require layout "edge".');
@@ -84,27 +104,46 @@ export function resolveSizeLegendLayout(program, config) {
84
104
  const geometry = horizontal ? {
85
105
  position, align: categorical.align, direction: categorical.direction,
86
106
  columns: categorical.columns, titlePosition: categorical.titlePosition,
87
- offset: categorical.offset, itemGap: categorical.itemGap
107
+ offset: categorical.offset, itemGap: effective.blockGap ?? categorical.itemGap
88
108
  } : { position };
89
- const values = sampleContinuousValues(scale.domain, config.count);
90
- const areas = mapLinearValues(values, scale.domain, scale.range, { clamp: scale.clamp ?? false });
109
+ const discrete = isDiscreteSizeScaleType(scale.type);
110
+ if (discrete && readLegendSampling(config).mode === "values") {
111
+ throw new Error("Discrete size legends do not support exact values.");
112
+ }
113
+ const values = discrete
114
+ ? undefined
115
+ : resolveLegendSampleValues(effective, scale, "Size legend");
116
+ const areas = discrete
117
+ ? scale.range
118
+ : mapSizeValues(values, scale);
91
119
  const radii = areas.map(area => Math.sqrt(area / Math.PI));
92
120
  const radius = Math.max(...radii);
93
121
  const width = Math.max(32, radius * 2);
94
122
  const { plot, canvas } = resolveContinuousBounds(program);
95
- const text = formatContinuousValues(
96
- values,
97
- scale.domain,
98
- "quantitative",
99
- config.labels.format
100
- );
101
- const layout = resolveLegendItemLayout(plot, { ...config, ...geometry, labels, titleStyle }, text, {
102
- width, height: radius * 2,
103
- itemBounds: radii.map(r => ({ left: width / 2 - r, right: width / 2 + r, top: -r, bottom: r }))
123
+ const text = discrete
124
+ ? formatDiscretizedIntervals(scale.thresholds, config.labels.format)
125
+ : formatContinuousValues(
126
+ values,
127
+ scale.domain,
128
+ "quantitative",
129
+ config.labels.format
130
+ );
131
+ const symbolStroke = effective.blockSymbol?.strokeWidth ?? 0;
132
+ const symbolExtent = symbolStroke / 2;
133
+ const sampleWidth = width + symbolStroke;
134
+ const layout = resolveLegendItemLayout(plot, { ...effective, ...geometry, labels, titleStyle }, text, {
135
+ width: sampleWidth, height: radius * 2 + symbolStroke,
136
+ itemBounds: radii.map(r => ({
137
+ left: sampleWidth / 2 - r - symbolExtent,
138
+ right: sampleWidth / 2 + r + symbolExtent,
139
+ top: -r - symbolExtent,
140
+ bottom: r + symbolExtent
141
+ }))
104
142
  });
105
- assertLegendBoundsInsideCanvas(layout.bounds, canvas, "Size legend layout", { ...config, ...geometry });
106
- const background = resolveLegendBackgroundFromBounds(layout.bounds, config.border, canvas, "Size legend", { ...config, ...geometry });
107
- return { ...layout, symbolX: layout.symbolX.map(x => x + width / 2), radii, text, labels, titleStyle, background };
143
+ assertLegendBoundsInsideCanvas(layout.bounds, canvas, "Size legend layout", { ...effective, ...geometry });
144
+ const background = resolveLegendBackgroundFromBounds(layout.bounds, effective.border, canvas, "Size legend", { ...effective, ...geometry });
145
+ return { ...layout, symbolX: layout.symbolX.map(x => x + sampleWidth / 2), radii, text,
146
+ labels, titleStyle, background, config: effective };
108
147
  }
109
148
 
110
149
  export const rematerializeSizeLegend = action(
@@ -121,7 +160,7 @@ export const rematerializeSizeLegend = action(
121
160
  if (encoding?.scale === undefined) {
122
161
  throw new Error("Size legend target requires a size encoding.");
123
162
  }
124
- const scale = requireScale(this, encoding.scale, "linear");
163
+ const scale = requireScale(this, encoding.scale);
125
164
  const title = config.inferredTitle === true ? encoding.field : config.title;
126
165
  const currentConfig = {
127
166
  ...config,
@@ -130,6 +169,7 @@ export const rematerializeSizeLegend = action(
130
169
  domain: scale.domain
131
170
  };
132
171
  const layout = resolveSizeLegendLayout(this, currentConfig);
172
+ const effective = layout.config;
133
173
  const { itemY, symbolX, labelX, radii, labels, titleStyle } = layout;
134
174
  let next = this
135
175
  .editSemantic({ property: "guide.legend.size.scale", value: encoding.scale })
@@ -146,9 +186,10 @@ export const rematerializeSizeLegend = action(
146
186
  .editGraphics({
147
187
  target: "sizeLegendSymbols",
148
188
  property: "fill",
149
- value: DEFAULT_COLORS.sizeSymbol
189
+ value: effective.blockSymbol?.fill ?? DEFAULT_COLORS.sizeSymbol
150
190
  })
151
- .editGraphics({ target: "sizeLegendSymbols", property: "opacity", value: 0.7 })
191
+ .editGraphics({ target: "sizeLegendSymbols", property: "opacity",
192
+ value: effective.blockSymbol?.opacity ?? 0.7 })
152
193
  .editGraphics({ target: "sizeLegendLabels", property: "length", value: radii.length })
153
194
  .editGraphics({ target: "sizeLegendLabels", property: "x", value: labelX })
154
195
  .editGraphics({ target: "sizeLegendLabels", property: "y", value: itemY })
@@ -158,12 +199,20 @@ export const rematerializeSizeLegend = action(
158
199
  value: layout.text
159
200
  });
160
201
  next = editLegendBackground(next, "sizeLegendBackground", layout.background, currentConfig.border);
202
+ if (effective.blockSymbol?.stroke !== undefined) {
203
+ next = next.editGraphics({ target: "sizeLegendSymbols", property: "stroke",
204
+ value: effective.blockSymbol.stroke });
205
+ }
206
+ if (effective.blockSymbol?.strokeWidth !== undefined) {
207
+ next = next.editGraphics({ target: "sizeLegendSymbols", property: "strokeWidth",
208
+ value: effective.blockSymbol.strokeWidth });
209
+ }
161
210
  next = styleContinuousText(next, "sizeLegendLabels", labels);
162
- if (currentConfig.titleVisible === false) return next;
211
+ if (effective.titleVisible === false) return next;
163
212
  next = next
164
213
  .editGraphics({ target: "sizeLegendTitle", property: "x", value: layout.title.x })
165
214
  .editGraphics({ target: "sizeLegendTitle", property: "y", value: layout.title.y })
166
- .editGraphics({ target: "sizeLegendTitle", property: "text", value: title });
215
+ .editGraphics({ target: "sizeLegendTitle", property: "text", value: effective.title });
167
216
  return styleContinuousText(next, "sizeLegendTitle", titleStyle, { align: layout.title.align });
168
217
  }
169
218
  );
@@ -177,12 +226,16 @@ export function resolveSizeLegendConfig(program, args = {}) {
177
226
  if (encoding?.scale === undefined) {
178
227
  throw new Error(`Point mark "${layer.id}" requires a size encoding.`);
179
228
  }
180
- const scale = requireScale(program, encoding.scale, "linear");
181
- const count = args.count ?? 5;
182
- if (!Number.isInteger(count) || count < 2) {
183
- throw new RangeError("Size legend count must be an integer of at least 2.");
229
+ const scale = requireScale(program, encoding.scale);
230
+ const discrete = isDiscreteSizeScaleType(scale.type);
231
+ if (discrete && (Object.hasOwn(args, "count") || Object.hasOwn(args, "values"))) {
232
+ throw new Error("Discrete size legends do not support count or exact values.");
184
233
  }
185
- validateGeneratedItemLimit(count, "Size legend count");
234
+ const sampling = discrete ? undefined : normalizeLegendSampling(args, {
235
+ operation: "create",
236
+ label: "Size legend"
237
+ });
238
+ const count = discrete ? scale.range.length : undefined;
186
239
  return {
187
240
  target: layer.id,
188
241
  scale: encoding.scale,
@@ -190,7 +243,7 @@ export function resolveSizeLegendConfig(program, args = {}) {
190
243
  title: args.title ?? encoding.field,
191
244
  inferredTitle: args.title === undefined,
192
245
  domain: scale.domain,
193
- count,
246
+ ...(discrete ? { count } : { sampling }),
194
247
  inheritAppearance: args.inheritAppearance === true,
195
248
  labels: normalizeLegendTextOptions(args.labels, "createLegend.labels", SIZE_LEGEND_LABELS),
196
249
  titleStyle: normalizeLegendTitleOptions(args.titleStyle, "createLegend.titleStyle", SIZE_LEGEND_TITLE_STYLE),
@@ -200,8 +253,8 @@ export function resolveSizeLegendConfig(program, args = {}) {
200
253
  }
201
254
 
202
255
  export function createSizeLegendFromConfig(program, config) {
203
- resolveSizeLegendLayout(program, config);
204
- const { count } = config;
256
+ const { radii } = resolveSizeLegendLayout(program, config);
257
+ const count = radii.length;
205
258
  const following = new Set(legendResourcePolicies().filter(policy =>
206
259
  policy.kind !== "size" && policy.family !== "categorical" &&
207
260
  program.guideConfigs.legend?.[policy.kind] !== undefined
@@ -241,7 +294,7 @@ export function createSizeLegendFromConfig(program, config) {
241
294
  export const createSizeLegend = action(
242
295
  {
243
296
  op: "createSizeLegend",
244
- description: "Create a quantitative equal-area point-size legend."
297
+ description: "Create an equal-area point-size legend."
245
298
  },
246
299
  function (args = {}) {
247
300
  const config = resolveSizeLegendConfig(this, args);
@@ -1,6 +1,5 @@
1
1
  import { action } from "../../../core/action.js";
2
2
  import {
3
- validateGeneratedItemLimit,
4
3
  validateNonEmptyString,
5
4
  validateKeys
6
5
  } from "../../../core/validation.js";
@@ -21,12 +20,16 @@ import {
21
20
  resolveContinuousBounds,
22
21
  resolveLegendBackgroundFromBounds,
23
22
  formatContinuousValues,
24
- sampleContinuousValues,
25
23
  selectLegendLayer,
26
24
  styleContinuousText
27
25
  } from "./continuous/common.js";
26
+ import {
27
+ normalizeLegendSampling,
28
+ resolveLegendSampleValues
29
+ } from "./sampling.js";
30
+ import { resolveEffectiveLegendBlockConfig } from "./blocks.js";
28
31
 
29
- const OPTIONS = Object.freeze(["target", "count", "position", "layout", "align",
32
+ const OPTIONS = Object.freeze(["target", "count", "values", "position", "layout", "align",
30
33
  "direction", "columns", "titlePosition", "offset", "itemGap", "title", "labels", "titleStyle", "border"]);
31
34
 
32
35
  export const STROKE_WIDTH_LEGEND_LABELS = Object.freeze({
@@ -74,20 +77,21 @@ function requireScale(program, id) {
74
77
  }
75
78
 
76
79
  export function resolveStrokeWidthLegendLayout(program, config) {
80
+ const effective = resolveEffectiveLegendBlockConfig(program, "strokeWidth", config);
77
81
  const scale = requireScale(program, config.scale);
78
82
  const { plot, canvas } = resolveContinuousBounds(program);
79
- const values = sampleContinuousValues(scale.domain, config.count);
83
+ const values = resolveLegendSampleValues(effective, scale, "Stroke-width legend");
80
84
  const widths = mapContinuousScaleValues(values, scale);
81
85
  const labels = formatContinuousValues(
82
86
  values,
83
87
  scale.domain,
84
88
  "quantitative",
85
- config.labels.format
89
+ effective.labels.format
86
90
  );
87
- const layout = resolveLegendItemLayout(plot, config, labels, { width: 32, height: 0, strokeWidth: widths });
88
- assertLegendBoundsInsideCanvas(layout.bounds, canvas, "Stroke-width legend layout", config);
89
- const background = resolveLegendBackgroundFromBounds(layout.bounds, config.border, canvas, "Stroke-width legend", config);
90
- return { ...layout, widths, labels, background };
91
+ const layout = resolveLegendItemLayout(plot, effective, labels, { width: 32, height: 0, strokeWidth: widths });
92
+ assertLegendBoundsInsideCanvas(layout.bounds, canvas, "Stroke-width legend layout", effective);
93
+ const background = resolveLegendBackgroundFromBounds(layout.bounds, effective.border, canvas, "Stroke-width legend", effective);
94
+ return { ...layout, widths, labels, background, config: effective };
91
95
  }
92
96
 
93
97
  export const rematerializeStrokeWidthLegend = /* @__PURE__ */ action(
@@ -114,15 +118,15 @@ export const rematerializeStrokeWidthLegend = /* @__PURE__ */ action(
114
118
  }
115
119
  const scale = requireScale(this, encoding.scale);
116
120
  const title = config.inferredTitle === true ? encoding.field : config.title;
117
- const layout = resolveStrokeWidthLegendLayout(this, { ...config, scale: encoding.scale, title });
121
+ const currentConfig = { ...config, scale: encoding.scale, title };
122
+ const layout = resolveStrokeWidthLegendLayout(this, currentConfig);
123
+ const effective = layout.config;
118
124
  const { itemY: y, widths } = layout;
119
125
  let next = this
120
126
  .editSemantic({ property: "guide.legend.strokeWidth.scale", value: encoding.scale })
121
127
  .editSemantic({ property: "guide.legend.strokeWidth.title", value: title })
122
128
  ._withLegendConfig("strokeWidth", {
123
- ...config,
124
- scale: encoding.scale,
125
- title,
129
+ ...currentConfig,
126
130
  domain: scale.domain
127
131
  })
128
132
  .editGraphics({ target: "strokeWidthLegendSymbols", property: "length", value: widths.length })
@@ -130,7 +134,8 @@ export const rematerializeStrokeWidthLegend = /* @__PURE__ */ action(
130
134
  .editGraphics({ target: "strokeWidthLegendSymbols", property: "x2", value: layout.symbolX.map(x => x + 32) })
131
135
  .editGraphics({ target: "strokeWidthLegendSymbols", property: "y1", value: y })
132
136
  .editGraphics({ target: "strokeWidthLegendSymbols", property: "y2", value: y })
133
- .editGraphics({ target: "strokeWidthLegendSymbols", property: "stroke", value: DEFAULT_COLORS.mark })
137
+ .editGraphics({ target: "strokeWidthLegendSymbols", property: "stroke",
138
+ value: effective.blockSymbol?.stroke ?? DEFAULT_COLORS.mark })
134
139
  .editGraphics({ target: "strokeWidthLegendSymbols", property: "strokeWidth", value: widths })
135
140
  .editGraphics({ target: "strokeWidthLegendLabels", property: "length", value: widths.length })
136
141
  .editGraphics({
@@ -145,13 +150,17 @@ export const rematerializeStrokeWidthLegend = /* @__PURE__ */ action(
145
150
  value: layout.labels
146
151
  });
147
152
  next = editLegendBackground(next, "strokeWidthLegendBackground", layout.background, config.border);
148
- next = styleContinuousText(next, "strokeWidthLegendLabels", config.labels);
149
- if (config.titleVisible === false) return next;
153
+ if (effective.blockSymbol?.opacity !== undefined) {
154
+ next = next.editGraphics({ target: "strokeWidthLegendSymbols", property: "opacity",
155
+ value: effective.blockSymbol.opacity });
156
+ }
157
+ next = styleContinuousText(next, "strokeWidthLegendLabels", effective.labels);
158
+ if (effective.titleVisible === false) return next;
150
159
  next = next
151
160
  .editGraphics({ target: "strokeWidthLegendTitle", property: "x", value: layout.title.x })
152
161
  .editGraphics({ target: "strokeWidthLegendTitle", property: "y", value: layout.title.y })
153
- .editGraphics({ target: "strokeWidthLegendTitle", property: "text", value: title });
154
- return styleContinuousText(next, "strokeWidthLegendTitle", config.titleStyle, { align: layout.title.align });
162
+ .editGraphics({ target: "strokeWidthLegendTitle", property: "text", value: effective.title });
163
+ return styleContinuousText(next, "strokeWidthLegendTitle", effective.titleStyle, { align: layout.title.align });
155
164
  }
156
165
  );
157
166
 
@@ -162,20 +171,17 @@ export function resolveStrokeWidthLegendConfig(program, args = {}) {
162
171
  const layer = resolveLayer(program, args.target);
163
172
  const encoding = layer.encoding.strokeWidth;
164
173
  requireScale(program, encoding.scale);
165
- const count = args.count ?? 5;
166
- if (!Number.isInteger(count) || count < 2) {
167
- throw new RangeError(
168
- "Stroke-width legend count must be an integer of at least 2."
169
- );
170
- }
171
- validateGeneratedItemLimit(count, "Stroke-width legend count");
174
+ const sampling = normalizeLegendSampling(args, {
175
+ operation: "create",
176
+ label: "Stroke-width legend"
177
+ });
172
178
  return {
173
179
  target: layer.id,
174
180
  scale: encoding.scale,
175
181
  ...normalizeItemLegendLayout({ ...args, itemGap: args.itemGap ?? 32 }),
176
182
  title: args.title ?? encoding.field,
177
183
  inferredTitle: args.title === undefined,
178
- count,
184
+ sampling,
179
185
  labels: normalizeLegendTextOptions(args.labels, "createLegend.labels", STROKE_WIDTH_LEGEND_LABELS),
180
186
  titleStyle: normalizeLegendTitleOptions(args.titleStyle, "createLegend.titleStyle", STROKE_WIDTH_LEGEND_TITLE_STYLE),
181
187
  border: normalizeLegendBorder(args.border),
@@ -184,8 +190,8 @@ export function resolveStrokeWidthLegendConfig(program, args = {}) {
184
190
  }
185
191
 
186
192
  export function createStrokeWidthLegendFromConfig(program, config) {
187
- resolveStrokeWidthLegendLayout(program, config);
188
- const { count } = config;
193
+ const layout = resolveStrokeWidthLegendLayout(program, config);
194
+ const count = layout.widths.length;
189
195
  let next = program
190
196
  .editSemantic({ property: "guide.legend.strokeWidth.scale", value: config.scale })
191
197
  .editSemantic({ property: "guide.legend.strokeWidth.title", value: config.title })