ggaction 0.0.24 → 0.0.26

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 (217) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -2
  3. package/knowledge/action-cards.json +1 -1
  4. package/knowledge/intent-taxonomy.json +1 -1
  5. package/knowledge/mcp-resources.json +1 -1
  6. package/knowledge/task-resolver.js +1 -1
  7. package/package.json +1 -1
  8. package/src/accessibility.js +13 -0
  9. package/src/actions/canvas/actions.js +2 -1
  10. package/src/actions/canvas/fitting.js +68 -13
  11. package/src/actions/charts/beeswarm.js +2 -1
  12. package/src/actions/charts/ecdf.js +2 -1
  13. package/src/actions/charts/endpoints.js +25 -16
  14. package/src/actions/charts/heatmap.js +5 -3
  15. package/src/actions/charts/missing.js +17 -0
  16. package/src/actions/charts/radar.js +70 -33
  17. package/src/actions/charts/raincloud.js +18 -4
  18. package/src/actions/charts/rug-strip.js +2 -2
  19. package/src/actions/charts/shared.js +17 -0
  20. package/src/actions/composition/actions.js +24 -7
  21. package/src/actions/composition/adjacent.js +90 -0
  22. package/src/actions/composition/index.js +11 -1
  23. package/src/actions/composition/layer.js +42 -0
  24. package/src/actions/composition/layerPlan.js +176 -0
  25. package/src/actions/composition/targets.js +105 -0
  26. package/src/actions/data/derived.js +3 -0
  27. package/src/actions/data/filter.js +1 -1
  28. package/src/actions/data/raincloudFilter.js +3 -1
  29. package/src/actions/encodings/appearance.js +26 -7
  30. package/src/actions/encodings/channels.js +1 -0
  31. package/src/actions/encodings/color/continuous.js +37 -34
  32. package/src/actions/encodings/color/index.js +57 -43
  33. package/src/actions/encodings/color/policy.js +15 -0
  34. package/src/actions/encodings/density/resolve.js +3 -2
  35. package/src/actions/encodings/density.js +2 -2
  36. package/src/actions/encodings/offset.js +2 -0
  37. package/src/actions/encodings/pathOrder.js +2 -0
  38. package/src/actions/encodings/position/areaAlignment.js +42 -0
  39. package/src/actions/encodings/position/index.js +37 -3
  40. package/src/actions/encodings/position/policies/line.js +1 -1
  41. package/src/actions/encodings/ranged.js +2 -0
  42. package/src/actions/encodings/remove.js +13 -10
  43. package/src/actions/encodings/ruleAppearance.js +14 -2
  44. package/src/actions/facets/actions.js +1 -1
  45. package/src/actions/guides/axes/axes.js +2 -1
  46. package/src/actions/guides/axes/edit.js +1 -1
  47. package/src/actions/guides/axes/labels.js +2 -1
  48. package/src/actions/guides/axes/parallel/lifecycle.js +11 -4
  49. package/src/actions/guides/axes/parallel/policy.js +5 -2
  50. package/src/actions/guides/axes/parallel/resolve.js +3 -1
  51. package/src/actions/guides/axes/parallel.js +9 -4
  52. package/src/actions/guides/axes/titles.js +1 -1
  53. package/src/actions/guides/facadeLegend.js +3 -2
  54. package/src/actions/guides/grids/grid.js +3 -2
  55. package/src/actions/guides/grids/resolve.js +2 -1
  56. package/src/actions/guides/guides.js +3 -2
  57. package/src/actions/guides/legends/automatic.js +28 -0
  58. package/src/actions/guides/legends/blocks.js +15 -21
  59. package/src/actions/guides/legends/categorical/actions.js +1 -1
  60. package/src/actions/guides/legends/categorical/components.js +4 -3
  61. package/src/actions/guides/legends/categorical/layout.js +5 -2
  62. package/src/actions/guides/legends/categorical/options.js +9 -14
  63. package/src/actions/guides/legends/categorical/recipes.js +3 -0
  64. package/src/actions/guides/legends/categorical/resolve.js +7 -5
  65. package/src/actions/guides/legends/colorSize.js +51 -14
  66. package/src/actions/guides/legends/continuous/common.js +1 -1
  67. package/src/actions/guides/legends/continuous/gradient.js +1 -1
  68. package/src/actions/guides/legends/continuous/opacity.js +2 -0
  69. package/src/actions/guides/legends/edit.js +1 -1
  70. package/src/actions/guides/legends/focused.js +2 -2
  71. package/src/actions/guides/legends/group.js +1 -1
  72. package/src/actions/guides/legends/lane.js +1 -1
  73. package/src/actions/guides/legends/lifecycle.js +2 -2
  74. package/src/actions/guides/legends/remove.js +18 -4
  75. package/src/actions/guides/legends/size.js +1 -1
  76. package/src/actions/guides/legends/transition.js +15 -6
  77. package/src/actions/guides/polar/axes/facade.js +4 -2
  78. package/src/actions/guides/polar/axes/labels.js +6 -1
  79. package/src/actions/guides/polar/axes/shared.js +4 -4
  80. package/src/actions/guides/polar/axes/titles.js +13 -2
  81. package/src/actions/guides/polar/grids.js +6 -3
  82. package/src/actions/guides/tickValues.js +7 -1
  83. package/src/actions/index.js +2 -0
  84. package/src/actions/marks/area/actions.js +9 -2
  85. package/src/actions/marks/area/materialize.js +1 -1
  86. package/src/actions/marks/bar/materialize.js +4 -4
  87. package/src/actions/marks/lifecycle.js +14 -0
  88. package/src/actions/marks/line/actions.js +3 -0
  89. package/src/actions/marks/line/materialize.js +4 -2
  90. package/src/actions/marks/point/edit.js +2 -0
  91. package/src/actions/marks/point/jitter.js +2 -0
  92. package/src/actions/marks/point/materialize.js +5 -4
  93. package/src/actions/marks/point/packing.js +2 -0
  94. package/src/actions/marks/references.js +5 -3
  95. package/src/actions/marks/remove.js +16 -0
  96. package/src/actions/marks/shared.js +2 -1
  97. package/src/actions/marks/text/actions.js +5 -2
  98. package/src/actions/overlays/index.js +168 -0
  99. package/src/actions/overlays/snapshot.js +45 -0
  100. package/src/actions/primitives/editGraphics.js +21 -27
  101. package/src/actions/primitives/semanticAction.js +4 -5
  102. package/src/actions/primitives/semanticValidation/guide.js +1 -0
  103. package/src/actions/primitives/semanticValidation/layer.js +7 -0
  104. package/src/actions/scales/consumers/common.js +10 -0
  105. package/src/actions/scales/consumers/families.js +1 -1
  106. package/src/actions/scales/preview.js +1 -1
  107. package/src/actions/selection/actions.js +63 -11
  108. package/src/actions/titles/actions.js +3 -14
  109. package/src/actions/titles/resolve.js +12 -9
  110. package/src/core/action.js +41 -25
  111. package/src/core/compositionScales.js +12 -0
  112. package/src/core/compositionState.js +16 -2
  113. package/src/core/immutable.js +11 -6
  114. package/src/core/programState.js +16 -0
  115. package/src/core/resourceReferences.js +19 -3
  116. package/src/core/validation.js +7 -0
  117. package/src/core/vocabulary.js +8 -2
  118. package/src/grammar/actionTargets.js +24 -0
  119. package/src/grammar/bin2d.js +7 -0
  120. package/src/grammar/colorSizeLegend.js +56 -5
  121. package/src/grammar/displayLabels.js +10 -0
  122. package/src/grammar/facetHeaders.js +1 -1
  123. package/src/grammar/facets/dependencies.js +10 -3
  124. package/src/grammar/facets/guides.js +2 -1
  125. package/src/grammar/facets/legendIntent.js +7 -5
  126. package/src/grammar/legendBlocks.js +1 -1
  127. package/src/grammar/legendCombinations.js +1 -1
  128. package/src/grammar/legendGroups.js +1 -1
  129. package/src/grammar/legendItemsOffset.js +12 -0
  130. package/src/grammar/legendOverflow.js +25 -0
  131. package/src/grammar/lineSeries.js +1 -1
  132. package/src/grammar/markSelection.js +23 -0
  133. package/src/grammar/overlay.js +51 -0
  134. package/src/grammar/parallelCoordinates.js +1 -208
  135. package/src/grammar/pathCommands.js +8 -2
  136. package/src/grammar/pathSeries.js +1 -1
  137. package/src/grammar/plotComposition.js +152 -0
  138. package/src/grammar/pointShapes.js +18 -0
  139. package/src/grammar/scales/transformed.js +1 -1
  140. package/src/grammar/schemas/concreteGraphic.js +8 -1
  141. package/src/grammar/schemas/graphicBounds.js +8 -1
  142. package/src/grammar/schemas/graphicTree.js +18 -2
  143. package/src/grammar/schemas/semanticPath.js +3 -0
  144. package/src/grammar/strokeStyle.js +14 -8
  145. package/src/grammar/text.js +72 -0
  146. package/src/inspection.js +1 -1
  147. package/src/inspectionDescriptors.js +1 -1
  148. package/src/layout/composition.js +30 -4
  149. package/src/layout/compositionLegend.js +91 -0
  150. package/src/layout/facetHeaderTitles.js +1 -1
  151. package/src/layout/labels.js +55 -27
  152. package/src/layout/legend.js +3 -2
  153. package/src/layout/legendGroup.js +1 -1
  154. package/src/layout/legendItems.js +7 -6
  155. package/src/layout/legendLane.js +1 -1
  156. package/src/layout/legendText.js +1 -1
  157. package/src/materialization/adjacentSource.js +27 -0
  158. package/src/materialization/composition.js +165 -52
  159. package/src/materialization/compositionLegends.js +120 -0
  160. package/src/materialization/compositionScales.js +125 -0
  161. package/src/materialization/compositionTitle.js +96 -0
  162. package/src/materialization/facetGuides/edit.js +22 -2
  163. package/src/materialization/facetGuides/layout.js +1 -1
  164. package/src/materialization/facetGuides/owner.js +4 -3
  165. package/src/materialization/facetGuides/ownerLayout.js +12 -6
  166. package/src/materialization/facetGuides/placement.js +19 -22
  167. package/src/materialization/facetGuides/preparation.js +17 -5
  168. package/src/materialization/facetHeaders.js +5 -1
  169. package/src/materialization/facets.js +1 -1
  170. package/src/materialization/guides/layout.js +1 -1
  171. package/src/materialization/guides/resources.js +1 -1
  172. package/src/materialization/guides/visibility.js +105 -0
  173. package/src/materialization/legendClip.js +1 -1
  174. package/src/materialization/legendGroups.js +1 -1
  175. package/src/materialization/legendItemsOffset.js +68 -0
  176. package/src/materialization/legendSuppression.js +22 -0
  177. package/src/materialization/legends.js +3 -2
  178. package/src/materialization/marks/capabilities.js +4 -0
  179. package/src/materialization/marks/index.js +5 -1
  180. package/src/materialization/marks/pathOrder.js +1 -1
  181. package/src/materialization/marks/policies.js +2 -1
  182. package/src/materialization/overlayItems.js +154 -0
  183. package/src/materialization/scales/policies/binnedPosition.js +43 -5
  184. package/src/materialization/scales/policies/series.js +12 -4
  185. package/src/materialization/scales/resolve.js +2 -0
  186. package/src/materialization/selection/items/bar.js +21 -3
  187. package/src/materialization/selection/items/common.js +18 -5
  188. package/src/materialization/selection/items/overlay.js +15 -0
  189. package/src/materialization/selection/items/path.js +1 -0
  190. package/src/materialization/selection/items/point.js +2 -3
  191. package/src/materialization/selection/items/tick.js +3 -12
  192. package/src/materialization/selection/path.js +2 -2
  193. package/src/materialization/selection/policies/bar.js +2 -0
  194. package/src/materialization/selection/policies/index.js +35 -1
  195. package/src/materialization/selection/policies/point.js +2 -0
  196. package/src/materialization/selection/policies/text.js +17 -0
  197. package/src/materialization/selection/rows.js +103 -0
  198. package/src/materialization/selection/state.js +19 -3
  199. package/src/materialization/selection/styles.js +8 -0
  200. package/src/materialization/text.js +6 -489
  201. package/src/materialization/textGeometry.js +503 -0
  202. package/src/materialization/textLines.js +20 -0
  203. package/src/persistence/validation.js +97 -24
  204. package/src/renderers/canvas/index.js +12 -7
  205. package/src/renderers/canvas/text.js +5 -5
  206. package/src/renderers/nativeBackend.js +25 -0
  207. package/src/renderers/pdf.js +3 -3
  208. package/src/renderers/png.js +4 -3
  209. package/src/renderers/svgSerialization.js +1 -643
  210. package/src/scene.js +5 -3
  211. package/src/selectors/plotComposition.js +115 -0
  212. package/src/selectors/selectionTargets.js +19 -0
  213. package/src/version.js +1 -1
  214. package/types/basic.d.ts +2 -2
  215. package/types/index.d.ts +11 -0
  216. package/types/inspection.d.ts +34 -0
  217. package/types/program.d.ts +553 -365
