ggaction 0.0.11 → 0.0.13

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 (289) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +3 -2
  3. package/knowledge/action-card.schema.json +1 -1
  4. package/knowledge/action-cards.json +1 -1
  5. package/knowledge/action-cards.schema.json +1 -1
  6. package/knowledge/extension-authoring.md +2 -1
  7. package/knowledge/intent-taxonomy.json +1 -1
  8. package/knowledge/mcp-resources.json +1 -1
  9. package/knowledge/task-resolver.js +1 -1
  10. package/package.json +4 -2
  11. package/src/actions/basic.js +2 -0
  12. package/src/actions/boxPlots/materialize.js +2 -1
  13. package/src/actions/canvas/actions.js +3 -1
  14. package/src/actions/canvas/fitting.js +211 -0
  15. package/src/actions/canvas/index.js +2 -0
  16. package/src/actions/categoryOrder/index.js +3 -2
  17. package/src/actions/charts/area.js +47 -0
  18. package/src/actions/charts/bar.js +11 -5
  19. package/src/actions/charts/beeswarm.js +70 -0
  20. package/src/actions/charts/density.js +44 -0
  21. package/src/actions/charts/ecdf.js +183 -0
  22. package/src/actions/charts/endpoints.js +451 -0
  23. package/src/actions/charts/heatmap.js +2 -2
  24. package/src/actions/charts/histogram.js +1 -1
  25. package/src/actions/charts/horizon.js +71 -0
  26. package/src/actions/charts/index.js +37 -0
  27. package/src/actions/charts/interval-regression.js +169 -0
  28. package/src/actions/charts/line.js +5 -5
  29. package/src/actions/charts/parallel.js +1 -1
  30. package/src/actions/charts/pie.js +44 -0
  31. package/src/actions/charts/polar.js +123 -0
  32. package/src/actions/charts/radar.js +299 -0
  33. package/src/actions/charts/radial.js +42 -0
  34. package/src/actions/charts/raincloud.js +422 -0
  35. package/src/actions/charts/rug-strip.js +279 -0
  36. package/src/actions/charts/scatter.js +9 -3
  37. package/src/actions/charts/shared.js +69 -10
  38. package/src/actions/composition/actions.js +118 -1
  39. package/src/actions/data/bin.js +29 -0
  40. package/src/actions/data/computed.js +24 -0
  41. package/src/actions/data/derived.js +66 -0
  42. package/src/actions/data/ecdf.js +38 -0
  43. package/src/actions/data/filter.js +212 -28
  44. package/src/actions/data/fold.js +24 -0
  45. package/src/actions/data/index.js +24 -0
  46. package/src/actions/data/interval.js +2 -0
  47. package/src/actions/data/intervalEdit.js +86 -4
  48. package/src/actions/data/regression.js +5 -2
  49. package/src/actions/data/stack.js +26 -0
  50. package/src/actions/data/summary.js +26 -0
  51. package/src/actions/data/timeUnit.js +1 -1
  52. package/src/actions/encodings/appearance.js +32 -13
  53. package/src/actions/encodings/barWidth.js +8 -0
  54. package/src/actions/encodings/basic.js +2 -0
  55. package/src/actions/encodings/color/continuous.js +19 -59
  56. package/src/actions/encodings/color/index.js +28 -22
  57. package/src/actions/encodings/color/layout.js +3 -97
  58. package/src/actions/encodings/color/policy.js +5 -8
  59. package/src/actions/encodings/density/resolve.js +37 -5
  60. package/src/actions/encodings/density.js +120 -33
  61. package/src/actions/encodings/horizon/resolve.js +8 -4
  62. package/src/actions/encodings/horizon.js +18 -8
  63. package/src/actions/encodings/index.js +2 -0
  64. package/src/actions/encodings/offset.js +33 -27
  65. package/src/actions/encodings/parallel.js +2 -8
  66. package/src/actions/encodings/position/apply.js +40 -4
  67. package/src/actions/encodings/position/index.js +43 -5
  68. package/src/actions/encodings/position/policies/arc.js +37 -2
  69. package/src/actions/encodings/position/policies/area.js +4 -42
  70. package/src/actions/encodings/position/policies/bar.js +48 -13
  71. package/src/actions/encodings/position/resolve.js +79 -24
  72. package/src/actions/encodings/ranged.js +123 -97
  73. package/src/actions/encodings/remove.js +37 -32
  74. package/src/actions/encodings/ruleAppearance.js +23 -9
  75. package/src/actions/encodings/seriesLayout.js +104 -0
  76. package/src/actions/encodings/shared.js +25 -23
  77. package/src/actions/encodings/strokeDash.js +9 -6
  78. package/src/actions/encodings/temporal.js +7 -0
  79. package/src/actions/encodings/text.js +30 -15
  80. package/src/actions/errorBands/create.js +18 -5
  81. package/src/actions/errorBands/edit.js +341 -21
  82. package/src/actions/errorBars/create.js +153 -11
  83. package/src/actions/errorBars/edit.js +365 -19
  84. package/src/actions/facets/actions.js +410 -12
  85. package/src/actions/facets/derive.js +57 -8
  86. package/src/actions/gradientPlots/components.js +33 -11
  87. package/src/actions/gradientPlots/materialize.js +5 -7
  88. package/src/actions/guides/applicability.js +20 -17
  89. package/src/actions/guides/axes/axes.js +4 -3
  90. package/src/actions/guides/axes/axis.js +18 -18
  91. package/src/actions/guides/axes/components.js +43 -0
  92. package/src/actions/guides/axes/edit.js +16 -51
  93. package/src/actions/guides/axes/labels.js +129 -19
  94. package/src/actions/guides/axes/lines.js +7 -5
  95. package/src/actions/guides/axes/parallel/lifecycle.js +117 -0
  96. package/src/actions/guides/axes/parallel/policy.js +137 -0
  97. package/src/actions/guides/axes/parallel/resolve.js +44 -23
  98. package/src/actions/guides/axes/parallel.js +93 -217
  99. package/src/actions/guides/axes/policy.js +21 -67
  100. package/src/actions/guides/axes/tickGroups.js +14 -8
  101. package/src/actions/guides/axes/ticks.js +5 -4
  102. package/src/actions/guides/axes/titles.js +21 -8
  103. package/src/actions/guides/basic.js +2 -0
  104. package/src/actions/guides/facade.js +106 -0
  105. package/src/actions/guides/facadeAxes.js +200 -0
  106. package/src/actions/guides/facadeLegend.js +63 -0
  107. package/src/actions/guides/grids/grid.js +37 -33
  108. package/src/actions/guides/grids/resolve.js +10 -2
  109. package/src/actions/guides/guides.js +35 -28
  110. package/src/actions/guides/legends/categorical/actions.js +193 -249
  111. package/src/actions/guides/legends/categorical/components.js +4 -5
  112. package/src/actions/guides/legends/categorical/layout.js +36 -349
  113. package/src/actions/guides/legends/categorical/options.js +30 -14
  114. package/src/actions/guides/legends/categorical/recipes.js +14 -0
  115. package/src/actions/guides/legends/categorical/resolve.js +21 -10
  116. package/src/actions/guides/legends/categorical/symbols.js +2 -3
  117. package/src/actions/guides/legends/continuous/common.js +69 -14
  118. package/src/actions/guides/legends/continuous/gradient.js +111 -83
  119. package/src/actions/guides/legends/continuous/index.js +6 -2
  120. package/src/actions/guides/legends/continuous/interval.js +71 -92
  121. package/src/actions/guides/legends/continuous/opacity.js +105 -84
  122. package/src/actions/guides/legends/creation.js +27 -0
  123. package/src/actions/guides/legends/edit.js +198 -66
  124. package/src/actions/guides/legends/focused.js +2 -2
  125. package/src/actions/guides/legends/lane.js +98 -86
  126. package/src/actions/guides/legends/lifecycle.js +132 -0
  127. package/src/actions/guides/legends/remove.js +31 -76
  128. package/src/actions/guides/legends/size.js +149 -105
  129. package/src/actions/guides/legends/strokeWidth.js +106 -64
  130. package/src/actions/guides/legends/target.js +12 -0
  131. package/src/actions/guides/legends/transition.js +43 -0
  132. package/src/actions/guides/polar/axes/facade.js +77 -62
  133. package/src/actions/guides/polar/axes/labels.js +5 -4
  134. package/src/actions/guides/polar/axes/lines.js +2 -1
  135. package/src/actions/guides/polar/axes/shared.js +6 -0
  136. package/src/actions/guides/polar/axes/ticks.js +5 -4
  137. package/src/actions/guides/polar/axes/titles.js +5 -4
  138. package/src/actions/guides/polar/grids.js +7 -7
  139. package/src/actions/guides/reuse.js +44 -0
  140. package/src/actions/guides/tickValues.js +19 -21
  141. package/src/actions/index.js +2 -0
  142. package/src/actions/intervals/resolve.js +35 -15
  143. package/src/actions/marks/arc/actions.js +1 -1
  144. package/src/actions/marks/area/actions.js +18 -4
  145. package/src/actions/marks/area/materialize.js +5 -2
  146. package/src/actions/marks/bar/materialize.js +11 -7
  147. package/src/actions/marks/index.js +5 -0
  148. package/src/actions/marks/line/actions.js +43 -108
  149. package/src/actions/marks/line/materialize.js +34 -52
  150. package/src/actions/marks/point/edit.js +11 -15
  151. package/src/actions/marks/point/index.js +1 -0
  152. package/src/actions/marks/point/jitter.js +3 -0
  153. package/src/actions/marks/point/materialize.js +138 -14
  154. package/src/actions/marks/point/packing.js +68 -0
  155. package/src/actions/marks/references.js +87 -0
  156. package/src/actions/marks/remove.js +17 -4
  157. package/src/actions/marks/rule/actions.js +60 -4
  158. package/src/actions/marks/rule/appearance.js +26 -0
  159. package/src/actions/marks/shared.js +12 -12
  160. package/src/actions/marks/text/actions.js +224 -6
  161. package/src/actions/marks/text/layout.js +2 -2
  162. package/src/actions/primitives/createGraphics.js +3 -1
  163. package/src/actions/primitives/semantic.js +1 -1
  164. package/src/actions/primitives/semanticAction.js +39 -2
  165. package/src/actions/primitives/semanticValidation/guide.js +10 -0
  166. package/src/actions/primitives/semanticValidation/layer.js +18 -1
  167. package/src/actions/primitives/semanticValidation/scale.js +10 -1
  168. package/src/actions/regression/create.js +1 -1
  169. package/src/actions/regression/edit.js +19 -6
  170. package/src/actions/regression/resolve.js +1 -1
  171. package/src/actions/scales/consumers/common.js +20 -53
  172. package/src/actions/scales/consumers/families.js +27 -14
  173. package/src/actions/scales/consumers/index.js +23 -2
  174. package/src/actions/scales/consumers/seriesLayout.js +33 -2
  175. package/src/actions/scales/create.js +4 -4
  176. package/src/actions/scales/definitions.js +19 -3
  177. package/src/actions/scales/edit.js +46 -34
  178. package/src/actions/scales/editPolicy.js +21 -31
  179. package/src/actions/scales/materialize.js +10 -46
  180. package/src/actions/scales/patch.js +1 -1
  181. package/src/actions/scales/preview.js +39 -0
  182. package/src/actions/scales/quantitativeColor.js +9 -5
  183. package/src/actions/theme/actions.js +43 -0
  184. package/src/actions/theme/index.js +9 -0
  185. package/src/actions/theme/reconcile.js +882 -0
  186. package/src/actions/titles/actions.js +11 -10
  187. package/src/actions/titles/resolve.js +1 -40
  188. package/src/actions/violinPlots/create.js +86 -42
  189. package/src/actions/violinPlots/edit.js +191 -0
  190. package/src/actions/violinPlots/index.js +2 -0
  191. package/src/core/ChartProgram.js +7 -0
  192. package/src/core/action.js +22 -2
  193. package/src/core/compositionState.js +136 -8
  194. package/src/core/vocabulary.js +2 -2
  195. package/src/grammar/aggregate.js +66 -23
  196. package/src/grammar/arcs.js +213 -65
  197. package/src/grammar/areaEndpoints.js +46 -0
  198. package/src/grammar/areaSeries.js +87 -41
  199. package/src/grammar/bars/aggregate.js +33 -12
  200. package/src/grammar/bars/policy.js +9 -7
  201. package/src/grammar/bin.js +181 -0
  202. package/src/grammar/categoryOrder.js +34 -0
  203. package/src/grammar/computed.js +150 -0
  204. package/src/grammar/ecdf.js +198 -0
  205. package/src/grammar/facets/index.js +117 -6
  206. package/src/grammar/fold.js +123 -0
  207. package/src/grammar/horizon.js +6 -4
  208. package/src/grammar/interval.js +37 -20
  209. package/src/grammar/lineSeries.js +20 -63
  210. package/src/grammar/markFilter.js +22 -4
  211. package/src/grammar/markLabels.js +93 -0
  212. package/src/grammar/parallelAxisTitles.js +16 -0
  213. package/src/grammar/pathSeries.js +91 -0
  214. package/src/grammar/pointPacking.js +183 -0
  215. package/src/grammar/positionCompatibility.js +1 -1
  216. package/src/grammar/positionDatum.js +12 -0
  217. package/src/grammar/rects.js +9 -1
  218. package/src/grammar/regression/derive.js +4 -0
  219. package/src/grammar/regression/index.js +7 -2
  220. package/src/grammar/regression/models.js +22 -7
  221. package/src/grammar/regression/parameters.js +40 -13
  222. package/src/grammar/rotation.js +29 -0
  223. package/src/grammar/rules.js +9 -21
  224. package/src/grammar/scales/color.js +23 -1
  225. package/src/grammar/scales/colorConsumers.js +38 -0
  226. package/src/grammar/scales/definition.js +15 -1
  227. package/src/grammar/scales/discretized.js +11 -5
  228. package/src/grammar/scales/fields.js +38 -8
  229. package/src/grammar/scales/mapping.js +2 -0
  230. package/src/grammar/scales/ordinal.js +23 -0
  231. package/src/grammar/scales/radial.js +55 -0
  232. package/src/grammar/scales/types.js +1 -0
  233. package/src/grammar/schemas/semanticPath.js +17 -3
  234. package/src/grammar/stack.js +170 -0
  235. package/src/grammar/statistics/confidenceInterval.js +101 -0
  236. package/src/grammar/summary.js +183 -0
  237. package/src/grammar/text.js +22 -16
  238. package/src/grammar/timeUnit.js +21 -11
  239. package/src/grammar/transformTopology.js +6 -0
  240. package/src/grammar/transforms.js +36 -0
  241. package/src/grammar/valueFormat.js +112 -0
  242. package/src/grammar/window.js +13 -7
  243. package/src/layout/facets.js +86 -23
  244. package/src/layout/guideCollisions.js +12 -0
  245. package/src/layout/legend.js +2 -10
  246. package/src/layout/legendItems.js +102 -0
  247. package/src/layout/legendLane.js +100 -11
  248. package/src/materialization/bars/aggregate.js +38 -152
  249. package/src/materialization/bars/histogram.js +24 -0
  250. package/src/materialization/categorySlotOffset.js +40 -0
  251. package/src/materialization/compositionSnapshot.js +1 -1
  252. package/src/materialization/dependencies.js +57 -16
  253. package/src/materialization/encodings.js +1 -1
  254. package/src/materialization/facetGuides/legacyCategorical.js +7 -1
  255. package/src/materialization/facetGuides/placement.js +16 -1
  256. package/src/materialization/facetGuides/preparation.js +26 -1
  257. package/src/materialization/facets.js +11 -2
  258. package/src/materialization/guides/layout.js +57 -0
  259. package/src/materialization/guides/resources.js +2 -2
  260. package/src/materialization/legends.js +10 -1
  261. package/src/materialization/marks/capabilities.js +30 -7
  262. package/src/materialization/marks/grouping.js +52 -0
  263. package/src/materialization/marks/index.js +63 -25
  264. package/src/materialization/marks/policies.js +2 -0
  265. package/src/materialization/rect.js +33 -33
  266. package/src/materialization/rowEncoding.js +70 -14
  267. package/src/materialization/scaleGuideDependencies.js +8 -5
  268. package/src/materialization/scales/map.js +1 -0
  269. package/src/materialization/scales/policies/arc.js +42 -0
  270. package/src/materialization/scales/policies/bar.js +0 -43
  271. package/src/materialization/scales/policies/binnedPosition.js +7 -4
  272. package/src/materialization/scales/policies/offset.js +54 -0
  273. package/src/materialization/scales/policies/series.js +2 -1
  274. package/src/materialization/scales/resolve.js +48 -18
  275. package/src/materialization/selection/compatibility.js +11 -0
  276. package/src/materialization/selection/items/bar.js +12 -68
  277. package/src/materialization/selection/items/common.js +8 -2
  278. package/src/materialization/selection/items/path.js +3 -13
  279. package/src/materialization/selection/items/rect.js +7 -4
  280. package/src/materialization/text.js +64 -5
  281. package/src/mcp/adapter.js +2 -2
  282. package/src/selectors/datasets.js +13 -0
  283. package/src/selectors/layers.js +3 -2
  284. package/src/selectors/scales.js +6 -0
  285. package/src/theme/defaults.js +39 -0
  286. package/types/basic.d.ts +11 -2
  287. package/types/index.d.ts +126 -0
  288. package/types/program.d.ts +1327 -124
  289. package/src/materialization/bars/grouped.js +0 -144
