ggaction 0.0.17 → 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 (315) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +6 -2
  3. package/knowledge/action-card.schema.json +1 -1
  4. package/knowledge/action-cards.json +1 -1
  5. package/knowledge/intent-taxonomy.json +1 -1
  6. package/knowledge/mcp-resources.json +1 -1
  7. package/knowledge/task-resolver.js +1 -1
  8. package/package.json +3 -3
  9. package/src/BasicChartProgram.js +1 -1
  10. package/src/accessibility.js +3 -2
  11. package/src/actions/boxPlots/create.js +14 -4
  12. package/src/actions/boxPlots/edit.js +39 -33
  13. package/src/actions/boxPlots/materialize.js +7 -3
  14. package/src/actions/boxPlots/options.js +37 -11
  15. package/src/actions/canvas/actions.js +70 -95
  16. package/src/actions/canvas/fitting.js +20 -1
  17. package/src/actions/canvas/index.js +3 -0
  18. package/src/actions/canvas/plotBorder.js +28 -0
  19. package/src/actions/charts/area.js +2 -2
  20. package/src/actions/charts/bar.js +34 -7
  21. package/src/actions/charts/ecdf.js +4 -18
  22. package/src/actions/charts/endpoints.js +4 -18
  23. package/src/actions/charts/heatmap.js +8 -7
  24. package/src/actions/charts/index.js +2 -0
  25. package/src/actions/charts/interval-regression.js +2 -2
  26. package/src/actions/charts/line.js +1 -1
  27. package/src/actions/charts/pie.js +3 -47
  28. package/src/actions/charts/radial.js +34 -12
  29. package/src/actions/charts/scatter.js +16 -2
  30. package/src/actions/charts/shared.js +2 -2
  31. package/src/actions/charts/text.js +35 -0
  32. package/src/actions/composition/actions.js +6 -2
  33. package/src/actions/data/box.js +2 -1
  34. package/src/actions/data/derived.js +11 -1
  35. package/src/actions/data/edit.js +3 -1
  36. package/src/actions/data/index.js +4 -0
  37. package/src/actions/data/regression.js +25 -16
  38. package/src/actions/data/regressionStatistics.js +73 -0
  39. package/src/actions/data/shared.js +8 -4
  40. package/src/actions/data/stack.js +3 -1
  41. package/src/actions/distributions/revision.js +24 -18
  42. package/src/actions/encodings/appearance.js +37 -29
  43. package/src/actions/encodings/barWidth.js +36 -11
  44. package/src/actions/encodings/channels.js +0 -1
  45. package/src/actions/encodings/color/continuous.js +1 -1
  46. package/src/actions/encodings/color/index.js +1 -1
  47. package/src/actions/encodings/color/policy.js +10 -3
  48. package/src/actions/encodings/density/resolve.js +1 -6
  49. package/src/actions/encodings/position/policies/arc.js +12 -10
  50. package/src/actions/encodings/position/policies/area.js +3 -3
  51. package/src/actions/encodings/position/policies/bar.js +15 -6
  52. package/src/actions/encodings/position/policies/line.js +17 -6
  53. package/src/actions/encodings/position/resolve.js +3 -2
  54. package/src/actions/encodings/ranged.js +17 -0
  55. package/src/actions/encodings/remove.js +14 -8
  56. package/src/actions/encodings/seriesLayout.js +1 -0
  57. package/src/actions/encodings/shared.js +26 -24
  58. package/src/actions/encodings/text.js +6 -3
  59. package/src/actions/errorBands/create.js +2 -2
  60. package/src/actions/errorBands/edit.js +2 -1
  61. package/src/actions/facets/actions.js +176 -247
  62. package/src/actions/facets/guides.js +2 -1
  63. package/src/actions/gradientPlots/resolve.js +2 -17
  64. package/src/actions/guides/applicability.js +1 -1
  65. package/src/actions/guides/axes/axes.js +30 -11
  66. package/src/actions/guides/axes/axis.js +72 -10
  67. package/src/actions/guides/axes/components.js +13 -13
  68. package/src/actions/guides/axes/edit.js +36 -24
  69. package/src/actions/guides/axes/labels.js +155 -71
  70. package/src/actions/guides/axes/lines.js +27 -24
  71. package/src/actions/guides/axes/parallel/lifecycle.js +1 -1
  72. package/src/actions/guides/axes/parallel/policy.js +8 -7
  73. package/src/actions/guides/axes/remove.js +13 -8
  74. package/src/actions/guides/axes/tickGroups.js +11 -7
  75. package/src/actions/guides/axes/ticks.js +36 -24
  76. package/src/actions/guides/axes/titles.js +107 -69
  77. package/src/actions/guides/basic.js +2 -0
  78. package/src/actions/guides/facadeAxes.js +33 -24
  79. package/src/actions/guides/facadeLegend.js +12 -4
  80. package/src/actions/guides/grids/grid.js +8 -2
  81. package/src/actions/guides/grids/resolve.js +102 -58
  82. package/src/actions/guides/legends/blocks.js +46 -20
  83. package/src/actions/guides/legends/categorical/actions.js +95 -19
  84. package/src/actions/guides/legends/categorical/components.js +15 -18
  85. package/src/actions/guides/legends/categorical/index.js +8 -0
  86. package/src/actions/guides/legends/categorical/layout.js +45 -20
  87. package/src/actions/guides/legends/categorical/options.js +39 -22
  88. package/src/actions/guides/legends/categorical/recipes.js +7 -3
  89. package/src/actions/guides/legends/categorical/resolve.js +11 -4
  90. package/src/actions/guides/legends/categorical/symbols.js +38 -18
  91. package/src/actions/guides/legends/categorical/validation.js +1 -6
  92. package/src/actions/guides/legends/colorSize.js +110 -0
  93. package/src/actions/guides/legends/combine.js +37 -0
  94. package/src/actions/guides/legends/continuous/colorSymbols.js +1 -0
  95. package/src/actions/guides/legends/continuous/common.js +1 -447
  96. package/src/actions/guides/legends/continuous/gradient.js +73 -46
  97. package/src/actions/guides/legends/continuous/interval.js +4 -4
  98. package/src/actions/guides/legends/continuous/opacity.js +33 -21
  99. package/src/actions/guides/legends/creation.js +2 -0
  100. package/src/actions/guides/legends/edit.js +1 -604
  101. package/src/actions/guides/legends/focused.js +8 -7
  102. package/src/actions/guides/legends/group.js +1 -0
  103. package/src/actions/guides/legends/lane.js +1 -516
  104. package/src/actions/guides/legends/lifecycle.js +2 -1
  105. package/src/actions/guides/legends/remove.js +9 -3
  106. package/src/actions/guides/legends/sampling.js +1 -1
  107. package/src/actions/guides/legends/size.js +65 -47
  108. package/src/actions/guides/legends/strokeWidth.js +13 -29
  109. package/src/actions/guides/legends/target.js +2 -79
  110. package/src/actions/guides/legends/transition.js +44 -11
  111. package/src/actions/guides/polar/axes/labels.js +4 -2
  112. package/src/actions/guides/polar/axes/titles.js +7 -5
  113. package/src/actions/guides/polar/resolve.js +6 -14
  114. package/src/actions/guides/tickValues.js +8 -5
  115. package/src/actions/index.js +16 -16
  116. package/src/actions/marks/arc/actions.js +3 -10
  117. package/src/actions/marks/area/actions.js +8 -5
  118. package/src/actions/marks/area/materialize.js +8 -9
  119. package/src/actions/marks/bar/create.js +10 -4
  120. package/src/actions/marks/bar/edit.js +29 -14
  121. package/src/actions/marks/bar/materialize.js +15 -7
  122. package/src/actions/marks/line/actions.js +11 -4
  123. package/src/actions/marks/line/materialize.js +9 -5
  124. package/src/actions/marks/point/edit.js +1 -1
  125. package/src/actions/marks/point/materialize.js +5 -1
  126. package/src/actions/marks/rect/actions.js +3 -3
  127. package/src/actions/marks/remove.js +12 -10
  128. package/src/actions/marks/rule/actions.js +7 -4
  129. package/src/actions/marks/text/actions.js +17 -5
  130. package/src/actions/marks/text/layout.js +3 -0
  131. package/src/actions/primitives/createGraphics.js +3 -2
  132. package/src/actions/primitives/editGraphics.js +15 -11
  133. package/src/actions/primitives/graphicProperties.js +1 -6
  134. package/src/actions/primitives/semanticAction.js +27 -1
  135. package/src/actions/primitives/semanticValidation/guide.js +11 -1
  136. package/src/actions/primitives/semanticValidation/layer.js +8 -0
  137. package/src/actions/regression/create.js +1 -1
  138. package/src/actions/regression/edit.js +18 -6
  139. package/src/actions/regression/resolve.js +2 -6
  140. package/src/actions/scales/channels.js +9 -4
  141. package/src/actions/scales/consumers/families.js +16 -5
  142. package/src/actions/scales/create.js +10 -5
  143. package/src/actions/scales/definitions.js +1 -425
  144. package/src/actions/scales/edit.js +3 -3
  145. package/src/actions/scales/editPolicy.js +12 -3
  146. package/src/actions/scales/materialize.js +1 -1
  147. package/src/actions/scales/patch.js +1 -1
  148. package/src/actions/scales/preview.js +21 -1
  149. package/src/actions/scales/quantitativeColor.js +8 -3
  150. package/src/actions/selection/actions.js +1 -13
  151. package/src/actions/theme/reconcile.js +126 -45
  152. package/src/actions/theme/state.js +12 -0
  153. package/src/actions/titles/actions.js +5 -18
  154. package/src/actions/titles/resolve.js +41 -19
  155. package/src/actions/violinPlots/edit.js +3 -20
  156. package/src/core/compositionState.js +6 -3
  157. package/src/core/font.js +9 -2
  158. package/src/core/graphicProperties.js +1 -0
  159. package/src/core/optionRequirements.js +1 -0
  160. package/src/core/programState.js +4 -0
  161. package/src/core/resourceReferences.js +5 -2
  162. package/src/core/textMetricProfile.js +3 -2
  163. package/src/core/textMetrics.js +41 -4
  164. package/src/core/validation.js +35 -0
  165. package/src/core/vocabulary.js +7 -0
  166. package/src/grammar/aggregate.js +4 -18
  167. package/src/grammar/arcs.js +1 -386
  168. package/src/grammar/areaPaint.js +10 -0
  169. package/src/grammar/areaSeries.js +18 -10
  170. package/src/grammar/axisTextPlacement.js +1 -0
  171. package/src/grammar/bars/geometry.js +30 -3
  172. package/src/grammar/bars/policy.js +12 -1
  173. package/src/grammar/bin.js +2 -12
  174. package/src/grammar/bin2d.js +1 -6
  175. package/src/grammar/boxPlot.js +56 -9
  176. package/src/grammar/categoryOrder.js +1 -6
  177. package/src/grammar/colorSizeLegend.js +39 -0
  178. package/src/grammar/complete.js +15 -19
  179. package/src/grammar/computed.js +115 -26
  180. package/src/grammar/curveCommands.js +29 -16
  181. package/src/grammar/datasetSchema.js +13 -9
  182. package/src/grammar/displayLabels.js +36 -0
  183. package/src/grammar/ecdf.js +2 -9
  184. package/src/grammar/facets/dependencies.js +6 -2
  185. package/src/grammar/facets/guides.js +58 -30
  186. package/src/grammar/facets/index.js +2 -5
  187. package/src/grammar/facets/legendIntent.js +65 -0
  188. package/src/grammar/fold.js +2 -12
  189. package/src/grammar/gradientProfile.js +1 -6
  190. package/src/grammar/guideDash.js +1 -0
  191. package/src/grammar/impute.js +21 -48
  192. package/src/grammar/interval.js +2 -9
  193. package/src/grammar/legendBlocks.js +1 -0
  194. package/src/grammar/legendBorder.js +9 -0
  195. package/src/grammar/legendClip.js +19 -0
  196. package/src/grammar/legendCombinations.js +45 -0
  197. package/src/grammar/legendGradient.js +27 -0
  198. package/src/grammar/legendGroups.js +1 -0
  199. package/src/grammar/legendOpacity.js +6 -0
  200. package/src/grammar/legendTitleStyle.js +22 -0
  201. package/src/grammar/lineSeries.js +32 -17
  202. package/src/grammar/markLabels.js +5 -2
  203. package/src/grammar/normalize.js +4 -54
  204. package/src/grammar/numeric.js +1 -290
  205. package/src/grammar/parallelCoordinates.js +1 -6
  206. package/src/grammar/parseTime.js +41 -0
  207. package/src/grammar/pathCommands.js +11 -4
  208. package/src/grammar/pointShapes.js +16 -2
  209. package/src/grammar/polar.js +1 -184
  210. package/src/grammar/positionCompatibility.js +3 -3
  211. package/src/grammar/regression/derive.js +16 -2
  212. package/src/grammar/regression/models.js +142 -54
  213. package/src/grammar/regression/parameters.js +23 -4
  214. package/src/grammar/regression/statistics.js +64 -0
  215. package/src/grammar/roundedRect.js +42 -54
  216. package/src/grammar/scales/appearance.js +1 -206
  217. package/src/grammar/scales/color.js +1 -382
  218. package/src/grammar/scales/colorConsumers.js +5 -2
  219. package/src/grammar/scales/definition.js +1 -197
  220. package/src/grammar/scales/discretized.js +1 -195
  221. package/src/grammar/scales/size.js +1 -456
  222. package/src/grammar/scales/temporal.js +2 -13
  223. package/src/grammar/scales/transformed.js +1 -383
  224. package/src/grammar/scales/types.js +1 -1
  225. package/src/grammar/scales/validation.js +1 -66
  226. package/src/grammar/schemas/concreteGraphic.js +14 -0
  227. package/src/grammar/schemas/graphic.js +6 -0
  228. package/src/grammar/schemas/graphicBounds.js +31 -7
  229. package/src/grammar/schemas/semanticPath.js +19 -3
  230. package/src/grammar/stack.js +2 -10
  231. package/src/grammar/statisticalReference.js +1 -6
  232. package/src/grammar/summary.js +4 -27
  233. package/src/grammar/text.js +19 -0
  234. package/src/grammar/timeUnit.js +22 -11
  235. package/src/grammar/transformKeys.js +47 -0
  236. package/src/grammar/transformTopology.js +1 -0
  237. package/src/grammar/transforms.js +15 -2
  238. package/src/grammar/valueFormat.js +17 -5
  239. package/src/grammar/weightedStatistics.js +1 -6
  240. package/src/grammar/window.js +4 -58
  241. package/src/inspectionDescriptors.js +1 -1
  242. package/src/layout/canvas.js +1 -110
  243. package/src/layout/facets.js +1 -244
  244. package/src/layout/guideCollisions.js +9 -1
  245. package/src/layout/labels.js +1 -0
  246. package/src/layout/legend.js +13 -0
  247. package/src/layout/legendGroup.js +1 -0
  248. package/src/layout/legendItems.js +59 -15
  249. package/src/layout/legendLane.js +1 -441
  250. package/src/layout/legendText.js +1 -0
  251. package/src/layout/title.js +35 -13
  252. package/src/materialization/bars/aggregate.js +11 -5
  253. package/src/materialization/bars/histogram.js +4 -1
  254. package/src/materialization/bars/ranged.js +19 -6
  255. package/src/materialization/bars/resolve.js +22 -1
  256. package/src/materialization/compositionSnapshot.js +2 -7
  257. package/src/materialization/coordinateBounds.js +21 -0
  258. package/src/materialization/dependencies.js +4 -44
  259. package/src/materialization/facetGuides/edit.js +30 -0
  260. package/src/materialization/facetGuides/layout.js +1 -0
  261. package/src/materialization/facetGuides/owner.js +125 -0
  262. package/src/materialization/facetGuides/ownerLayout.js +71 -0
  263. package/src/materialization/facetGuides/placement.js +71 -14
  264. package/src/materialization/facetGuides/preparation.js +22 -7
  265. package/src/materialization/facetHeaders.js +1 -347
  266. package/src/materialization/facets.js +1 -185
  267. package/src/materialization/graphicHierarchy.js +5 -1
  268. package/src/materialization/graphicTranslation.js +73 -0
  269. package/src/materialization/guides/layout.js +1 -57
  270. package/src/materialization/guides/resources.js +1 -123
  271. package/src/materialization/layout.js +53 -0
  272. package/src/materialization/legendBorder.js +28 -0
  273. package/src/materialization/legendClip.js +76 -0
  274. package/src/materialization/legendGroups.js +1 -0
  275. package/src/materialization/legendText.js +1 -0
  276. package/src/materialization/legends.js +2 -2
  277. package/src/materialization/marks/capabilities.js +5 -6
  278. package/src/materialization/rect.js +1 -1
  279. package/src/materialization/scaleGuideDependencies.js +14 -15
  280. package/src/materialization/scales/map.js +6 -0
  281. package/src/materialization/scales/policies/arc.js +8 -7
  282. package/src/materialization/scales/policies/bar.js +19 -7
  283. package/src/materialization/scales/policies/series.js +2 -1
  284. package/src/materialization/scales/resolve.js +7 -3
  285. package/src/materialization/selection/items/arc.js +1 -1
  286. package/src/materialization/selection/items/bar.js +7 -6
  287. package/src/materialization/selection/items/path.js +7 -6
  288. package/src/materialization/selection/items/rect.js +2 -2
  289. package/src/materialization/text.js +13 -4
  290. package/src/materialization/textLines.js +15 -0
  291. package/src/materialization/typography.js +10 -2
  292. package/src/persistence/codec.js +37 -6
  293. package/src/persistence/validation.js +220 -0
  294. package/src/persistence.js +19 -6
  295. package/src/renderers/canvas/circle.js +6 -3
  296. package/src/renderers/canvas/fill.js +7 -1
  297. package/src/renderers/canvas/index.js +22 -11
  298. package/src/renderers/canvas/isolated.js +166 -0
  299. package/src/renderers/canvas/limits.js +3 -0
  300. package/src/renderers/canvas/line.js +1 -0
  301. package/src/renderers/canvas/path.js +1 -1
  302. package/src/renderers/canvas/rect.js +11 -8
  303. package/src/renderers/canvas/stroke.js +2 -1
  304. package/src/renderers/canvas/text.js +5 -3
  305. package/src/renderers/pdf.js +2 -2
  306. package/src/renderers/png.js +2 -2
  307. package/src/renderers/svg.js +14 -4
  308. package/src/selectors/axes.js +1 -0
  309. package/src/selectors/graphicCapacity.js +38 -0
  310. package/src/selectors/layers.js +19 -0
  311. package/src/theme/defaults.js +8 -2
  312. package/src/version.js +1 -1
  313. package/types/basic.d.ts +3 -0
  314. package/types/index.d.ts +12 -0
  315. package/types/program.d.ts +536 -129