@@ -1,4 +1,12 @@
1
- import { freezeOwned } from "../core/immutable.js";
1
+ import { resolveConcreteGraphicBounds, resolveGraphicParentOffset } from "../grammar/schemas/graphicBounds.js";
2
+ import { layoutCompositionLegends } from "../layout/compositionLegend.js";
3
+ import { compositionLegends } from "./compositionLegends.js";
4
+ import { translateGraphic } from "./graphicTranslation.js";
5
+ import { compositionTitleLayout, materializeCompositionTitle } from "./compositionTitle.js";
6
+ import { resolveGraphicBounds } from "../layout/canvas.js";
7
+ import { resolveSharedCompositionChildren } from "./compositionScales.js";
8
+ import { resolvePlacedPlotBounds } from "../layout/composition.js";
9
+ import { cloneAndFreeze, freezeOwned } from "../core/immutable.js";
2
10
  import {
3
11
  requireSingleOrderedGraphicByType
4
12
  } from "../grammar/schemas/graphicTree.js";
@@ -6,7 +14,7 @@ import {
6
14
  resolveCompositionLayout,
7
15
  resolveCompositionSnapshotPlacement
8
16
  } from "../layout/composition.js";
9
- import { namespaceGraphicSnapshot } from "./compositionSnapshot.js";
17
+ import { namespaceGraphicSnapshot, namespaceGraphicId } from "./compositionSnapshot.js";
10
18
 