@@ -4,6 +4,28 @@ export const SIDE_LEGEND_SYMBOL_CENTER = 16;
4
4
  export const SIDE_LEGEND_LABEL_START = 44;
5
5
  export const HORIZONTAL_LEGEND_TITLE_ELEMENT_GAP = 12;
6
6
 
7
+ export function isHorizontalEdgeLegend(config) {
8
+ return config !== undefined &&
9
+ ["top", "bottom"].includes(config.position) &&
10
+ config.layout !== "legacy-bottom";
11
+ }
12
+
13
+ export function resolveSingleHorizontalLegendPlacement({ plot, canvas, config, bounds }) {
14
+ if (!isHorizontalEdgeLegend(config)) {
15
+ throw new Error("Single legend placement requires a horizontal edge layout.");
16
+ }
17
+ const dx = config.align === "left" ? plot.x - bounds.left
18
+ : config.align === "right" ? plot.x + plot.width - bounds.right
19
+ : plot.x + plot.width / 2 - midpoint(bounds.left, bounds.right);
20
+ const dy = config.position === "top" ? plot.y - config.offset - bounds.bottom
21
+ : plot.y + plot.height + config.offset - bounds.top;
22
+ const occupied = translateBounds(bounds, dx, dy);
23
+ if (occupied.left < 0 || occupied.right > canvas.width || occupied.top < 0 || occupied.bottom > canvas.height) {
24
+ throw new Error(`Legend layout requires more ${config.position}-margin or Canvas space.`);
25
+ }
26
+ return { dx, dy, occupied };
27
+ }
28
+
7
29
  function midpoint(start, end) {
8
30
  const sum = start + end;
9
31
  const result = Number.isFinite(sum) ? sum / 2 : start / 2 + end / 2;
@@ -25,24 +47,25 @@ function decoration(border) {
25
47
  : border.padding + border.lineWidth / 2;
26
48
  }
27
49
 
28
- function horizontalExtent(blocks) {
50
+ function horizontalExtent(blocks, labelStart) {
29
51
  let left = 0;
30
52
  let right = 0;
31
53
  for (const block of blocks) {
32
- if (block.title !== undefined) right = Math.max(right, block.title.width);
54
+ const inset = block.inset ?? 0;
55
+ if (block.title !== undefined) right = Math.max(right, block.title.width + inset);
33
56
  left = Math.min(left,
34
- SIDE_LEGEND_SYMBOL_CENTER + block.symbol.left - block.symbol.centerX
57
+ SIDE_LEGEND_SYMBOL_CENTER + block.symbol.left - block.symbol.centerX - inset
35
58
  );
36
59
  right = Math.max(
37
60
  right,
38
- SIDE_LEGEND_SYMBOL_CENTER + block.symbol.right - block.symbol.centerX,
39
- SIDE_LEGEND_LABEL_START + block.labels.width
61
+ SIDE_LEGEND_SYMBOL_CENTER + block.symbol.right - block.symbol.centerX + inset,
62
+ labelStart + block.labels.width + inset
40
63
  );
41
64
  }
42
65
  return { left, right };
43
66
  }
44
67
 
45
- function placeBlock(block, cursor) {
68
+ function placeBlock(block, cursor, labelStart) {
46
69
  let dy;
47
70
  if (block.title === undefined) {
48
71
  dy = cursor - block.bounds.top;
@@ -65,7 +88,7 @@ function placeBlock(block, cursor) {
65
88
  ? undefined
66
89
  : -block.title.x,
67
90
  symbolDx: SIDE_LEGEND_SYMBOL_CENTER - block.symbol.centerX,
68
- labelDx: SIDE_LEGEND_LABEL_START - block.labels.x,
91
+ labelDx: labelStart - block.labels.x,
69
92
  bounds
70
93
  };
71
94
  }
@@ -87,7 +110,10 @@ export function resolveSideLegendLane({
87
110
  }
88
111
  const blocks = groups.flatMap(group => group.blocks);
89
112
  if (blocks.length < 2) return undefined;
90
- const laneExtent = horizontalExtent(blocks);
113
+ const labelStart = Math.max(SIDE_LEGEND_LABEL_START, ...blocks.map(
114
+ block => SIDE_LEGEND_SYMBOL_CENTER + Math.abs(block.labels.x - block.symbol.centerX)
115
+ ));
116
+ const laneExtent = horizontalExtent(blocks, labelStart);
91
117
  const offset = blocks.reduce(
92
118
  (maximum, block) => Math.max(maximum, block.offset),
93
119
  -Infinity
@@ -96,7 +122,7 @@ export function resolveSideLegendLane({
96
122
  ? plot.x + plot.width + offset
97
123
  : plot.x - offset - laneExtent.right;
98
124
  const symbolCenterX = titleStartX + SIDE_LEGEND_SYMBOL_CENTER;
99
- const labelStartX = titleStartX + SIDE_LEGEND_LABEL_START;
125
+ const labelStartX = titleStartX + labelStart;
100
126
  const first = blocks[0];
101
127
  let cursor = first.title === undefined
102
128
  ? plot.y + 12
@@ -110,7 +136,7 @@ export function resolveSideLegendLane({
110
136
  if (occupiedGroups.length > 0) cursor += inset;
111
137
  const groupPlacements = [];
112
138
  for (const block of group.blocks) {
113
- const placement = placeBlock(block, cursor);
139
+ const placement = placeBlock(block, cursor, labelStart);
114
140
  groupPlacements.push(placement);
115
141
  placements.push({
116
142
  ...placement,
@@ -123,7 +149,7 @@ export function resolveSideLegendLane({
123
149
  cursor = placement.bounds.bottom + SIDE_LEGEND_BLOCK_GAP;
124
150
  }
125
151
  const vertical = unionBounds(groupPlacements.map(item => item.bounds));
126
- const groupExtent = horizontalExtent(group.blocks);
152
+ const groupExtent = horizontalExtent(group.blocks, labelStart);
127
153
  const foreground = {
128
154
  left: titleStartX + groupExtent.left,
129
155
  right: titleStartX + groupExtent.right,
@@ -232,6 +258,13 @@ function normalizeHorizontalRow(entries) {
232
258
  )
233
259
  : commonTitleY + titleDescent + HORIZONTAL_LEGEND_TITLE_ELEMENT_GAP;
234
260
  return entries.map(({ group, dx }) => {
261
+ if (group.atomic) {
262
+ const contentDy = elementAnchor - group.element.top;
263
+ const foreground = translateBounds(group.content, dx, contentDy);
264
+ return { id: group.id, dx, titleDy: contentDy, contentDy, foreground,
265
+ occupied: expandBounds(foreground, group.inset), padding: group.padding,
266
+ backgroundId: group.backgroundId };
267
+ }
235
268
  const contentDy = commonTitleY === undefined
236
269
  ? elementAnchor - midpoint(
237
270
  group.element.top,
@@ -350,3 +383,59 @@ export function resolveHorizontalLegendLane({
350
383
  rowCount: packed.length
351
384
  };
352
385
  }
386
+
387
+ // Compose independently measured content blocks before the outer edge lane
388
+ // treats the whole group as one indivisible item.
389
+ export function resolveHorizontalLegendGroup({ edge, plot, canvas, groups,
390
+ align, offset, border, backgroundId, collisionBounds = [] }) {
391
+ const inset = decoration(border);
392
+ const innerPlot = { ...plot, x: plot.x + inset, width: plot.width - inset * 2 };
393
+ const packed = packHorizontalRows(groups, innerPlot);
394
+ const rows = packed.map(normalizeHorizontalRow);
395
+ let cursor = 0;
396
+ const placements = [];
397
+ for (const row of rows) {
398
+ const bounds = unionBounds(row.map(item => item.occupied));
399
+ const dy = edge === "top" ? cursor - bounds.bottom : cursor - bounds.top;
400
+ const translated = row.map(item => translateHorizontalPlacement(item, dy));
401
+ placements.push(...translated);
402
+ const occupied = unionBounds(translated.map(item => item.occupied));
403
+ cursor = edge === "top" ? occupied.top - HORIZONTAL_LEGEND_BLOCK_GAP
404
+ : occupied.bottom + HORIZONTAL_LEGEND_BLOCK_GAP;
405
+ }
406
+ const foreground = unionBounds(placements.map(item => item.occupied));
407
+ const occupied = expandBounds(foreground, inset);
408
+ const width = occupied.right - occupied.left;
409
+ const x = align === "left" ? plot.x : align === "right" ? plot.x + plot.width - width
410
+ : plot.x + (plot.width - width) / 2;
411
+ const dx = x - occupied.left;
412
+ const dy = edge === "top" ? plot.y - offset - occupied.bottom
413
+ : plot.y + plot.height + offset - occupied.top;
414
+ const finalBounds = translateBounds(occupied, dx, dy);
415
+ if (finalBounds.left < 0 || finalBounds.right > canvas.width ||
416
+ finalBounds.top < 0 || finalBounds.bottom > canvas.height) {
417
+ throw new Error(`Combined legend requires more ${edge}-margin or Canvas space.`);
418
+ }
419
+ if (collisionBounds.some(bounds => overlap(finalBounds, bounds))) {
420
+ throw new Error(`Combined ${edge} legend and ${edge === "top" ? "chart titles" : "x-axis guides"} require more margin space.`);
421
+ }
422
+ return {
423
+ placements: placements.map(item => ({ ...item, dx: item.dx + dx,
424
+ titleDy: item.titleDy + dy, contentDy: item.contentDy + dy,
425
+ ...(item.backgroundId === undefined ? {} : { background: {
426
+ id: item.backgroundId,
427
+ x: item.foreground.left + dx - item.padding,
428
+ y: item.foreground.top + dy - item.padding,
429
+ width: item.foreground.right - item.foreground.left + item.padding * 2,
430
+ height: item.foreground.bottom - item.foreground.top + item.padding * 2
431
+ } }) })),
432
+ occupied: finalBounds,
433
+ background: backgroundId === undefined ? undefined : {
434
+ id: backgroundId,
435
+ x: foreground.left + dx - border.padding,
436
+ y: foreground.top + dy - border.padding,
437
+ width: foreground.right - foreground.left + border.padding * 2,
438
+ height: foreground.bottom - foreground.top + border.padding * 2
439
+ }
440
+ };
441
+ }
@@ -1,161 +1,47 @@
1
- import { deriveBarAggregates } from "../../grammar/bars/aggregate.js";
2
- import {
3
- BAR_ORIENTATIONS,
4
- resolveBarChannels,
5
- resolveBarColorLayout
6
- } from "../../grammar/bars/policy.js";
7
- import {
8
- DEFAULT_SERIES_BASELINE,
9
- layoutSeriesPartition
10
- } from "../../grammar/seriesLayout.js";
11
- import {
12
- isDiscretePositionScaleType,
13
- mapContinuousScaleValues,
14
- mapOrdinalPositionValues
15
- } from "../../grammar/scales/index.js";
1
+ import { deriveSeriesBarCells } from "../../grammar/bars/aggregate.js";
2
+ import { resolveBarChannels, resolveBarColorLayout, resolveBarOffsetChannel } from "../../grammar/bars/policy.js";
3
+ import { mapContinuousScaleValues, mapOrdinalPositionValues } from "../../grammar/scales/index.js";
16
4
  import { mapScaleConsumerValues } from "../scales/map.js";
17
- import {
18
- DEFAULT_BAR_FILL,
19
- resolveBarAppearance
20
- } from "./resolve.js";
21
- import { deriveGroupedRectangles } from "./grouped.js";
5
+ import { DEFAULT_BAR_FILL, resolveBarAppearance } from "./resolve.js";
22
6
  import { resolveBarWidth } from "../../grammar/bars/geometry.js";
7
+ import { finiteMidpoint } from "../../grammar/numeric.js";
23
8
 
24
9
  export function deriveAggregateRectangles(required, resolved, widthConfig) {
25
10
  const { dataset, layer } = required;
26
- const layout = resolveBarColorLayout(layer);
27
- if (layout === "group") {
28
- return deriveGroupedRectangles(required, resolved, widthConfig);
29
- }
30
-
31
- const xScale = resolved.resolvedScales[required.xEncoding.scale];
32
- const yScale = resolved.resolvedScales[required.yEncoding.scale];
33
- const channels = resolveBarChannels(layer);
34
- const vertical = channels.orientation === BAR_ORIENTATIONS.vertical;
35
- const categoryScale = vertical ? xScale : yScale;
36
- const measureScale = vertical ? yScale : xScale;
37
- const colorEncoding = layer.encoding?.color;
38
- const colorScale = resolved.resolvedScales[colorEncoding?.scale];
39
- if (colorEncoding !== undefined && colorScale === undefined) {
40
- throw new Error(
41
- `Bar mark "${layer.id}" requires a resolved color scale.`
42
- );
43
- }
44
-
45
- const seriesDomain = colorScale?.domain ?? [undefined];
46
- const seriesIndex = new Map(
47
- seriesDomain.map((value, index) => [value, index])
48
- );
49
- const colors = new Map(seriesDomain.map((value, index) => [
50
- value,
51
- colorScale?.range[index % colorScale.range.length] ?? DEFAULT_BAR_FILL
52
- ]));
53
- const cells = deriveBarAggregates(dataset.values, layer).values;
54
- const discreteCategory = categoryScale.type === "ordinal" ||
55
- isDiscretePositionScaleType(categoryScale.type);
56
- const categoryDomain = discreteCategory
57
- ? categoryScale.domain
58
- : [...new Set(cells.map(cell => cell[channels.category]))]
59
- .sort((left, right) => left - right);
60
- const categoryIndex = new Map(
61
- categoryDomain.map((value, index) => [value, index])
62
- );
63
- const cellMap = new Map(cells.map(cell => [
64
- JSON.stringify([cell[channels.category], cell.color]),
65
- cell
66
- ]));
67
- const thickness = resolveBarWidth(
68
- widthConfig,
69
- Math.abs(categoryScale.bandwidth ?? categoryScale.step)
70
- );
71
- const baseline = DEFAULT_SERIES_BASELINE;
72
- const continuousColor = colorEncoding?.fieldType === "quantitative";
73
- if (continuousColor) {
74
- const mappedColors = mapScaleConsumerValues(
75
- cells.map(cell => cell.color), colorScale, "color"
76
- );
77
- const cellByCategory = new Map(
78
- cells.map((cell, index) => [cell[channels.category], { cell, index }])
79
- );
80
- const existing = resolved.graphicSpec.objects[layer.id].items;
81
- const config = resolved.markConfigs[layer.id] ?? {};
82
- const appearance = config.barAppearance ?? {};
83
- return categoryDomain.flatMap(categoryValue => {
84
- const entry = cellByCategory.get(categoryValue);
85
- if (entry === undefined) return [];
86
- const { cell, index } = entry;
87
- const categoryCenter = discreteCategory
88
- ? mapOrdinalPositionValues([categoryValue], categoryScale)[0]
89
- : mapContinuousScaleValues(
90
- [categoryValue],
91
- categoryScale
92
- )[0];
93
- const [segment] = layoutSeriesPartition(
94
- [cell[channels.measure]],
95
- layout,
96
- { baseline }
97
- );
98
- if (segment === undefined) return [];
99
- const [start, end] = mapContinuousScaleValues(
100
- [segment.start, segment.end],
101
- measureScale
102
- );
103
- return [{
104
- x: vertical ? categoryCenter - thickness / 2 : Math.min(start, end),
105
- y: vertical ? Math.min(start, end) : categoryCenter - thickness / 2,
106
- width: vertical ? thickness : Math.abs(start - end),
107
- height: vertical ? Math.abs(start - end) : thickness,
108
- fill: mappedColors[index],
109
- ...resolveBarAppearance(config, existing[index]?.properties)
110
- }];
111
- });
112
- }
113
- const segments = [];
114
-
115
- for (const category of categoryDomain) {
116
- const partition = seriesDomain.map(color =>
117
- cellMap.get(JSON.stringify([category, color]))?.[channels.measure] ?? 0
118
- );
119
- for (const segment of layoutSeriesPartition(partition, layout, { baseline })) {
120
- const color = seriesDomain[segment.index];
121
- const cell = cellMap.get(JSON.stringify([category, color]));
122
- if (cell === undefined) continue;
123
- segments.push({ category, color, ...segment });
124
- }
125
- }
126
-
127
- const existing = resolved.graphicSpec.objects[layer.id].items;
11
+ const channels = resolveBarChannels(layer), vertical = channels.orientation === "vertical";
12
+ const categoryScale = resolved.resolvedScales[layer.encoding[channels.category].scale];
13
+ const measureScale = resolved.resolvedScales[layer.encoding[channels.measure].scale];
14
+ const color = layer.encoding.color, colorScale = resolved.resolvedScales[color?.scale];
15
+ const grouped = resolveBarColorLayout(layer) === "group";
16
+ const offsetScale = resolved.resolvedScales[layer.encoding[resolveBarOffsetChannel(layer)]?.scale];
17
+ if (grouped && offsetScale === undefined) throw new Error("Grouped bar requires a resolved offset scale.");
18
+ const temporal = categoryScale.type === "time";
19
+ const segments = deriveSeriesBarCells(dataset.values, layer, temporal ? undefined : categoryScale.domain,
20
+ grouped ? offsetScale.domain : layer.encoding.group === undefined && color?.fieldType !== "quantitative" ? colorScale?.domain : undefined);
21
+ const fills = color === undefined ? undefined : mapScaleConsumerValues(segments.map(segment => segment.cell.color), colorScale, "color");
128
22
  const config = resolved.markConfigs[layer.id] ?? {};
129
- const appearance = config.barAppearance ?? {};
130
- return segments.map((segment, index) => {
131
- const category = categoryIndex.get(segment.category);
132
- const color = seriesIndex.get(segment.color);
133
- if (category === undefined || color === undefined) {
134
- throw new Error("Bar value is outside a resolved ordinal domain.");
23
+ const existing = resolved.graphicSpec.objects[layer.id].items;
24
+ const width = resolveBarWidth(widthConfig, grouped ? offsetScale.bandwidth : Math.abs(categoryScale.bandwidth ?? categoryScale.step));
25
+ const indices = new Map(categoryScale.domain.map((value, index) => [value, index]));
26
+ return segments.map(({ cell, start, end, seriesIndex }, index) => {
27
+ const categoryValue = cell[channels.category];
28
+ const categoryCenter = (temporal ? mapContinuousScaleValues : mapOrdinalPositionValues)([categoryValue], categoryScale)[0];
29
+ let position = !grouped && layer.encoding.group !== undefined && layer.encoding.group.inferredFrom === undefined && !temporal && categoryScale.step > 0
30
+ ? (categoryScale.start ?? categoryScale.range[0]) + indices.get(categoryValue) * categoryScale.step + categoryScale.bandwidth / 2 - width / 2
31
+ : categoryCenter - width / 2;
32
+ if (grouped) {
33
+ const direction = Math.sign(categoryScale.step ?? categoryScale.range[1] - categoryScale.range[0]) || 1;
34
+ const offsetCenter = offsetScale.start + seriesIndex * offsetScale.step + (Math.sign(offsetScale.step) || 1) * offsetScale.bandwidth / 2;
35
+ if (vertical && direction > 0 && offsetScale.step > 0) {
36
+ const categoryStart = temporal ? categoryCenter - categoryScale.bandwidth / 2
37
+ : (categoryScale.start ?? categoryScale.range[0]) + indices.get(categoryValue) * categoryScale.step;
38
+ position = categoryStart + offsetScale.start + seriesIndex * offsetScale.step + (offsetScale.bandwidth - width) / 2;
39
+ } else position = categoryCenter + direction * (offsetCenter - finiteMidpoint(...offsetScale.range)) - width / 2;
135
40
  }
136
- const categoryCenter = discreteCategory
137
- ? mapOrdinalPositionValues([segment.category], categoryScale)[0]
138
- : mapContinuousScaleValues(
139
- [segment.category],
140
- categoryScale
141
- )[0];
142
- const [start, end] = mapContinuousScaleValues(
143
- [segment.start, segment.end],
144
- measureScale
145
- );
146
- return {
147
- x: vertical
148
- ? categoryCenter - thickness / 2
149
- : Math.min(start, end),
150
- y: vertical
151
- ? Math.min(start, end)
152
- : categoryCenter - thickness / 2,
153
- width: vertical ? thickness : Math.abs(start - end),
154
- height: vertical ? Math.abs(start - end) : thickness,
155
- fill: segment.color === undefined
156
- ? appearance.fill ?? config.fill ?? colors.get(segment.color)
157
- : colors.get(segment.color),
158
- ...resolveBarAppearance(config, existing[index]?.properties)
159
- };
41
+ const [a, b] = mapContinuousScaleValues([start, end], measureScale);
42
+ return { x: vertical ? position : Math.min(a, b), y: vertical ? Math.min(a, b) : position,
43
+ width: vertical ? width : Math.abs(a - b), height: vertical ? Math.abs(a - b) : width,
44
+ fill: fills?.[index] ?? config.barAppearance?.fill ?? config.fill ?? DEFAULT_BAR_FILL,
45
+ ...resolveBarAppearance(config, existing[index]?.properties) };
160
46
  });
161
47
  }
@@ -1,3 +1,4 @@
1
+ import { readSeriesIdentity } from "../../grammar/pathSeries.js";
1
2
  import {
2
3
  countHistogramBins,
3
4
  findHistogramBinIndex,
@@ -34,6 +35,29 @@ export function deriveHistogramSegments({
34
35
  const colorEncoding = layer.encoding?.color;
35
36
  const layout = resolveBarColorLayout(layer);
36
37
 
38
+ if (layer.encoding?.group !== undefined) {
39
+ const identity = readSeriesIdentity(dataset.values, layer);
40
+ const cellRows = bins.boundaries.slice(0, -1).map(() => identity.domain.map(() => []));
41
+ const index = new Map(identity.domain.map((value, i) => [value, i]));
42
+ xValues.forEach((value, i) => { const bin = findHistogramBinIndex(value, bins.boundaries);
43
+ if (bin !== -1) cellRows[bin][index.get(identity.values[i])].push(dataset.values[i]); });
44
+ return cellRows.flatMap((cells, bin) => layoutSeriesPartition(cells.map(rows => rows.length), layout).map(segment => {
45
+ const members = cells[segment.index];
46
+ let color, colorValue;
47
+ if (colorEncoding !== undefined) {
48
+ const values = readNominalField(members, colorEncoding.field);
49
+ if (new Set(values).size !== 1) throw new Error("Histogram color requires one value within each bin/series cell.");
50
+ colorValue = values[0];
51
+ const scale = resolvedScales[colorEncoding.scale];
52
+ color = mapOrdinalValues([colorValue], scale.domain, scale.range)[0];
53
+ }
54
+ return { bin, start: bins.boundaries[bin], end: bins.boundaries[bin + 1],
55
+ category: segment.index, categoryCount: identity.domain.length,
56
+ stackStart: segment.start, stackEnd: segment.end, members,
57
+ ...(color === undefined ? {} : { color, colorValue }) };
58
+ }));
59
+ }
60
+
37
61
  if (colorEncoding?.scale === undefined) {
38
62
  return countHistogramBins(xValues, bins.boundaries).flatMap((count, bin) =>
39
63
  layoutSeriesPartition([count], layout).map(segment => ({
@@ -0,0 +1,40 @@
1
+ export function resolveCategorySlotOffset(program, layer) {
2
+ const policy = program.markConfigs[layer.id]?.categorySlotOffset;
3
+ if (policy === undefined) return undefined;
4
+ if (!["x", "y"].includes(policy.channel)) {
5
+ throw new Error(`Category slot offset for "${layer.id}" requires channel x or y.`);
6
+ }
7
+ if (!Number.isFinite(policy.band) || policy.band < -0.5 || policy.band > 0.5) {
8
+ throw new RangeError(`Category slot offset for "${layer.id}" must be within [-0.5, 0.5] band.`);
9
+ }
10
+ const encoding = layer.encoding?.[policy.channel];
11
+ if (!["nominal", "ordinal"].includes(encoding?.fieldType)) {
12
+ throw new Error(`Category slot offset for "${layer.id}" requires a categorical position.`);
13
+ }
14
+ const scale = program.resolvedScales[encoding.scale];
15
+ if (scale?.type !== "band" || !Number.isFinite(scale.bandwidth) || scale.bandwidth <= 0) {
16
+ throw new Error(`Category slot offset for "${layer.id}" requires a positive band scale.`);
17
+ }
18
+ return { channel: policy.channel, pixels: scale.bandwidth * policy.band };
19
+ }
20
+
21
+ export function offsetCategoryPositions(program, layer, positions) {
22
+ const offset = resolveCategorySlotOffset(program, layer);
23
+ if (offset === undefined || offset.pixels === 0) return positions;
24
+ const shift = values => values === undefined
25
+ ? undefined
26
+ : values.map(value => Number.isFinite(value) ? value + offset.pixels : value);
27
+ return {
28
+ ...positions,
29
+ [offset.channel]: shift(positions[offset.channel])
30
+ };
31
+ }
32
+
33
+ export function offsetCategoryRectangles(program, layer, rectangles) {
34
+ const offset = resolveCategorySlotOffset(program, layer);
35
+ if (offset === undefined || offset.pixels === 0) return rectangles;
36
+ return rectangles.map(rectangle => ({
37
+ ...rectangle,
38
+ [offset.channel]: rectangle[offset.channel] + offset.pixels
39
+ }));
40
+ }
@@ -32,7 +32,7 @@ export function namespaceGraphicId(namespace, id) {
32
32
  const encode = value => Array.from(value, character =>
33
33
  character.codePointAt(0).toString(16).padStart(6, "0")
34
34
  ).join("");
35
- return `g${encode(namespace)}_${encode(id)}`;
35
+ return `g${encode(namespace)}_${id}`;
36
36
  }
37
37
 
38
38
  function rewriteItem(item, namespace) {
@@ -1,12 +1,16 @@
1
+ import { withGuideLayoutTransaction } from "./guides/layout.js";
1
2
  import {
2
3
  canDeferScaleConsumerApplication,
4
+ getLayerScaleIds,
3
5
  getExistingMarkRematerializationStep,
4
6
  getMarkMaterializationStep,
5
- getSourceDependentMarkSteps
7
+ getScaleConsumerMarkSteps,
8
+ getSourceDependentMarkSteps,
9
+ reusePlannedMarkScales
6
10
  } from "./marks/index.js";
7
- import { requireLayer } from "../selectors/layers.js";
11
+ import { findLayer, requireLayer } from "../selectors/layers.js";
8
12
  import {
9
- applyMaterializationPlan,
13
+ applyMaterializationPlan as executeMaterializationPlan,
10
14
  buildMaterializationPlan
11
15
  } from "./planner.js";
12
16
  import { hasMaterializedLegend } from "./legends.js";
@@ -16,13 +20,6 @@ import {
16
20
  } from "./scaleGuideDependencies.js";
17
21
  import { planLayoutRematerialization } from "./layout.js";
18
22
 
19
- function layerScaleIds(layer) {
20
- return [
21
- ...Object.values(layer.encoding ?? {}).map(encoding => encoding?.scale),
22
- ...(layer.encoding?.parallel?.dimensions ?? []).map(dimension => dimension.scale)
23
- ].filter(scale => scale !== undefined);
24
- }
25
-
26
23
  export function planCanvasRematerialization(program) {
27
24
  const marks = [];
28
25
  for (const layer of program.semanticSpec.layers) {
@@ -38,7 +35,7 @@ export function planCanvasRematerialization(program) {
38
35
  if (needsCanvasScaleRematerialization(program, scale)) {
39
36
  const deferredConsumers = program.semanticSpec.layers.filter(layer =>
40
37
  canDeferScaleConsumerApplication(layer) &&
41
- layerScaleIds(layer).includes(scale.id)
38
+ getLayerScaleIds(layer).includes(scale.id)
42
39
  );
43
40
  const canDeferMarks = deferredConsumers.length === 0 ||
44
41
  deferredConsumers.every(layer => deferredMarkIds.has(layer.id));
@@ -66,7 +63,7 @@ export function planCanvasRematerialization(program) {
66
63
 
67
64
  export function planLayerDataRematerialization(program, id) {
68
65
  const layer = requireLayer(program, id);
69
- const scaleIds = [...new Set(layerScaleIds(layer))];
66
+ const scaleIds = [...new Set(getLayerScaleIds(layer))];
70
67
  const markStep = getMarkMaterializationStep(program, layer);
71
68
  const scales = scaleIds.map(scale => ({
72
69
  op: "rematerializeScale",
@@ -76,15 +73,19 @@ export function planLayerDataRematerialization(program, id) {
76
73
  ...(markStep === undefined ? {} : { marks: false })
77
74
  }
78
75
  }));
79
- const marks = [
76
+ const directMarks = [
80
77
  ...(markStep === undefined ? [] : [markStep]),
81
- ...getSourceDependentMarkSteps(program, id)
78
+ ...getScaleConsumerMarkSteps(program, scaleIds)
79
+ ];
80
+ const marks = [
81
+ ...directMarks,
82
+ ...directMarks.flatMap(step => getSourceDependentMarkSteps(program, step.args.id))
82
83
  ];
83
84
  if (scales.length > 0 || marks.length > 0) {
84
85
  const guides = scaleIds.flatMap(scale =>
85
86
  planScaleGuideRematerialization(program, scale)
86
87
  );
87
- return buildMaterializationPlan({ scales, marks, guides });
88
+ return buildMaterializationPlan({ scales, marks: reusePlannedMarkScales(program, marks, scaleIds), guides });
88
89
  }
89
90
  const existingStep = getExistingMarkRematerializationStep(program, layer);
90
91
  return buildMaterializationPlan({
@@ -99,5 +100,45 @@ export function applyLayerDataRematerialization(program, id) {
99
100
  );
100
101
  }
101
102
 
102
- export { applyMaterializationPlan } from "./planner.js";
103
+ export function applyLayerEmptyDataView(program, id) {
104
+ requireLayer(program, id);
105
+ const targets = [
106
+ id,
107
+ ...getSourceDependentMarkSteps(program, id).map(step => step.args.id)
108
+ ];
109
+ let next = program;
110
+ for (const target of new Set(targets)) {
111
+ const graphic = next.graphicSpec.objects[target];
112
+ if (graphic === undefined) continue;
113
+ next = graphic.type === "collection"
114
+ ? next.editGraphics({ target, property: "items", value: [] })
115
+ : next.editGraphics({ target, property: "length", value: 0 });
116
+ }
117
+ return next;
118
+ }
119
+
120
+ export function applyDetachedScaleRematerialization(program, previousLayers) {
121
+ const retained = new Set(program.semanticSpec.layers.flatMap(getLayerScaleIds));
122
+ const scaleIds = [...new Set(previousLayers.flatMap(layer => {
123
+ const current = findLayer(program, layer.id);
124
+ const currentIds = current === undefined ? [] : getLayerScaleIds(current);
125
+ return getLayerScaleIds(layer).filter(id => !currentIds.includes(id) && retained.has(id));
126
+ }))];
127
+ if (scaleIds.length === 0) return program;
128
+ const directMarks = getScaleConsumerMarkSteps(program, scaleIds);
129
+ return applyMaterializationPlan(program, buildMaterializationPlan({
130
+ scales: scaleIds.map(id => ({
131
+ op: "rematerializeScale", args: { id, guides: false, marks: false }
132
+ })),
133
+ marks: [
134
+ ...directMarks,
135
+ ...directMarks.flatMap(step => getSourceDependentMarkSteps(program, step.args.id))
136
+ ],
137
+ guides: scaleIds.flatMap(id => planScaleGuideRematerialization(program, id))
138
+ }));
139
+ }
140
+
141
+ export function applyMaterializationPlan(program, plan) {
142
+ return withGuideLayoutTransaction(program, next => executeMaterializationPlan(next, plan));
143
+ }
103
144
  export { planScaleGuideRematerialization };
@@ -25,7 +25,7 @@ export function planEncodingRematerialization(program, {
25
25
  );
26
26
  const marks = [
27
27
  ...directMarks,
28
- ...getSourceDependentMarkSteps(program, target)
28
+ ...directMarks.flatMap(step => getSourceDependentMarkSteps(program, step.args.id))
29
29
  ];
30
30
 
31
31
  const guides = [];
@@ -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
  }