@@ -1,3 +1,24 @@
1
+ import { readScaleField } from "../../grammar/scales/index.js";
2
+ import { mapScaleConsumerValues } from "../scales/map.js";
3
+
4
+ // Appearance belongs to a final bar cell and never introduces another group.
5
+ export function resolveBarCellOpacities(program, layer, rowsByCell) {
6
+ const encoding = layer.encoding?.opacity;
7
+ if (encoding === undefined) return undefined;
8
+ const scale = program.resolvedScales[encoding.scale];
9
+ const values = rowsByCell.map(rows => {
10
+ const values = readScaleField(rows, encoding.field, encoding.fieldType, {
11
+ allowUnknown: Object.hasOwn(scale, "unknown")
12
+ });
13
+ if (new Set(values).size !== 1) {
14
+ throw new Error("Bar opacity requires one value within each final cell.");
15
+ }
16
+ return values[0];
17
+ });
18
+ return mapScaleConsumerValues(values, scale, "opacity");
19
+ }
20
+
21
+ import { hasRectRadius } from "../../grammar/roundedRect.js";
1
22
  import { DEFAULT_COLORS } from "../../theme/defaults.js";
2
23
  import { findDataset } from "../../selectors/datasets.js";
3
24
  import { findLayer } from "../../selectors/layers.js";