11
19
  const ZERO_MARGIN = freezeOwned({ top: 0, right: 0, bottom: 0, left: 0 });
12
20
 
@@ -34,16 +42,117 @@ export function compositionChildDescriptor(id, program) {
34
42
  };
35
43
  }
36
44
 
37
- export function resolveCompositionProgramLayout(program) {
38
- program._assertCompositionProgram("resolveCompositionProgramLayout");
45
+ export function compositionPlotBounds(program) {
46
+ if (!program.compositionSpec) return resolveGraphicBounds(program);
47
+ if (program.compositionSpec.type === "facet") {
48
+ // The retained facet already owns concrete child placements, including
49
+ // header/legend reservations. Read those snapshots without re-entering its
50
+ // materializer (which uses the shared snapshot attachment helpers below).
51
+ const placements = program.compositionSpec.children.map(id => {
52
+ const { id: canvas } = requireSingleOrderedGraphicByType(program.children[id].graphicSpec, "canvas");
53
+ const root = program.graphicSpec.objects[namespaceGraphicId(`${program.compositionSpec.id}-${id}`, canvas)];
54
+ if (!root) throw new Error("Plot alignment requires a materialized facet view.");
55
+ return { id, x: root.properties.x, y: root.properties.y, width: root.properties.width, height: root.properties.height };
56
+ });
57
+ return resolvePlacedPlotBounds({ placements, plots: program.compositionSpec.children.map(id =>
58
+ ({ id, ...resolveGraphicBounds(program.children[id]) })) });
59
+ }
60
+ const { children, layout } = prepareCompositionSnapshots(program);
61
+ return resolvePlacedPlotBounds({ placements: layout.children,
62
+ plots: layout.children.map(({ id }) => ({ id, ...compositionPlotBounds(children[id]) })) });
63
+ }
64
+
65
+ function resizeCompositionPlotExtent(program, dimension, size) {
66
+ const current = compositionPlotBounds(program);
67
+ if (Math.abs(current[dimension] - size) < 1e-6) return program;
68
+ if (!program.compositionSpec) return program.editCanvas({ plot: {
69
+ width: current.width, height: current.height, [dimension]: size } });
39
70
  const spec = program.compositionSpec;
40
- return resolveCompositionLayout({
41
- direction: spec.direction,
42
- children: spec.children.map(id => compositionChildDescriptor(id, program.children[id])),
43
- gap: spec.gap,
44
- align: spec.align,
45
- padding: spec.padding
46
- });
71
+ const delta = size - current[dimension];
72
+ if (spec.type === "facet") {
73
+ const plots = Object.values(program.children).map(resolveGraphicBounds);
74
+ if (!plots.length || plots.some(plot => plot.width !== plots[0].width || plot.height !== plots[0].height)) {
75
+ throw new Error("Plot-aligned facets require uniform source plot sizes.");
76
+ }
77
+ const tracks = dimension === "width" ? spec.columns : Math.ceil(spec.children.length / spec.columns);
78
+ return program.editFacetSource({ action: { method: "editCanvas", options: { plot: {
79
+ width: plots[0].width, height: plots[0].height, [dimension]: plots[0][dimension] + delta / tracks } } } });
80
+ }
81
+ const parallel = (dimension === "width") === (spec.direction === "horizontal");
82
+ const increment = parallel ? delta / spec.children.length : delta;
83
+ const children = Object.fromEntries(spec.children.map(id => [id,
84
+ resizeCompositionPlotExtent(program.children[id], dimension, compositionPlotBounds(program.children[id])[dimension] + increment)]));
85
+ return program._withCompositionState({ compositionSpec: spec, children }).materializeComposition();
86
+ }
87
+
88
+ const snapshotCache = new WeakMap();
89
+
90
+ // Measure the visible snapshots that remain after guide collection. Canvas
91
+ // margins and the removed legend's old lane are not occupied chart content.
92
+ function retainedCompositionBounds(program, children, layout, legend) {
93
+ const removed = new Set(legend.flatMap(block => block.remove)), bounds = [];
94
+ for (const cell of layout.children) {
95
+ const child = resizeChildForSnapshot(children[cell.id], cell);
96
+ const placement = resolveSnapshotPlacement(child, cell, layout);
97
+ const namespace = `${program.compositionSpec.id}-${cell.id}`;
98
+ const visit = id => {
99
+ if (removed.has(namespaceGraphicId(namespace, id))) return;
100
+ const object = child.graphicSpec.objects[id];
101
+ if (object.type === "canvas") {
102
+ for (const nested of object.children ?? []) visit(nested);
103
+ } else {
104
+ const value = resolveConcreteGraphicBounds(child.graphicSpec, id, child.materializationConfigs.textMetrics);
105
+ if (value) bounds.push({ left: value.left + placement.x, right: value.right + placement.x,
106
+ top: value.top + placement.y, bottom: value.bottom + placement.y });
107
+ }
108
+ };
109
+ for (const id of child.graphicSpec.order) visit(id);
110
+ }
111
+ return bounds;
112
+ }
113
+
114
+ export function prepareCompositionSnapshots(program) {
115
+ program._assertCompositionProgram("prepareCompositionSnapshots");
116
+ const spec = program.compositionSpec;
117
+ let bySpec = snapshotCache.get(program.children);
118
+ const cached = bySpec?.get(spec);
119
+ if (cached && cached.titleConfig === program.titleConfig && cached.semanticTitle === program.semanticSpec.title) return cached;
120
+ let children = resolveSharedCompositionChildren(program.children, spec.share);
121
+ if (spec.align === "plot") {
122
+ const dimension = spec.direction === "horizontal" ? "height" : "width";
123
+ const plots = Object.fromEntries(spec.children.map(id => [id, compositionPlotBounds(children[id])]));
124
+ const size = Math.max(...Object.values(plots).map(plot => plot[dimension]));
125
+ children = Object.fromEntries(spec.children.map(id => {
126
+ const child = children[id];
127
+ if (Math.abs(plots[id][dimension] - size) < 1e-6) return [id, child];
128
+ return [id, resizeCompositionPlotExtent(child, dimension, size)];
129
+ }));
130
+ }
131
+ const title = compositionTitleLayout(program);
132
+ const padding = { top: 0, bottom: 0, left: 0, right: 0, ...spec.padding };
133
+ if (title.config) padding[title.config.position] += title.reservation + 8;
134
+ const baseLayout = resolveCompositionLayout({ direction: spec.direction,
135
+ children: spec.children.map(id => ({ ...compositionChildDescriptor(id, children[id]),
136
+ ...(spec.align === "plot" ? { plot: compositionPlotBounds(children[id]) } : {}) })),
137
+ gap: spec.gap, align: spec.align, padding });
138
+ const legends = compositionLegends(program, children);
139
+ // Ordinary Canvas compositions also accept low-level graphical programs
140
+ // with no semantic plot margins. Only collected, plot-anchored guides need
141
+ // this data-region measurement; start/center/end layout uses child Canvases.
142
+ const plot = legends.length ? resolvePlacedPlotBounds({ placements: baseLayout.children,
143
+ plots: baseLayout.children.map(({ id }) => ({ id, ...compositionPlotBounds(children[id]) })) }) : undefined;
144
+ const retained = legends.length ? retainedCompositionBounds(program, children, baseLayout, legends) : [];
145
+ const { layout, frames: legendFrames } = layoutCompositionLegends(baseLayout, plot, legends, title, retained);
146
+ const dx = layout.children[0].x - baseLayout.children[0].x, dy = layout.children[0].y - baseLayout.children[0].y;
147
+ const retainedBounds = retained.map(b => ({ left: b.left + dx, right: b.right + dx, top: b.top + dy, bottom: b.bottom + dy }));
148
+ const result = { children, layout, title, legends, legendFrames, retainedBounds, titleConfig: program.titleConfig, semanticTitle: program.semanticSpec.title };
149
+ if (!bySpec) { bySpec = new WeakMap(); snapshotCache.set(program.children, bySpec); }
150
+ bySpec.set(spec, result);
151
+ return result;
152
+ }
153
+
154
+ export function resolveCompositionProgramLayout(program) {
155
+ return prepareCompositionSnapshots(program).layout;
47
156
  }
48
157
 
49
158
  function resizeChildForSnapshot(program, placement) {
@@ -65,7 +174,7 @@ function resizeChildForSnapshot(program, placement) {
65
174
  }
66
175
 
67
176
  function resolveSnapshotPlacement(program, placement, layout) {
68
- if (program.compositionSpec === undefined) return placement;
177
+ if (program.compositionSpec === undefined || layout.align === "plot") return placement;
69
178
  const canvas = requireSingleOrderedGraphicByType(program.graphicSpec, "canvas");
70
179
  return resolveCompositionSnapshotPlacement({
71
180
  direction: layout.direction,
@@ -76,54 +185,38 @@ function resolveSnapshotPlacement(program, placement, layout) {
76
185
  });
77
186
  }
78
187
 
79
- function itemDefinitions(object) {
80
- return object.items.map(item => ({
81
- type: item.type ?? object.type,
82
- properties: item.properties
83
- }));
84
- }
85
-
86
188
  export function attachSnapshotObject(program, snapshot, id, parent) {
87
- const object = snapshot.objects[id];
88
- let next = program.createGraphics({
89
- id,
90
- type: object.type,
91
- parent,
92
- ...(object.items !== undefined && object.type !== "collection"
93
- ? { length: object.items.length }
94
- : {})
95
- });
96
- for (const [property, value] of Object.entries(object.properties ?? {})) {
97
- next = next.editGraphics({ target: id, property, value });
98
- }
99
- if (object.items !== undefined) {
100
- next = next.editGraphics({
101
- target: id,
102
- property: "items",
103
- value: itemDefinitions(object)
104
- });
105
- }
106
- if (object.clip !== undefined) next = next.editGraphics({target: id, property: "clip", value: object.clip});
107
- for (const childId of object.children ?? []) {
108
- next = attachSnapshotObject(next, snapshot, childId, id);
109
- }
110
- return next;
189
+ // Snapshot input is already materialized by the child domain actions. The
190
+ // enclosing materializeComposition/facet action owns this concrete transfer;
191
+ // there is no second semantic compilation or per-property authoring replay.
192
+ const objects = { ...program.graphicSpec.objects };
193
+ const attach = key => {
194
+ if (objects[key] !== undefined) throw new Error(`Graphic "${key}" already exists in the receiving snapshot.`);
195
+ const object = snapshot.objects[key];
196
+ if (!object) throw new Error(`Unknown snapshot graphic "${key}".`);
197
+ objects[key] = cloneAndFreeze(object);
198
+ for (const child of object.children ?? []) attach(child);
199
+ };
200
+ attach(id);
201
+ const owner = objects[parent];
202
+ if (!owner || !["canvas", "collection"].includes(owner.type)) throw new Error("Snapshot attachment requires a Canvas or collection parent.");
203
+ objects[parent] = freezeOwned({ ...owner, children: freezeOwned([...(owner.children ?? []), id]) });
204
+ return program._clone({ graphicSpec: freezeOwned({ ...program.graphicSpec, objects: freezeOwned(objects) }) });
111
205
  }
112
206
 
113
207
  export function clearCompositionChildren(program) {
114
208
  const canvas = program.graphicSpec.objects.canvas;
115
- let next = program;
116
- for (const id of canvas?.children ?? []) {
117
- next = next.editGraphics({ target: id, remove: true });
118
- }
119
- return next;
209
+ return canvas?.children?.length ? program.editGraphics({ target: canvas.children, remove: true }) : program;
120
210
  }
121
211
 
122
212
  export function materializeCompositionGraphics(program) {
123
213
  program._assertCompositionProgram("materializeCompositionGraphics");
124
- const layout = resolveCompositionProgramLayout(program);
125
- const background = program.graphicSpec.objects.canvas?.properties
126
- ?.background ?? "white";
214
+ const { layout, children, title, legends, legendFrames } = prepareCompositionSnapshots(program);
215
+ // A uniform child surface also covers parent gaps and reserved guide lanes.
216
+ // Mixed local backgrounds retain the composition's existing outer surface.
217
+ const backgrounds = new Set(Object.values(children).map(child => child.graphicSpec.objects.canvas?.properties.background));
218
+ const background = backgrounds.size === 1 && !backgrounds.has(undefined)
219
+ ? [...backgrounds][0] : program.graphicSpec.objects.canvas?.properties.background ?? "white";
127
220
  let next = clearCompositionChildren(program);
128
221
  if (next.graphicSpec.objects.canvas === undefined) {
129
222
  next = next.createGraphics({ id: "canvas", type: "canvas" });
@@ -137,7 +230,7 @@ export function materializeCompositionGraphics(program) {
137
230
  }
138
231
 
139
232
  for (const placement of layout.children) {
140
- const source = program.children[placement.id];
233
+ const source = children[placement.id];
141
234
  const child = resizeChildForSnapshot(source, placement);
142
235
  const snapshotPlacement = resolveSnapshotPlacement(child, placement, layout);
143
236
  const snapshot = namespaceGraphicSnapshot(child.graphicSpec, {
@@ -148,6 +241,26 @@ export function materializeCompositionGraphics(program) {
148
241
  next = attachSnapshotObject(next, snapshot, snapshot.order[0], "canvas");
149
242
  }
150
243
 
244
+ for (const legend of legends) {
245
+ for (const id of legend.remove) if (next.graphicSpec.objects[id]) next = next.editGraphics({ target: id, remove: true });
246
+ const { source, roots, bounds, id } = legend;
247
+ next = next.createGraphics({ id, type: "canvas", parent: "canvas" });
248
+ for (const [property, value] of Object.entries({ ...legendFrames.get(id), background: "transparent" })) {
249
+ next = next.editGraphics({ target: id, property, value });
250
+ }
251
+ const snapshot = namespaceGraphicSnapshot(source.graphicSpec, { namespace: id });
252
+ for (const root of roots) {
253
+ const key = namespaceGraphicId(id, root);
254
+ next = attachSnapshotObject(next, snapshot, key, id);
255
+ const [parentX, parentY] = resolveGraphicParentOffset(source.graphicSpec, root);
256
+ next = translateGraphic(next, key, parentX - bounds.left, parentY - bounds.top);
257
+ }
258
+ }
259
+ if (title.config) {
260
+ const plot = resolvePlacedPlotBounds({ placements: layout.children,
261
+ plots: layout.children.map(({ id }) => ({ id, ...compositionPlotBounds(children[id]) })) });
262
+ next = materializeCompositionTitle(next, plot, title);
263
+ }
151
264
  return next._withCanvasConfig({
152
265
  margin: ZERO_MARGIN,
153
266
  size: { width: "auto", height: "auto" }
@@ -0,0 +1,120 @@
1
+ import { describeLegendBlock, legendBlockChannels } from "../grammar/legendBlocks.js";
2
+ import { allLegendGraphicIds } from "./guides/resources.js";
3
+ import { unionConcreteGraphicBounds } from "../grammar/schemas/graphicBounds.js";
4
+ import { namespaceGraphicId } from "./compositionSnapshot.js";
5
+ import { localLegendPlacement } from "../grammar/legendGroups.js";
6
+ import { mapScaleConsumerValues } from "./scales/map.js";
7
+ import { restoreLegendItemsOffset } from "./legendItemsOffset.js";
8
+
9
+ // A categorical color/shape block can explain a color-only view too. Compare
10
+ // encoded mappings, not labels or resource IDs. Constant glyphs in another
11
+ // view do not establish a conflicting shape encoding.
12
+ function coversLegend(candidate, other) {
13
+ if (candidate.channels.length === 1 && other.channels.length === 1) return true;
14
+ if (candidate.block.family !== "categorical" || other.block.family !== "categorical" ||
15
+ candidate.config.field !== other.config.field ||
16
+ other.channels.some(channel => !candidate.channels.includes(channel))) return false;
17
+ const values = other.config.domain;
18
+ if (!Array.isArray(values) || values.some(value => !candidate.config.domain.includes(value))) return false;
19
+ return other.channels.every(channel => {
20
+ const scaleFor = item => item.mappingSource.resolvedScales[item.config.scales[item.config.channels.indexOf(channel)]];
21
+ const a = scaleFor(candidate), b = scaleFor(other);
22
+ if (!a || !b || values.some(value => !a.domain.includes(value) || !b.domain.includes(value))) return false;
23
+ return JSON.stringify(mapScaleConsumerValues(values, a, channel)) ===
24
+ JSON.stringify(mapScaleConsumerValues(values, b, channel));
25
+ });
26
+ }
27
+
28
+ const visibleBlocks = new WeakMap();
29
+ function collect(child) {
30
+ if (visibleBlocks.has(child)) return visibleBlocks.get(child);
31
+ const candidates = [];
32
+ if (child.compositionSpec && !child.compositionSpec.facet) {
33
+ for (const id of child.compositionSpec.children) {
34
+ for (const candidate of collect(child.children[id])) {
35
+ const remove = candidate.remove.map(root => namespaceGraphicId(`${child.compositionSpec.id}-${id}`, root));
36
+ // A nested composition may already have promoted this block. Its
37
+ // canonical leaves still contain it, but its rendered snapshot does not.
38
+ if (remove.every(root => child.graphicSpec.objects[root])) candidates.push({ ...candidate, remove,
39
+ targets: candidate.targets.map(target => ({ ...target, childPath: [id, ...target.childPath] })) });
40
+ }
41
+ }
42
+ for (const representative of compositionLegends(child, child.children)) {
43
+ const id = representative.id;
44
+ if (child.graphicSpec.objects[id]) {
45
+ const bounds = unionConcreteGraphicBounds(child.graphicSpec, [id], child.materializationConfigs.textMetrics);
46
+ const dx = bounds.left - representative.bounds.left, dy = bounds.top - representative.bounds.top;
47
+ const anchor = representative.anchorBounds ?? representative.bounds;
48
+ candidates.push({ ...representative, source: child,
49
+ roots: child.graphicSpec.objects[id].children ?? [id], remove: [id], bounds,
50
+ anchorBounds: { left: anchor.left + dx, right: anchor.right + dx, top: anchor.top + dy, bottom: anchor.bottom + dy } });
51
+ }
52
+ }
53
+ } else {
54
+ const intrinsic = restoreLegendItemsOffset(child);
55
+ for (const [kind, config] of Object.entries(child.guideConfigs.legend ?? {})) {
56
+ const channels = legendBlockChannels(kind, config);
57
+ const roots = allLegendGraphicIds([kind]).filter(id => child.graphicSpec.objects[id]);
58
+ const bounds = unionConcreteGraphicBounds(child.graphicSpec, roots, child.materializationConfigs.textMetrics);
59
+ if (!bounds) continue;
60
+ const local = localLegendPlacement(config);
61
+ const group = child.guideConfigs.legendGroups?.[config.target];
62
+ candidates.push({ source: child, mappingSource: child, roots, bounds,
63
+ anchorBounds: unionConcreteGraphicBounds(intrinsic.graphicSpec, roots, child.materializationConfigs.textMetrics), channels, config,
64
+ targets: [{ childPath: [], ...(child.compositionSpec?.facet ? {} : { id: config.target }) }],
65
+ block: describeLegendBlock(child, kind, config),
66
+ placement: { ...(local ?? group?.placement ??
67
+ { position: config.position, align: config.align, offset: config.offset }),
68
+ inferredOffset: local?.inferredOffset ?? group?.inferredOffset ?? config.inferredOffset }, remove: roots });
69
+ }
70
+ }
71
+ visibleBlocks.set(child, candidates);
72
+ return candidates;
73
+ }
74
+
75
+ // Scale compatibility is validated before this plan. Canonical child guides
76
+ // remain intact; only the composed graphic snapshots are collected.
77
+ export function compositionLegends(program, children = program.children) {
78
+ const candidates = program.compositionSpec.children.flatMap(id => collect(children[id]).map(candidate => ({
79
+ ...candidate, remove: candidate.remove.map(root => namespaceGraphicId(`${program.compositionSpec.id}-${id}`, root)),
80
+ targets: candidate.targets.map(target => ({ ...target, childPath: [id, ...target.childPath] }))
81
+ })));
82
+ const plans = [];
83
+ const removed = new Set();
84
+ if (program.compositionSpec.share?.color) {
85
+ const colors = candidates.filter(candidate => candidate.channels.includes("color"));
86
+ const ordered = [...colors].sort((a, b) => b.channels.length - a.channels.length);
87
+ const representative = ordered.find(candidate => colors.every(other => coversLegend(candidate, other)))
88
+ ?? colors.find(candidate => candidate.channels.length === 1);
89
+ if (representative) {
90
+ const collected = colors.filter(candidate => coversLegend(representative, candidate));
91
+ collected.forEach(candidate => removed.add(candidate));
92
+ plans.push({ ...representative, id: `${program.compositionSpec.id}-shared-color-legend`,
93
+ remove: collected.flatMap(candidate => candidate.remove),
94
+ targets: collected.flatMap(candidate => candidate.targets) });
95
+ }
96
+ }
97
+ for (const candidate of candidates) if (program.compositionSpec.legends === "collect" && !removed.has(candidate)) plans.push({ ...candidate,
98
+ // Derive identity from the canonical owner, never the current stack index.
99
+ id: namespaceGraphicId(JSON.stringify([program.compositionSpec.id, candidate.targets, candidate.channels]), "legend") });
100
+ return plans;
101
+ }
102
+
103
+ // New neighbours keep the existing shared guide's authored presentation. Scale
104
+ // mappings and symbol semantics still belong to each native constructor.
105
+ export function adjacentLegendPresentation(source, target, child) {
106
+ const from = collect(source).find(candidate => candidate.config.target === target && candidate.channels.includes("color"));
107
+ const to = collect(child).find(candidate => candidate.channels.includes("color"));
108
+ if (!from || !to) return undefined;
109
+ const config = from.config, options = { ...from.placement };
110
+ if (from.placement.inferredOffset ?? config.inferredOffset) delete options.offset;
111
+ delete options.inferredOffset;
112
+ for (const key of ["labels", "titleStyle", "titlePosition", "border", "itemsOffset"]) if (config[key] !== undefined) options[key] = config[key];
113
+ if (!config.inferredTitle) options.title = config.titleVisible === false ? false : config.title;
114
+ if (from.block.family === to.block.family) {
115
+ const presentation = from.block.family === "gradient" ? ["direction", "gradient"] : ["direction", "columns", "itemGap"];
116
+ for (const key of presentation) if (config[key] !== undefined) options[key] = config[key];
117
+ if (config.symbol && config.inferredSymbol === false) options.symbol = config.symbol;
118
+ }
119
+ return { target: to.config.target, channel: "color", ...options };
120
+ }
@@ -0,0 +1,125 @@
1
+ import { normalizeCompositionShares } from "../core/compositionScales.js";
2
+ import { findSemanticScale } from "../selectors/scales.js";
3
+ import { ownership } from "../selectors/markOwners.js";
4
+ import { findCoordinate } from "../selectors/coordinates.js";
5
+ import { hasOrdinalDomain } from "../grammar/scales/types.js";
6
+ import { compositionChannel } from "../selectors/plotComposition.js";
7
+
8
+ // A facet retains an unpartitioned source recipe at its root. Its resolved
9
+ // source scale is not a displayed scale: aggregation/stacking is recomputed in
10
+ // each cell. Read the actual cell domains, including independent facet scales.
11
+ function displayedDomains(program, id) {
12
+ if (program.compositionSpec?.type === "facet") {
13
+ return program.compositionSpec.children.flatMap(child => displayedDomains(program.children[child], id));
14
+ }
15
+ return program.resolvedScales[id] ? [program.resolvedScales[id].domain] : [];
16
+ }
17
+
18
+ function mergeDomains(scale, domains, channel) {
19
+ if (hasOrdinalDomain(scale.type)) return [...new Set(domains.flat())];
20
+ if (scale.type === "quantile") return domains.flat().toSorted((a, b) => a - b);
21
+ if (!domains.length || domains.some(domain => domain.length !== 2 || domain.some(value => !Number.isFinite(value)))) {
22
+ throw new Error(`Shared ${channel} requires finite domains or compatible ordinal categories.`);
23
+ }
24
+ return [Math.min(...domains.flat()), Math.max(...domains.flat())];
25
+ }
26
+
27
+ function bindings(program, channel, path = []) {
28
+ if (program.compositionSpec && program.compositionSpec.type !== "facet") return Object.entries(program.children).flatMap(([id, child]) => bindings(child, channel, [...path, id]));
29
+ const owners = ownership(program);
30
+ const entries = new Map();
31
+ for (const layer of program.semanticSpec.layers) {
32
+ if (owners.has(layer.id) || layer.derivedBindings?.overlay || program.markConfigs[layer.id]?.statisticalReference) continue;
33
+ const encoding = layer.encoding?.[channel];
34
+ if (!encoding?.scale) continue;
35
+ // A constant anchor has no data-field meaning. Its private scale (for
36
+ // example an annotation's 0..1 plot coordinates) is not a shared data axis.
37
+ const role = compositionChannel(program, encoding, layer.data);
38
+ if (!role) continue;
39
+ if (["x", "y"].includes(channel) && findCoordinate(program, layer.coordinate)?.type !== "cartesian") {
40
+ throw new Error(`Shared ${channel} requires Cartesian positions.`);
41
+ }
42
+ const scale = findSemanticScale(program, encoding.scale);
43
+ if (!scale || !program.resolvedScales[scale.id]) throw new Error(`Shared ${channel} requires a materialized scale.`);
44
+ const meaning = { field: role.field, fields: role.fields, fieldType: role.fieldType,
45
+ aggregateUnit: role.measurement.unit === "sum" ? "value" : role.measurement.unit, temporalUnit: role.temporalUnit };
46
+ const previous = entries.get(scale.id);
47
+ if (previous && JSON.stringify(previous.meaning) !== JSON.stringify(meaning)) {
48
+ throw new Error(`Shared ${channel} has ambiguous field meaning inside a view.`);
49
+ }
50
+ entries.set(scale.id, { path, program, scale, meaning,
51
+ domain: mergeDomains(scale, displayedDomains(program, scale.id), channel) });
52
+ }
53
+ if (!entries.size) throw new Error(`Shared ${channel} is not encoded in view "${path.join("/") || "root"}".`);
54
+ return [...entries.values()];
55
+ }
56
+
57
+ function compatibleDefinition(scale, channel) {
58
+ const omitted = new Set(["id", "domain", "emptyDomain", "nice", "zero"]);
59
+ if (["x", "y"].includes(channel)) omitted.add("range");
60
+ return Object.fromEntries(Object.entries(scale).filter(([key]) => !omitted.has(key)).sort(([a], [b]) => a.localeCompare(b)));
61
+ }
62
+
63
+ function unionDomain(entries, channel) {
64
+ const first = entries[0];
65
+ const meaning = JSON.stringify(first.meaning);
66
+ const definition = JSON.stringify(compatibleDefinition(first.scale, channel));
67
+ for (const entry of entries) {
68
+ if (JSON.stringify(entry.meaning) !== meaning || JSON.stringify(compatibleDefinition(entry.scale, channel)) !== definition) {
69
+ throw new Error(`Cannot share ${channel}: field meaning and scale mapping must be compatible across views.`);
70
+ }
71
+ }
72
+ const explicit = entries.filter(entry => Array.isArray(entry.scale.domain)).map(entry => entry.scale.domain);
73
+ if (explicit.some(domain => JSON.stringify(domain) !== JSON.stringify(explicit[0]))) {
74
+ throw new Error(`Cannot share ${channel}: explicit domains conflict.`);
75
+ }
76
+ // An explicit common domain is intentional. Otherwise derive a fresh union on
77
+ // each composition edit; never feed yesterday's union back into inference.
78
+ if (explicit.length) return explicit[0];
79
+ return mergeDomains(first.scale, entries.map(entry => entry.domain), channel);
80
+ }
81
+
82
+ function updatePath(program, path, callback) {
83
+ if (!path.length) return callback(program);
84
+ const [id, ...rest] = path;
85
+ const children = { ...program.children, [id]: updatePath(program.children[id], rest, callback) };
86
+ return program._withCompositionState({ compositionSpec: program.compositionSpec, children });
87
+ }
88
+
89
+ function materializeChangedAncestors(original, revised) {
90
+ if (original === revised || !revised.compositionSpec || revised.compositionSpec.type === "facet") return revised;
91
+ const children = Object.fromEntries(Object.entries(revised.children).map(([id, child]) =>
92
+ [id, materializeChangedAncestors(original.children[id], child)]));
93
+ return revised._withCompositionState({ compositionSpec: revised.compositionSpec, children }).materializeComposition();
94
+ }
95
+
96
+ // Canonical children retain authored domains. Only render snapshots receive a
97
+ // shared resolved domain, just as concat's automatic sizing derives snapshots.
98
+ export function resolveSharedCompositionChildren(children, requested) {
99
+ const share = normalizeCompositionShares(requested);
100
+ const plans = [];
101
+ for (const [channel, enabled] of Object.entries(share)) {
102
+ if (!enabled) continue;
103
+ const entries = Object.entries(children).flatMap(([id, child]) => bindings(child, channel, [id]));
104
+ const domain = unionDomain(entries, channel);
105
+ for (const entry of entries) plans.push({ ...entry, domain });
106
+ }
107
+ let result = children;
108
+ const seen = new Map();
109
+ for (const { path: [id, ...path], scale, domain } of plans) {
110
+ const key = JSON.stringify([id, path, scale.id]);
111
+ if (seen.has(key)) {
112
+ if (JSON.stringify(seen.get(key)) !== JSON.stringify(domain)) throw new Error("Shared channels require incompatible domains on the same scale.");
113
+ continue;
114
+ }
115
+ seen.set(key, domain);
116
+ result = { ...result, [id]: updatePath(result[id], path, child => {
117
+ if (JSON.stringify(child.resolvedScales[scale.id]?.domain) === JSON.stringify(domain) &&
118
+ displayedDomains(child, scale.id).every(value => JSON.stringify(value) === JSON.stringify(domain))) return child;
119
+ return child.compositionSpec?.type === "facet"
120
+ ? child.editFacetSource({ action: { method: "editScale", options: { id: scale.id, domain } } })
121
+ : child.editScale({ id: scale.id, domain });
122
+ }) };
123
+ }
124
+ return Object.fromEntries(Object.entries(result).map(([id, child]) => [id, materializeChangedAncestors(children[id], child)]));
125
+ }
@@ -0,0 +1,96 @@
1
+ import { textBoundsFitCanvas } from "../core/textMetrics.js";
2
+ import { resolveConcreteGraphicBounds } from "../grammar/schemas/graphicBounds.js";
3
+ import { alignedTextAnchor, alignedTitleAnchor, buildTitleReadingBlock, paintedTitleComponent, resolveTitleComponentBounds, titleTextProperties } from "../layout/title.js";
4
+ const TITLE_IDS = ["chartTitle", "chartSubtitle"];
5
+ export function compositionTitleLayout(program) {
6
+ if (program.semanticSpec.title.text === undefined) {
7
+ return { height: 0 };
8
+ }
9
+ const config = program.titleConfig;
10
+ if (config === undefined) {
11
+ throw new Error("Composition title requires chart title configuration.");
12
+ }
13
+ const block = buildTitleReadingBlock({
14
+ text: program.semanticSpec.title.text,
15
+ subtitle: program.semanticSpec.title.subtitle
16
+ }, config, program.materializationConfigs.textMetrics);
17
+ const top = 8 + config.offset;
18
+ if (top < 0) {
19
+ throw new Error("Composition title offset places text outside the Canvas.");
20
+ }
21
+ const reservation = Math.ceil(top + block.height);
22
+ return { config, block, top, reservation, height: config.position === "top" ? reservation : 0 };
23
+ }
24
+
25
+ function materializeTitleComponent(program, id, lines, centers, style, plot, title) {
26
+ if (lines.length === 0) return program;
27
+ const { config, block, top } = title;
28
+ const canvas = program.graphicSpec.objects.canvas.properties;
29
+ const horizontal = ["top", "bottom"].includes(config.position);
30
+ const component = paintedTitleComponent(horizontal ? {
31
+ x: alignedTextAnchor(plot.x, plot.width, config.align) + (config.alongOffset ?? 0),
32
+ y: centers.map(center => (config.position === "top" ? top : canvas.height - top - block.height) + center),
33
+ lines, textAlign: config.align,
34
+ ...(config.position === "bottom" ? { rotation: 0, explicitRotation: true } : {})
35
+ } : {
36
+ x: centers.map(center => config.position === "left" ? top + center : canvas.width - top - center),
37
+ y: alignedTitleAnchor(plot.y, plot.height, block.width, config.align) + (config.alongOffset ?? 0),
38
+ lines, textAlign: "center", rotation: config.position === "left" ? -Math.PI / 2 : Math.PI / 2,
39
+ explicitRotation: true
40
+ });
41
+ const bounds = resolveTitleComponentBounds(component, style, program.materializationConfigs.textMetrics);
42
+ if (!textBoundsFitCanvas(bounds, program.graphicSpec.objects.canvas.properties)) {
43
+ throw new Error("Composition title places text outside the Canvas.");
44
+ }
45
+ const count = component.lines.length;
46
+ let next = program.createGraphics({
47
+ id,
48
+ type: "text",
49
+ ...(count > 1 ? { length: count } : {}),
50
+ parent: "canvas"
51
+ });
52
+ for (const [property, value] of Object.entries({ ...titleTextProperties(component, style),
53
+ ...(component.explicitRotation ? { rotation: component.rotation } : {})
54
+ })) {
55
+ next = next.editGraphics({ target: id, property, value });
56
+ }
57
+ return next;
58
+ }
59
+
60
+ export function materializeCompositionTitle(program, plot, title) {
61
+ if (title.config.position === "top" && program.compositionSpec.topLane !== "shared") {
62
+ // Child Canvas margins already reserve space above visible chart content.
63
+ // Anchor against the retained ink instead of adding that empty space to
64
+ // the title gap. Keep child placements, authored margins and the explicit
65
+ // shared top-lane contract intact.
66
+ let edge = plot.y;
67
+ for (const [id, object] of Object.entries(program.graphicSpec.objects)) {
68
+ if (TITLE_IDS.includes(id) || object.type === "canvas" || object.children?.length ||
69
+ object.properties?.opacity === 0) continue;
70
+ const bounds = resolveConcreteGraphicBounds(program.graphicSpec, id, program.materializationConfigs.textMetrics);
71
+ if (bounds) edge = Math.min(edge, bounds.top);
72
+ }
73
+ title = { ...title, top: Math.max(title.top, edge - 16 - title.block.height) };
74
+ }
75
+ let next = materializeTitleComponent(
76
+ program,
77
+ TITLE_IDS[0],
78
+ title.block.titleLines,
79
+ title.block.titleCenters,
80
+ title.config.titleStyle,
81
+ plot,
82
+ title
83
+ );
84
+ if (title.block.subtitleLines.length > 0) {
85
+ next = materializeTitleComponent(
86
+ next,
87
+ TITLE_IDS[1],
88
+ title.block.subtitleLines,
89
+ title.block.subtitleCenters,
90
+ title.config.subtitleStyle,
91
+ plot,
92
+ title
93
+ );
94
+ }
95
+ return next;
96
+ }