@@ -45,7 +66,7 @@ export function requireCompleteBar(program, id) {
45
66
  const dataset = applyItemMissingPolicy(layer, sourceDataset);
46
67
  const graphic = program.graphicSpec.objects[id];
47
68
  const roundedCollection = graphic?.type === "collection" &&
48
- Object.hasOwn(program.markConfigs[id]?.barAppearance ?? {}, "cornerRadius") &&
69
+ hasRectRadius(program.markConfigs[id]?.barAppearance) &&
49
70
  graphic.items?.every(child => ["rect", "path"].includes(child.type));
50
71
  if (graphic?.type !== "rect" && !roundedCollection) {
51
72
  throw new Error(`Bar mark "${id}" requires rect graphics.`);
@@ -1,4 +1,6 @@
1
1
  import { cloneAndFreeze, freezeOwned, isPlainObject } from "../core/immutable.js";
2
+ import { namespaceGraphicId } from "../selectors/graphicCapacity.js";
3
+ export { namespaceGraphicId } from "../selectors/graphicCapacity.js";
2
4
  import {
3
5
  requireSingleOrderedGraphicByType,
4
6
  walkGraphicTreeEvents
@@ -28,13 +30,6 @@ function validateCanvas(canvas, id) {
28
30
  }
29
31
  }
30
32
 
31
- export function namespaceGraphicId(namespace, id) {
32
- const encode = value => Array.from(value, character =>
33
- character.codePointAt(0).toString(16).padStart(6, "0")
34
- ).join("");
35
- return `g${encode(namespace)}_${id}`;
36
- }
37
-
38
33
  function rewriteItem(item, namespace) {
39
34
  if (!isPlainObject(item) || typeof item.id !== "string" || item.id.length === 0) {
40
35
  throw new TypeError("Graphic snapshot items require non-empty string ids.");
@@ -1,3 +1,4 @@
1
+ import { cartesianAxisOwners } from "../selectors/axes.js";
1
2
  import { isSourceOwnedText } from "../grammar/text.js";
2
3
  import { resolvePolarFrame } from "../grammar/polar.js";
3
4
  import { resolveEffectiveBounds } from "../layout/aspect.js";
@@ -202,3 +203,23 @@ export function resolveGuideCoordinateBounds(program, {
202
203
  }
203
204
  return resolveCoordinateBounds(program, target, { resolvedScales });
204
205
  }
206
+
207
+ // Axis consumers use a displaced frame; marks and grids retain the plot.
208
+ export function resolveCartesianAxisFrameBounds(program, channel, position, scale, axisId = channel) {
209
+ const plot = resolveGuideCoordinateBounds(program, {
210
+ coordinate: program.semanticSpec.guides.axis?.[axisId]?.coordinate, scale, channel
211
+ });
212
+ if (plot === undefined) return undefined;
213
+ const offset = program.guideConfigs.axis?.[axisId]?.frame?.offset ?? 0;
214
+ if (offset === 0) return plot;
215
+ const shift = ["bottom", "right"].includes(position) ? offset : -offset;
216
+ return channel === "x" ? { ...plot, y: plot.y + shift } : { ...plot, x: plot.x + shift };
217
+ }
218
+
219
+ // Cartesian guide bindings are semantic scale consumers without data rows.
220
+ export function axisScaleBindings(program, id) {
221
+ return cartesianAxisOwners(program).flatMap(owner =>
222
+ owner.scale === id && owner.coordinate !== undefined &&
223
+ owner.config?.binding?.standalone === true
224
+ ? [{ channel: owner.channel, coordinate: owner.coordinate }] : []);
225
+ }
@@ -1,6 +1,5 @@
1
1
  import { withGuideLayoutTransaction } from "./guides/layout.js";
2
2
  import {
3
- canDeferScaleConsumerApplication,
4
3
  getLayerScaleIds,
5
4
  getExistingMarkRematerializationStep,
6
5
  getMarkMaterializationStep,
@@ -13,54 +12,13 @@ import {
13
12
  applyMaterializationPlan as executeMaterializationPlan,
14
13
  buildMaterializationPlan
15
14
  } from "./planner.js";
16
- import { hasMaterializedLegend } from "./legends.js";
17
15
  import {
18
- needsCanvasScaleRematerialization,
19
16
  planScaleGuideRematerialization
20
17
  } from "./scaleGuideDependencies.js";
21
18
  import { planLayoutRematerialization } from "./layout.js";
22
19
  import { requireCoordinate } from "../selectors/coordinates.js";
23
20
 
24
- export function planCanvasRematerialization(program) {
25
- const marks = [];
26
- for (const layer of program.semanticSpec.layers) {
27
- const step = getMarkMaterializationStep(program, layer);
28
- if (step !== undefined) marks.push(step);
29
- }
30
- const deferredMarkIds = new Set(
31
- marks
32
- .map(step => step.args.id)
33
- );
34
- const scales = [];
35
- for (const scale of program.semanticSpec.scales) {
36
- if (needsCanvasScaleRematerialization(program, scale)) {
37
- const deferredConsumers = program.semanticSpec.layers.filter(layer =>
38
- canDeferScaleConsumerApplication(layer) &&
39
- getLayerScaleIds(layer).includes(scale.id)
40
- );
41
- const canDeferMarks = deferredConsumers.length === 0 ||
42
- deferredConsumers.every(layer => deferredMarkIds.has(layer.id));
43
- scales.push({
44
- op: "rematerializeScale",
45
- args: {
46
- id: scale.id,
47
- guides: false,
48
- ...(canDeferMarks ? { marks: false } : {})
49
- }
50
- });
51
- }
52
- }
53
- const guides = program.semanticSpec.scales.flatMap(scale =>
54
- needsCanvasScaleRematerialization(program, scale)
55
- ? planScaleGuideRematerialization(program, scale.id)
56
- : []
57
- );
58
- if (hasMaterializedLegend(program)) {
59
- guides.push({ op: "rematerializeLegend" });
60
- }
61
- const layout = planLayoutRematerialization(program);
62
- return buildMaterializationPlan({ scales, marks, guides, layout });
63
- }
21
+ export { planCanvasRematerialization } from "./layout.js";
64
22
 
65
23
  export function planCoordinateRematerialization(program, target) {
66
24
  requireCoordinate(program, target);
@@ -74,7 +32,9 @@ export function planCoordinateRematerialization(program, target) {
74
32
  ...(layer.encoding?.parallel?.dimensions ?? []).map(
75
33
  dimension => dimension.scale
76
34
  )
77
- ]).filter(id => id !== undefined))];
35
+ ]).filter(id => id !== undefined)),
36
+ ...Object.values(program.semanticSpec.guides.axis ?? {}).filter(guide => guide.coordinate === target)
37
+ .map(guide => guide.scale).filter(id => id !== undefined)];
78
38
  const directMarks = layers
79
39
  .map(layer => getMarkMaterializationStep(program, layer))
80
40
  .filter(step => step !== undefined);
@@ -0,0 +1,30 @@
1
+ import { prepareSharedFacetLegend } from "./preparation.js";
2
+ import { facetLegendIntent, intrinsicFacetLegendSource, restoreFacetLegendIntent,
3
+ prepareAuthoredFacetLegend } from "./owner.js";
4
+
5
+ export function editFacetParentLegend(program, operation, args) {
6
+ if (program.compositionSpec?.type !== "facet") {
7
+ throw new Error(`${operation} supports only a unit chart or a facet parent shared legend.`);
8
+ }
9
+ const current = program.compositionSpec.facet.guides.legend;
10
+ if (current === false) throw new Error(`${operation} requires an existing facet parent legend.`);
11
+ const prepared = current === "shared" ? prepareSharedFacetLegend(program) : prepareAuthoredFacetLegend(program);
12
+ let source;
13
+ if (prepared.source !== undefined) {
14
+ source = restoreFacetLegendIntent(intrinsicFacetLegendSource(program), facetLegendIntent(prepared.source));
15
+ } else {
16
+ const entries = prepared.compatibility.entries;
17
+ if (entries.length !== 1) throw new Error("Facet parent inferred legend requires an unambiguous native owner.");
18
+ const config = entries[0].config;
19
+ source = intrinsicFacetLegendSource(program).createLegend({ target: config.target, channels: ["color"],
20
+ itemGap: 26, titleStyle: { fontSize: 12, fontWeight: 700 },
21
+ ...(prepared.legend.mark === "point" ? { symbol: { layers: [{ type: "point", size: 5.5, stroke: "#ffffff", strokeWidth: 0.35 }] } } : {}) });
22
+ }
23
+ source = source[operation](args);
24
+ const intent = Object.keys(source.guideConfigs.legend ?? {}).length === 0 ? false : facetLegendIntent(source);
25
+ const spec = program.compositionSpec;
26
+ return program._clone({ trace: source.trace, actionStack: source.actionStack, actionSequence: source._actionSequence })
27
+ ._withCompositionState({ children: program.children,
28
+ compositionSpec: { ...spec, facet: { ...spec.facet, guides: { ...spec.facet.guides, legend: intent } } } })
29
+ .materializeComposition();
30
+ }
@@ -0,0 +1 @@
1
+ import{resolveAuthoredFacetLegendLayout as p}from"./ownerLayout.js";import{cartesianAxisOwners as B}from"../../selectors/axes.js";import{PLOT_LEGEND_POSITIONS as F}from"../../layout/legend.js";import{resolveConcreteGraphicBounds as G}from"../../grammar/schemas/graphicBounds.js";import{resolveFacetOuterSides as I}from"../../grammar/facets/guides.js";import{resolveTextBounds as P,textBoundsIntersect as C}from"../../core/textMetrics.js";import{axisGraphicIds as E}from"../guides/resources.js";import{resolveFacetHeaderItems as T}from"../facetHeaders.js";function b(o,t,e){return{left:o.left+t,right:o.right+t,top:o.top+e,bottom:o.bottom+e}}function M(o,t,e,f){const r=o.materializationConfigs.textMetrics,h=I({placements:t.children}),i=[];for(const s of t.children){const m=o.children[s.id];for(const a of B(m))for(const[g,w]of[[2,"labels"],[3,"title"]]){const n=a.config?.[w]?.position;if(o.compositionSpec.facet.guides.axes==="outer"&&!h[n]?.includes(s.id))continue;const u=E(a.channel,a.id)[g];if(m.graphicSpec.objects[u]===void 0)continue;const x=G(m.graphicSpec,u,r);x!==void 0&&i.push(b(x,s.x,s.y))}}const{items:d}=T(t,e,f);for(const s of d)s.properties.text!==""&&i.push(P(s.properties,r));return i}function O(o,t,e,f,r){const h=o.right-o.left,i=o.bottom-o.top,d=e==="right"?t.x+t.width+r:e==="left"?t.x-r-h:f==="left"?t.x:f==="right"?t.x+t.width-h:t.x+(t.width-h)/2,s=["left","right"].includes(e)?t.y:e==="top"?t.y-r-i:t.y+t.height+r;return{left:d,right:d+h,top:s,bottom:s+i}}function q(o,{layout:t,plot:e,plots:f,title:r,preparedHeaders:h,prepared:i}){if(i?.mode==="authored")return p(o,{layout:t,plot:e,title:r,prepared:i,guides:M(o,t,f,h)});if(i?.mode!=="promoted")return{layout:t,plot:e};const d=i.occupiedGroup;if(d?.placement.at!==void 0||F.includes(d?.placement.position))return{layout:t,plot:e};const s=i.reservation.position,m=i.reservation.align,a=i.kinds.map(c=>i.source.guideConfigs.legend[c]),g=i.reservation.gap,w=d===void 0?a.every(c=>c.inferredOffset===!0):d.inferredOffset===!0;let n=O(i.bounds,e,s,m,g);const u=M(o,t,f,h),x=u.map(c=>s==="right"?c.right-(e.x+e.width):s==="left"?e.x-c.left:s==="top"?e.y-c.top:c.bottom-(e.y+e.height));if(w)n=O(i.bounds,e,s,m,Math.max(g,...x.map(c=>c+g)));else if(u.some(c=>C(n,c)))throw new Error("Facet shared legend overlaps retained axes or headers.");const l=Math.max(0,t.padding.left-n.left),v=Math.max(0,r.height+t.padding.top-n.top),L=t.children.map(c=>({...c,x:c.x+l,y:c.y+v})),S={...e,x:e.x+l,y:e.y+v};return n=b(n,l,v),{plot:S,layout:{...t,children:L,width:Math.ceil(Math.max(t.width+l,n.right+t.padding.right)),height:Math.ceil(Math.max(t.height+v,n.bottom+t.padding.bottom)),legend:{position:s,x:n.left,y:n.top,width:n.right-n.left,height:n.bottom-n.top,translation:{x:n.left-i.bounds.left,y:n.top-i.bounds.top}}}}}export{q as resolvePromotedFacetLegendLayout};
@@ -0,0 +1,125 @@
1
+ import { findLayer } from "../../selectors/layers.js";
2
+ import { isAuthoredFacetLegend } from "../../grammar/facets/legendIntent.js";
3
+ export { isAuthoredFacetLegend };
4
+ import { freezeOwned } from "../../core/immutable.js";
5
+ import { unionConcreteGraphicBounds } from "../../grammar/schemas/graphicBounds.js";
6
+ import { describeLegendBlock } from "../../grammar/legendBlocks.js";
7
+ import { legendResourcePolicy, allLegendGraphicIds } from "../guides/resources.js";
8
+ import { resolveSharedFacetLegends, sameFacetGuideValue } from "../../grammar/facets/guides.js";
9
+
10
+ export function facetLegendIntent(source) {
11
+ return freezeOwned({ type: "authored-legend", blocks: Object.fromEntries(
12
+ Object.entries(source.guideConfigs.legend ?? {}).map(([kind, config]) => [kind, {
13
+ config,
14
+ ...(source.semanticSpec.guides.legend?.[legendResourcePolicy(kind).semanticKind] === undefined ? {} : {
15
+ guide: source.semanticSpec.guides.legend[legendResourcePolicy(kind).semanticKind]
16
+ })
17
+ }])
18
+ ), groups: source.guideConfigs.legendGroups ?? {} });
19
+ }
20
+
21
+ export function intrinsicFacetLegendSource(program) {
22
+ const first = program.children[program.compositionSpec.children[0]];
23
+ let source = new program.constructor({
24
+ semanticSpec: first.semanticSpec, graphicSpec: first.graphicSpec,
25
+ resolvedScales: first.resolvedScales, materializationConfigs: first.materializationConfigs,
26
+ context: { ...first.context, deferGuideLayoutValidation: true, facetLegendIntrinsic: true },
27
+ trace: program.trace, actionStack: program.actionStack, actionSequence: program._actionSequence
28
+ });
29
+ for (const id of allLegendGraphicIds(Object.keys(source.guideConfigs.legend ?? {}))) {
30
+ if (source.graphicSpec.objects[id] !== undefined) source = source.editGraphics({ target: id, remove: true });
31
+ }
32
+ for (const key of Object.keys(source.semanticSpec.guides.legend ?? {})) {
33
+ source = source.editSemantic({ property: `guide.legend.${key}`, remove: true });
34
+ }
35
+ return source._withoutMaterializationConfig(["guides", "legend"])
36
+ ._withoutMaterializationConfig(["guides", "legendGroups"]);
37
+ }
38
+
39
+ export function restoreFacetLegendIntent(source, intent) {
40
+ const scaffolds = [];
41
+ for (const [kind, { config }] of Object.entries(intent.blocks)) {
42
+ const descriptor = describeLegendBlock(source, kind, config);
43
+ const entry = { target: config.target, channels: [...descriptor.channels] };
44
+ if (descriptor.channels.includes("shape") && descriptor.channels.includes("size")) entry.combine = "size-shape";
45
+ if (kind === "colorSize") entry.combine = "color-size";
46
+ if (descriptor.family === "categorical" && config.inferredSymbol === false && entry.combine === undefined) entry.symbol = config.symbol;
47
+ if (kind === "gradient" && config.representation === "symbols") entry.representation = "symbols";
48
+ scaffolds.push(entry);
49
+ }
50
+ // Build unclipped resources first; each authored block restores its own window below.
51
+ // A global scaffold option would also reach gradient strips or overwrite sibling windows.
52
+ for (const args of scaffolds) source = source.createLegend(args);
53
+ for (const [kind, { config, guide }] of Object.entries(intent.blocks)) {
54
+ source = source._withLegendConfig(kind, config);
55
+ const background = legendResourcePolicy(kind).graphicIds.find(id => id.endsWith("Background"));
56
+ if (config.border === false && source.graphicSpec.objects[background] !== undefined) {
57
+ source = source.editGraphics({ target: background, remove: true });
58
+ } else if (config.border !== false && background !== undefined && source.graphicSpec.objects[background] === undefined) {
59
+ const roots = new Set(legendResourcePolicy(kind).graphicIds);
60
+ const before = source.graphicSpec.objects.canvas.children.find(id => roots.has(id));
61
+ source = source.createGraphics({ id: background, type: "rect", parent: "canvas", ...(before === undefined ? {} : { before }) });
62
+ }
63
+ if (guide !== undefined) {
64
+ for (const [property, value] of Object.entries(guide)) {
65
+ source = source.editSemantic({ property: `guide.legend.${legendResourcePolicy(kind).semanticKind}.${property}`, value });
66
+ }
67
+ }
68
+ }
69
+ source = source._withMaterializationConfig(["guides", "legendGroups"], intent.groups);
70
+ return source.rematerializeLegend();
71
+ }
72
+
73
+ export function prepareAuthoredFacetLegend(program, intent = program.compositionSpec.facet.guides.legend) {
74
+ let source = intrinsicFacetLegendSource(program);
75
+ source = isAuthoredFacetLegend(intent) ? restoreFacetLegendIntent(source, intent) : source.createLegend(intent);
76
+ const kinds = Object.keys(source.guideConfigs.legend ?? {});
77
+ for (const [kind, config] of Object.entries(source.guideConfigs.legend)) {
78
+ const descriptor = describeLegendBlock(source, kind, config);
79
+ const owner = findLayer(source, config.target);
80
+ for (const channel of descriptor.channels) {
81
+ if (program.compositionSpec.facet.scales[channel] === "independent") {
82
+ throw new Error("Facet parent legend requires shared encoded scales.");
83
+ }
84
+ }
85
+ for (const child of Object.values(program.children)) {
86
+ const candidate = findLayer(child, config.target);
87
+ if (candidate === undefined || candidate.mark.type !== owner.mark.type ||
88
+ descriptor.channels.some(channel => !sameFacetGuideValue(candidate.encoding?.[channel], owner.encoding?.[channel])) ||
89
+ !sameFacetGuideValue(child.markConfigs[config.target], source.markConfigs[config.target])) {
90
+ throw new Error("Facet parent legend requires compatible source mark bindings and recipes in every child.");
91
+ }
92
+ }
93
+ }
94
+ const compatibility = resolveSharedFacetLegends(program.compositionSpec.children.map(id => ({
95
+ id, guideConfigs: source.guideConfigs, resolvedScales: program.children[id].resolvedScales
96
+ })));
97
+ const blocks = kinds.map(kind => {
98
+ const ids = new Set(allLegendGraphicIds([kind]));
99
+ const roots = source.graphicSpec.objects.canvas.children.filter(id => ids.has(id));
100
+ const bounds = unionConcreteGraphicBounds(source.graphicSpec, roots, source.materializationConfigs.textMetrics);
101
+ if (bounds === undefined) throw new Error("Facet parent legend requires measurable block bounds.");
102
+ return { kind, roots, bounds, config: source.guideConfigs.legend[kind] };
103
+ });
104
+ return { mode: "authored", source, kinds, blocks, compatibility,
105
+ intent: facetLegendIntent(source),
106
+ roots: blocks.flatMap(block => block.roots),
107
+ bounds: unionConcreteGraphicBounds(source.graphicSpec, blocks.flatMap(block => block.roots), source.materializationConfigs.textMetrics),
108
+ reservation: { position: "right", align: "center", gap: 0, width: 0, height: 0 } };
109
+ }
110
+
111
+ // Theme reconciliation changes the derived native appearance in one transaction.
112
+ // Adopt only that transaction's configs; ordinary layout/data replay reads intent.
113
+ export function synchronizeFacetLegendAppearance(program) {
114
+ const intent = program.compositionSpec?.type === "facet" ? program.compositionSpec.facet.guides.legend : undefined;
115
+ if (!isAuthoredFacetLegend(intent)) return program;
116
+ const blocks = Object.fromEntries(Object.entries(intent.blocks).map(([kind, block]) => {
117
+ const config = program.guideConfigs.legend?.[kind];
118
+ if (config === undefined || config.target !== block.config.target) throw new Error("Parent theme reconciliation lost its legend owner.");
119
+ return [kind, { ...block, config }];
120
+ }));
121
+ const spec = program.compositionSpec;
122
+ return program._withCompositionState({ children: program.children,
123
+ compositionSpec: { ...spec, facet: { ...spec.facet,
124
+ guides: { ...spec.facet.guides, legend: { ...intent, blocks, groups: program.guideConfigs.legendGroups ?? {} } } } } });
125
+ }
@@ -0,0 +1,71 @@
1
+ import { localLegendPlacement } from "../../grammar/legendGroups.js";
2
+ import { PLOT_LEGEND_POSITIONS } from "../../core/vocabulary.js";
3
+ import { resolveLegendGroupPlacement } from "../../layout/legendGroup.js";
4
+ import { textBoundsIntersect } from "../../core/textMetrics.js";
5
+
6
+ const shift = (bounds, x, y) => ({ left: bounds.left + x, right: bounds.right + x,
7
+ top: bounds.top + y, bottom: bounds.bottom + y });
8
+
9
+ function nativeGroup(blocks) {
10
+ const config = blocks[0].config;
11
+ const positions = new Set(blocks.map(block => block.config.position));
12
+ if (positions.size > 1) throw new Error("Parent legend inherited blocks require one common anchor.");
13
+ const placement = { position: config.position ?? "right", align: config.align ?? "center",
14
+ offset: Math.max(...blocks.map(block => block.config.offset ?? 0)) };
15
+ return { placement, blockLayout: "wrap", inferredOffset: blocks.every(block => block.config.inferredOffset === true) };
16
+ }
17
+
18
+ export function resolveAuthoredFacetLegendLayout(program, { layout, plot, title, prepared, guides }) {
19
+ const partitions = [];
20
+ const targets = [...new Set(prepared.blocks.map(block => block.config.target))];
21
+ for (const target of targets) {
22
+ const owned = prepared.blocks.filter(block => block.config.target === target);
23
+ const inherited = owned.filter(block => localLegendPlacement(block.config) === undefined);
24
+ if (inherited.length) partitions.push({ blocks: inherited,
25
+ group: prepared.source.guideConfigs.legendGroups?.[target] ?? nativeGroup(inherited) });
26
+ for (const block of owned) {
27
+ const placement = localLegendPlacement(block.config);
28
+ if (placement !== undefined) partitions.push({ blocks: [block],
29
+ group: { placement, blockLayout: "column", inferredOffset: placement.inferredOffset } });
30
+ }
31
+ }
32
+ const placements = [];
33
+ for (const { blocks, group } of partitions) {
34
+ const position = group.placement.position;
35
+ const inside = group.placement.at !== undefined || PLOT_LEGEND_POSITIONS.includes(position);
36
+ let effective = group;
37
+ if (!inside && group.inferredOffset === true) {
38
+ const base = group.placement.offset;
39
+ const distances = guides.map(bound => position === "right" ? bound.right - plot.x - plot.width
40
+ : position === "left" ? plot.x - bound.left : position === "top" ? plot.y - bound.top
41
+ : bound.bottom - plot.y - plot.height);
42
+ effective = { ...group, placement: { ...group.placement, offset: Math.max(base, ...distances.map(value => value + base)) } };
43
+ }
44
+ const plans = resolveLegendGroupPlacement({ blocks: blocks.map(block => ({ id: block.kind, bounds: block.bounds })),
45
+ group: effective, plot, canvas: layout, deferCanvasFit: true });
46
+ for (const plan of plans) {
47
+ const block = blocks.find(block => block.kind === plan.id);
48
+ const bounds = shift(block.bounds, plan.dx, plan.dy);
49
+ if (inside && (bounds.left < plot.x || bounds.right > plot.x + plot.width ||
50
+ bounds.top < plot.y || bounds.bottom > plot.y + plot.height)) {
51
+ throw new Error("Facet parent legend does not fit the final plot union.");
52
+ }
53
+ if (guides.some(bound => textBoundsIntersect(bounds, bound))) {
54
+ throw new Error("Facet parent legend overlaps retained axes or headers.");
55
+ }
56
+ if (placements.some(other => textBoundsIntersect(bounds, other.bounds))) {
57
+ throw new Error("Facet parent legend blocks overlap.");
58
+ }
59
+ placements.push({ kind: block.kind, dx: plan.dx, dy: plan.dy, bounds, inside });
60
+ }
61
+ }
62
+ const dx = Math.max(0, ...placements.map(item => layout.padding.left - item.bounds.left));
63
+ const dy = Math.max(0, ...placements.map(item => title.height + layout.padding.top - item.bounds.top));
64
+ const moved = placements.map(item => ({ ...item, dx: item.dx + dx, dy: item.dy + dy,
65
+ bounds: shift(item.bounds, dx, dy) }));
66
+ return { plot: { ...plot, x: plot.x + dx, y: plot.y + dy },
67
+ layout: { ...layout, children: layout.children.map(cell => ({ ...cell, x: cell.x + dx, y: cell.y + dy })),
68
+ width: Math.ceil(Math.max(layout.width + dx, ...moved.map(item => item.bounds.right + layout.padding.right))),
69
+ height: Math.ceil(Math.max(layout.height + dy, ...moved.map(item => item.bounds.bottom + layout.padding.bottom))),
70
+ parentLegendPlacements: moved } };
71
+ }
@@ -1,3 +1,6 @@
1
+ import { translateGraphic } from "../graphicTranslation.js";
2
+ import { cartesianAxisOwners } from "../../selectors/axes.js";
3
+ import { resolveLegendGroupPlacement } from "../../layout/legendGroup.js";
1
4
  import { isPlainObject } from "../../core/immutable.js";
2
5
  import { resolveConcreteGraphicBounds } from
3
6
  "../../grammar/schemas/graphicBounds.js";
@@ -18,6 +21,9 @@ function copyLegendConfig(program, source) {
18
21
  for (const [kind, config] of Object.entries(source.guideConfigs.legend ?? {})) {
19
22
  next = next._withLegendConfig(kind, config);
20
23
  }
24
+ for (const [target, group] of Object.entries(source.guideConfigs.legendGroups ?? {})) {
25
+ next = next._withMaterializationConfig(["guides", "legendGroups", target], group);
26
+ }
21
27
  return next;
22
28
  }
23
29
 
@@ -33,6 +39,12 @@ function removeNamespacedGraphics(program, namespace, ids) {
33
39
  }
34
40
 
35
41
  function legendTranslation(prepared, plot, layout) {
42
+ if (layout.legend?.translation !== undefined) return layout.legend.translation;
43
+ if (prepared.occupiedGroup !== undefined) {
44
+ const [placement] = resolveLegendGroupPlacement({ blocks: [{ id: "shared", bounds: prepared.bounds }],
45
+ group: prepared.occupiedGroup, plot, canvas: layout });
46
+ return { x: placement.dx, y: placement.dy };
47
+ }
36
48
  const position = prepared.reservation.position;
37
49
  if (["top", "bottom"].includes(position)) {
38
50
  const width = prepared.bounds.right - prepared.bounds.left;
@@ -55,18 +67,16 @@ function legendTranslation(prepared, plot, layout) {
55
67
  const strips = prepared.source.graphicSpec.objects[`${prefix}Strips`];
56
68
  if (strips?.items?.[0] !== undefined) {
57
69
  const properties = strips.items[0].properties;
58
- const length = prepared.source.guideConfigs.legend[gradientKind].gradient.length;
59
70
  return {
60
71
  x: layout.legend.x - (position === "left"
61
72
  ? prepared.bounds.left
62
73
  : properties.x),
63
- y: plot.y + (plot.height - length) / 2 - properties.y
74
+ y: plot.y - prepared.bounds.top
64
75
  };
65
76
  }
66
- const height = prepared.bounds.bottom - prepared.bounds.top;
67
77
  return {
68
78
  x: layout.legend.x - prepared.bounds.left,
69
- y: plot.y + Math.max(0, (plot.height - height) / 2) - prepared.bounds.top
79
+ y: plot.y - prepared.bounds.top
70
80
  };
71
81
  }
72
82
 
@@ -91,7 +101,7 @@ function attachParentLegend(program, prepared, plot, layout) {
91
101
  return next;
92
102
  }
93
103
 
94
- function assertSharedLegendFits(program, id, layout, plot) {
104
+ function assertSharedLegendFits(program, id, layout, plot, occupiedGroup) {
95
105
  const bounds = resolveConcreteGraphicBounds(program.graphicSpec, id, program.materializationConfigs.textMetrics);
96
106
  if (bounds === undefined) {
97
107
  throw new Error("Facet shared legend requires measurable concrete bounds.");
@@ -102,7 +112,7 @@ function assertSharedLegendFits(program, id, layout, plot) {
102
112
  Math.max(bounds.left, plot.x) &&
103
113
  Math.min(bounds.bottom, plot.y + plot.height) >
104
114
  Math.max(bounds.top, plot.y);
105
- if (outside || overlapsPlot) {
115
+ if (outside || overlapsPlot && occupiedGroup === undefined) {
106
116
  throw new Error(
107
117
  "Facet shared legend requires more space than the composed Canvas provides."
108
118
  );
@@ -110,7 +120,32 @@ function assertSharedLegendFits(program, id, layout, plot) {
110
120
  return program;
111
121
  }
112
122
 
113
- export function applyFacetGuideComposition(program, { layout, plot } = {}) {
123
+ function attachAuthoredParentLegend(program, prepared, layout, synchronizeColorSizeMeaning) {
124
+ const meaning = prepared.source.semanticSpec.guides.legend?.colorSize;
125
+ if (meaning !== undefined || program.semanticSpec.guides.legend?.colorSize !== undefined) {
126
+ program = synchronizeColorSizeMeaning(program, meaning);
127
+ }
128
+ const id = `${program.compositionSpec.id}-shared-legend`;
129
+ let next = program._withoutMaterializationConfig(["guides", "legend"])
130
+ ._withoutMaterializationConfig(["guides", "legendGroups"]);
131
+ next = copyLegendConfig(next, prepared.source).createGraphics({ id, type: "canvas", parent: "canvas" });
132
+ for (const [property, value] of Object.entries({ x: 0, y: 0, width: layout.width, height: layout.height, background: "transparent" })) {
133
+ next = next.editGraphics({ target: id, property, value });
134
+ }
135
+ for (const block of prepared.blocks) {
136
+ const placement = layout.parentLegendPlacements.find(item => item.kind === block.kind);
137
+ for (const root of block.roots) {
138
+ next = attachSnapshotObject(next, prepared.source.graphicSpec, root, id);
139
+ next = translateGraphic(next, root, placement.dx, placement.dy);
140
+ }
141
+ }
142
+ if (prepared.keepShared === true) return next;
143
+ const spec = next.compositionSpec;
144
+ return next._withCompositionState({ children: next.children,
145
+ compositionSpec: { ...spec, facet: { ...spec.facet, guides: { ...spec.facet.guides, legend: prepared.intent } } } });
146
+ }
147
+
148
+ export function applyFacetGuideComposition(program, { layout, plot, synchronizeColorSizeMeaning } = {}) {
114
149
  if (!isPlainObject(layout) || !Array.isArray(layout.children)) {
115
150
  throw new TypeError("Facet guide composition requires a resolved layout.");
116
151
  }
@@ -122,8 +157,10 @@ export function applyFacetGuideComposition(program, { layout, plot } = {}) {
122
157
  const child = program.children[cell.id];
123
158
  return {
124
159
  id: cell.id,
125
- axes: Object.keys(child.guideConfigs.axis ?? {})
126
- .filter(channel => ["x", "y"].includes(channel)),
160
+ axes: cartesianAxisOwners(child).map(owner => owner.id),
161
+ axisOwners: cartesianAxisOwners(child).map(owner => ({ id: owner.id, channel: owner.channel,
162
+ components: Object.fromEntries(["line", "ticks", "labels", "title"].flatMap(component =>
163
+ owner.config?.[component] === undefined ? [] : [[component, owner.config[component].position]])) })),
127
164
  legendKinds: legendKinds(child)
128
165
  };
129
166
  });
@@ -138,8 +175,15 @@ export function applyFacetGuideComposition(program, { layout, plot } = {}) {
138
175
  for (const cell of layout.children) {
139
176
  const namespace = `${program.compositionSpec.id}-${cell.id}`;
140
177
  const childPlan = ownership.children[cell.id];
141
- for (const channel of childPlan.removeAxes) {
142
- next = removeNamespacedGraphics(next, namespace, axisGraphicIds(channel));
178
+ const axisOwners = cartesianAxisOwners(program.children[cell.id]);
179
+ for (const axisId of childPlan.removeAxes) {
180
+ const owner = axisOwners.find(owner => owner.id === axisId);
181
+ next = removeNamespacedGraphics(next, namespace, axisGraphicIds(owner.channel, axisId));
182
+ }
183
+ for (const [axisId, components] of Object.entries(childPlan.removeAxisComponents ?? {})) {
184
+ const owner = axisOwners.find(owner => owner.id === axisId);
185
+ const ids = axisGraphicIds(owner.channel, axisId);
186
+ next = removeNamespacedGraphics(next, namespace, components.map(component => ids[["line", "ticks", "labels", "title"].indexOf(component)]));
143
187
  }
144
188
  if (childPlan.removeLegends.length > 0) {
145
189
  next = removeNamespacedGraphics(
@@ -149,14 +193,27 @@ export function applyFacetGuideComposition(program, { layout, plot } = {}) {
149
193
  );
150
194
  }
151
195
  }
152
- if (prepared === undefined) return next;
196
+ if (prepared === undefined) {
197
+ if (next.semanticSpec.guides.legend?.colorSize !== undefined) {
198
+ next = synchronizeColorSizeMeaning(next, undefined);
199
+ }
200
+ return next._withoutMaterializationConfig(["guides", "legend"])
201
+ ._withoutMaterializationConfig(["guides", "legendGroups"]);
202
+ }
203
+ if (prepared.mode === "authored") {
204
+ next = attachAuthoredParentLegend(next, prepared, layout, synchronizeColorSizeMeaning);
205
+ return assertSharedLegendFits(next, `${program.compositionSpec.id}-shared-legend`, layout, plot, {});
206
+ }
153
207
  let id;
154
208
  if (prepared.mode === "legacyCategorical") {
155
209
  id = `${program.compositionSpec.id}-legend`;
156
- next = materializeLegacyCategoricalLegend(next, prepared, layout);
210
+ next = materializeLegacyCategoricalLegend(next, prepared, {
211
+ ...layout,
212
+ legend: { ...layout.legend, y: plot.y }
213
+ });
157
214
  } else {
158
215
  id = `${program.compositionSpec.id}-shared-legend`;
159
216
  next = attachParentLegend(next, prepared, plot, layout);
160
217
  }
161
- return assertSharedLegendFits(next, id, layout, plot);
218
+ return assertSharedLegendFits(next, id, layout, plot, prepared.occupiedGroup);
162
219
  }
@@ -1,3 +1,8 @@
1
+ import { localLegendPosition } from "../../grammar/legendGroups.js";
2
+ import { prepareAuthoredFacetLegend, facetLegendIntent } from "./owner.js";
3
+ import { isPlainObject } from "../../core/immutable.js";
4
+ import { PLOT_LEGEND_POSITIONS } from "../../layout/legend.js";
5
+ import { isContinuousColorScaleType } from "../../grammar/scales/types.js";
1
6
  import {
2
7
  unionConcreteGraphicBounds
3
8
  } from "../../grammar/schemas/graphicBounds.js";
@@ -109,7 +114,7 @@ function prepareAutoLegendSource(child) {
109
114
  request.channels.includes("color") ? "color" : "stroke"
110
115
  ]?.scale
111
116
  );
112
- const gradient = scale?.type === "sequential";
117
+ const gradient = isContinuousColorScaleType(scale?.type);
113
118
  const gradientPrefix = request.channels.includes("color")
114
119
  ? "colorGradient"
115
120
  : "strokeGradient";
@@ -123,7 +128,7 @@ function prepareAutoLegendSource(child) {
123
128
  titleStyle: { color: DEFAULT_COLORS.strongText, fontSize: 11 }
124
129
  } : {})
125
130
  });
126
- if (gradient) {
131
+ if (gradient && scale.type === "sequential") {
127
132
  const resolved = source.resolvedScales[scale.id];
128
133
  source = source
129
134
  .editGraphics({
@@ -176,6 +181,7 @@ function promotedLegendPlacement(source, kinds) {
176
181
  }
177
182
 
178
183
  export function prepareSharedFacetLegend(program) {
184
+ if (isPlainObject(program.compositionSpec.facet.guides.legend)) return prepareAuthoredFacetLegend(program);
179
185
  if (program.compositionSpec.facet.guides.legend !== "shared") {
180
186
  return undefined;
181
187
  }
@@ -188,6 +194,9 @@ export function prepareSharedFacetLegend(program) {
188
194
  }
189
195
  const source = inferred ? prepareAutoLegendSource(first) : first;
190
196
  const kinds = legendKinds(source);
197
+ if (kinds.some(kind => localLegendPosition(source.guideConfigs.legend[kind]) !== undefined)) {
198
+ return { ...prepareAuthoredFacetLegend(program, facetLegendIntent(source)), keepShared: true };
199
+ }
191
200
  const compatibility = resolveSharedFacetLegends(
192
201
  compatibleLegendChildren(program, source, inferred)
193
202
  );
@@ -201,8 +210,13 @@ export function prepareSharedFacetLegend(program) {
201
210
  if (bounds === undefined) {
202
211
  throw new Error("Shared facet legend has no measurable concrete bounds.");
203
212
  }
204
- const gradient = kinds.some(kind => ["gradient", "strokeGradient"].includes(kind));
205
- const placement = promotedLegendPlacement(source, kinds);
213
+ const targets = new Set(kinds.map(kind => source.guideConfigs.legend[kind].target));
214
+ const group = targets.size === 1 ? source.guideConfigs.legendGroups?.[[...targets][0]] : undefined;
215
+ const insideGroup = group !== undefined && (group.placement.at !== undefined || PLOT_LEGEND_POSITIONS.includes(group.placement.position));
216
+ const placement = group === undefined ? promotedLegendPlacement(source, kinds)
217
+ : { position: insideGroup ? "right" : group.placement.position,
218
+ align: group.placement.align ?? "left" };
219
+
206
220
  const width = Math.ceil(bounds.right - bounds.left);
207
221
  return {
208
222
  mode: "promoted",
@@ -212,11 +226,12 @@ export function prepareSharedFacetLegend(program) {
212
226
  roots,
213
227
  bounds,
214
228
  compatibility,
229
+ ...(group === undefined ? {} : { occupiedGroup: group }),
215
230
  reservation: {
216
231
  ...placement,
217
- gap: gradient ? 24 : 18,
218
- width: Math.max(gradient ? 112 : 132, width),
219
- height: Math.ceil(bounds.bottom - bounds.top)
232
+ gap: insideGroup ? 0 : group?.placement.offset ?? Math.max(...kinds.map(kind => source.guideConfigs.legend[kind].offset)),
233
+ width: insideGroup ? 0 : width,
234
+ height: insideGroup ? 0 : Math.ceil(bounds.bottom - bounds.top)
220
235
  }
221
236
  };
222
237
  }