ggaction 0.0.2 → 0.0.4

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 (242) hide show
  1. package/CHANGELOG.md +55 -2
  2. package/README.md +95 -72
  3. package/package.json +15 -5
  4. package/src/actions/boxPlots/components.js +3 -3
  5. package/src/actions/boxPlots/edit.js +277 -0
  6. package/src/actions/boxPlots/index.js +2 -0
  7. package/src/actions/boxPlots/materialize.js +12 -1
  8. package/src/actions/boxPlots/options.js +25 -14
  9. package/src/actions/canvas/actions.js +21 -17
  10. package/src/actions/composition/actions.js +247 -0
  11. package/src/actions/composition/index.js +1 -0
  12. package/src/actions/coordinates/actions.js +2 -5
  13. package/src/actions/data/density.js +7 -1
  14. package/src/actions/data/filter.js +3 -1
  15. package/src/actions/data/regression.js +1 -1
  16. package/src/actions/encodings/appearance.js +12 -1
  17. package/src/actions/encodings/barWidth.js +5 -3
  18. package/src/actions/encodings/color/continuous.js +137 -0
  19. package/src/actions/encodings/color/index.js +151 -0
  20. package/src/actions/encodings/color/layout.js +91 -0
  21. package/src/actions/encodings/color/policy.js +88 -0
  22. package/src/actions/encodings/index.js +4 -2
  23. package/src/actions/encodings/offset.js +91 -73
  24. package/src/actions/encodings/position/apply.js +66 -0
  25. package/src/actions/encodings/position/index.js +130 -0
  26. package/src/actions/encodings/position/policies/arc.js +26 -0
  27. package/src/actions/encodings/position/policies/bar.js +9 -5
  28. package/src/actions/encodings/position/policies/index.js +5 -1
  29. package/src/actions/encodings/position/policies/line.js +20 -3
  30. package/src/actions/encodings/position/resolve.js +58 -9
  31. package/src/actions/encodings/ranged.js +30 -8
  32. package/src/actions/encodings/shared.js +17 -21
  33. package/src/actions/encodings/strokeDash.js +1 -1
  34. package/src/actions/encodings/text.js +73 -0
  35. package/src/actions/errorBands/create.js +37 -29
  36. package/src/actions/errorBands/edit.js +206 -0
  37. package/src/actions/errorBands/index.js +9 -0
  38. package/src/actions/errorBands/options.js +39 -0
  39. package/src/actions/errorBars/create.js +50 -43
  40. package/src/actions/errorBars/edit.js +130 -0
  41. package/src/actions/errorBars/index.js +3 -0
  42. package/src/actions/errorBars/options.js +52 -0
  43. package/src/actions/facets/actions.js +162 -0
  44. package/src/actions/facets/derive.js +200 -0
  45. package/src/actions/facets/guides.js +16 -0
  46. package/src/actions/facets/index.js +1 -0
  47. package/src/actions/facets/replay.js +69 -0
  48. package/src/actions/guides/applicability.js +113 -0
  49. package/src/actions/guides/axes/axes.js +70 -16
  50. package/src/actions/guides/axes/edit.js +126 -0
  51. package/src/actions/guides/axes/index.js +12 -0
  52. package/src/actions/guides/axes/labels.js +22 -19
  53. package/src/actions/guides/axes/remove.js +60 -0
  54. package/src/actions/guides/axes/tickGroups.js +7 -5
  55. package/src/actions/guides/axes/ticks.js +10 -5
  56. package/src/actions/guides/axes/titles.js +8 -6
  57. package/src/actions/guides/grids/grid.js +144 -15
  58. package/src/actions/guides/grids/resolve.js +6 -14
  59. package/src/actions/guides/guides.js +17 -75
  60. package/src/actions/guides/index.js +6 -0
  61. package/src/actions/guides/legends/categorical/actions.js +48 -9
  62. package/src/actions/guides/legends/categorical/components.js +16 -16
  63. package/src/actions/guides/legends/categorical/index.js +14 -14
  64. package/src/actions/guides/legends/categorical/layout.js +7 -3
  65. package/src/actions/guides/legends/categorical/options.js +2 -5
  66. package/src/actions/guides/legends/categorical/resolve.js +2 -2
  67. package/src/actions/guides/legends/categorical/symbols.js +14 -14
  68. package/src/actions/guides/legends/continuous/common.js +7 -7
  69. package/src/actions/guides/legends/continuous/interval.js +6 -6
  70. package/src/actions/guides/legends/continuous/opacity.js +1 -1
  71. package/src/actions/guides/legends/edit.js +4 -26
  72. package/src/actions/guides/legends/focused.js +63 -0
  73. package/src/actions/guides/legends/index.js +4 -0
  74. package/src/actions/guides/legends/remove.js +46 -0
  75. package/src/actions/guides/legends/size.js +13 -15
  76. package/src/actions/guides/legends/target.js +25 -0
  77. package/src/actions/guides/polar/axes/facade.js +219 -0
  78. package/src/actions/guides/polar/axes/index.js +55 -0
  79. package/src/actions/guides/polar/axes/labels.js +188 -0
  80. package/src/actions/guides/polar/axes/lines.js +113 -0
  81. package/src/actions/guides/polar/axes/shared.js +112 -0
  82. package/src/actions/guides/polar/axes/ticks.js +164 -0
  83. package/src/actions/guides/polar/axes/titles.js +165 -0
  84. package/src/actions/guides/polar/grids.js +252 -0
  85. package/src/actions/guides/polar/index.js +2 -0
  86. package/src/actions/guides/polar/resolve.js +269 -0
  87. package/src/actions/guides/tickValues.js +1 -1
  88. package/src/actions/index.js +4 -0
  89. package/src/actions/intervals/resolve.js +1 -1
  90. package/src/actions/marks/arc/actions.js +239 -0
  91. package/src/actions/marks/arc/index.js +1 -0
  92. package/src/actions/marks/{area.js → area/actions.js} +51 -38
  93. package/src/actions/marks/area/index.js +4 -0
  94. package/src/actions/marks/bar/create.js +26 -5
  95. package/src/actions/marks/bar/edit.js +1 -1
  96. package/src/actions/marks/bar/materialize.js +25 -31
  97. package/src/actions/marks/index.js +12 -4
  98. package/src/actions/marks/lifecycle.js +27 -0
  99. package/src/actions/marks/line/actions.js +373 -0
  100. package/src/actions/marks/line/index.js +1 -0
  101. package/src/actions/marks/{point.js → point/actions.js} +67 -108
  102. package/src/actions/marks/point/index.js +1 -0
  103. package/src/actions/marks/rect/actions.js +165 -0
  104. package/src/actions/marks/rect/index.js +1 -0
  105. package/src/actions/marks/remove.js +214 -0
  106. package/src/actions/marks/{rule.js → rule/actions.js} +34 -28
  107. package/src/actions/marks/rule/index.js +1 -0
  108. package/src/actions/marks/shared.js +156 -25
  109. package/src/actions/marks/text/actions.js +192 -0
  110. package/src/actions/marks/text/index.js +1 -0
  111. package/src/actions/primitives/createGraphics.js +13 -6
  112. package/src/actions/primitives/editGraphics.js +10 -3
  113. package/src/actions/primitives/semantic.js +26 -3
  114. package/src/actions/primitives/semanticValue.js +26 -4
  115. package/src/actions/regression/components.js +2 -2
  116. package/src/actions/regression/create.js +19 -2
  117. package/src/actions/regression/edit.js +247 -0
  118. package/src/actions/regression/index.js +3 -0
  119. package/src/actions/regression/resolve.js +3 -1
  120. package/src/actions/scales/consumers/common.js +91 -0
  121. package/src/actions/scales/consumers/families.js +47 -0
  122. package/src/actions/scales/consumers/index.js +41 -0
  123. package/src/actions/scales/{consumers.js → consumers/seriesLayout.js} +14 -127
  124. package/src/actions/scales/create.js +1 -1
  125. package/src/actions/scales/definitions.js +15 -6
  126. package/src/actions/scales/edit.js +45 -22
  127. package/src/actions/scales/materialize.js +58 -409
  128. package/src/actions/selection/actions.js +38 -24
  129. package/src/actions/selection/index.js +2 -0
  130. package/src/actions/titles/actions.js +63 -2
  131. package/src/actions/titles/index.js +2 -0
  132. package/src/actions/titles/resolve.js +12 -17
  133. package/src/composition.js +10 -0
  134. package/src/core/ChartProgram.js +49 -65
  135. package/src/core/action.js +10 -0
  136. package/src/core/compositionState.js +169 -0
  137. package/src/core/materializationState.js +65 -0
  138. package/src/core/programState.js +28 -0
  139. package/src/core/validation.js +16 -0
  140. package/src/core/vocabulary.js +154 -4
  141. package/src/grammar/arcs.js +154 -0
  142. package/src/grammar/areaSeries.js +1 -1
  143. package/src/grammar/bars/aggregate.js +3 -3
  144. package/src/grammar/bars/geometry.js +3 -3
  145. package/src/grammar/bars/policy.js +7 -1
  146. package/src/grammar/coordinates.js +3 -7
  147. package/src/grammar/density.js +21 -2
  148. package/src/grammar/facets/dependencies.js +185 -0
  149. package/src/grammar/facets/guides.js +257 -0
  150. package/src/grammar/facets/index.js +122 -0
  151. package/src/grammar/facets/scales.js +254 -0
  152. package/src/grammar/interval.js +2 -96
  153. package/src/grammar/lineSeries.js +112 -4
  154. package/src/grammar/palettes.js +5 -7
  155. package/src/grammar/polar.js +102 -0
  156. package/src/grammar/polarGuides.js +243 -0
  157. package/src/grammar/polarLineCommands.js +43 -0
  158. package/src/grammar/polarPaths.js +161 -0
  159. package/src/grammar/positionCompatibility.js +18 -2
  160. package/src/grammar/rects.js +26 -0
  161. package/src/grammar/regression/derive.js +97 -0
  162. package/src/grammar/regression/index.js +22 -0
  163. package/src/grammar/regression/models.js +282 -0
  164. package/src/grammar/regression/parameters.js +106 -0
  165. package/src/grammar/rules.js +1 -1
  166. package/src/grammar/scales/color.js +4 -2
  167. package/src/grammar/scales/continuous.js +6 -1
  168. package/src/grammar/scales/fields.js +7 -0
  169. package/src/grammar/scales/ordinal.js +5 -2
  170. package/src/grammar/scales/policies.js +1 -1
  171. package/src/grammar/schemas/graphic.js +1 -1
  172. package/src/grammar/schemas/graphicBounds.js +64 -9
  173. package/src/grammar/schemas/graphicTree.js +44 -10
  174. package/src/grammar/schemas/semanticPath.js +31 -9
  175. package/src/grammar/seriesLayout.js +13 -2
  176. package/src/grammar/statistics/studentT.js +98 -0
  177. package/src/grammar/text.js +71 -0
  178. package/src/grammar/ticks.js +50 -13
  179. package/src/grammar/transforms.js +1 -1
  180. package/src/index.js +1 -0
  181. package/src/layout/composition.js +310 -0
  182. package/src/layout/facets.js +131 -0
  183. package/src/layout/title.js +6 -0
  184. package/src/materialization/bars/aggregate.js +6 -3
  185. package/src/materialization/bars/grouped.js +75 -37
  186. package/src/materialization/bars/histogram.js +1 -1
  187. package/src/materialization/bars/ranged.js +1 -1
  188. package/src/materialization/composition.js +152 -0
  189. package/src/materialization/compositionSnapshot.js +114 -0
  190. package/src/materialization/density.js +1 -1
  191. package/src/materialization/dependencies.js +47 -73
  192. package/src/materialization/encodings.js +14 -28
  193. package/src/materialization/facetGuides/index.js +2 -0
  194. package/src/materialization/facetGuides/legacyCategorical.js +125 -0
  195. package/src/materialization/facetGuides/placement.js +126 -0
  196. package/src/materialization/facetGuides/preparation.js +188 -0
  197. package/src/materialization/facets.js +214 -0
  198. package/src/materialization/graphicHierarchy.js +15 -5
  199. package/src/materialization/guides/resources.js +46 -0
  200. package/src/materialization/marks/capabilities.js +141 -0
  201. package/src/materialization/marks/index.js +105 -0
  202. package/src/materialization/marks/policies.js +87 -0
  203. package/src/materialization/planner.js +105 -12
  204. package/src/materialization/rect.js +138 -0
  205. package/src/materialization/rectConfig.js +38 -0
  206. package/src/materialization/rowEncoding.js +33 -0
  207. package/src/materialization/scaleGuideDependencies.js +94 -0
  208. package/src/materialization/scales/map.js +40 -0
  209. package/src/materialization/scales/policies/arc.js +37 -0
  210. package/src/materialization/scales/policies/bar.js +132 -0
  211. package/src/materialization/scales/policies/series.js +76 -0
  212. package/src/materialization/scales/resolve.js +326 -0
  213. package/src/materialization/selection/items/arc.js +29 -0
  214. package/src/materialization/selection/items/bar.js +255 -0
  215. package/src/materialization/selection/items/common.js +131 -0
  216. package/src/materialization/selection/items/index.js +6 -0
  217. package/src/materialization/selection/items/path.js +59 -0
  218. package/src/materialization/selection/items/point.js +55 -0
  219. package/src/materialization/selection/items/rect.js +23 -0
  220. package/src/materialization/selection/items/rule.js +25 -0
  221. package/src/materialization/selection/policies/arc.js +10 -0
  222. package/src/materialization/selection/policies/area.js +1 -1
  223. package/src/materialization/selection/policies/bar.js +1 -1
  224. package/src/materialization/selection/policies/index.js +5 -1
  225. package/src/materialization/selection/policies/line.js +1 -1
  226. package/src/materialization/selection/policies/point.js +1 -1
  227. package/src/materialization/selection/policies/rect.js +10 -0
  228. package/src/materialization/selection/policies/rule.js +1 -1
  229. package/src/materialization/selection/styles.js +25 -17
  230. package/src/materialization/text.js +156 -0
  231. package/src/renderers/canvas/index.js +68 -22
  232. package/src/renderers/canvas/text.js +14 -1
  233. package/types/extension.d.ts +5 -1
  234. package/types/index.d.ts +56 -1
  235. package/types/program.d.ts +664 -27
  236. package/src/actions/encodings/color.js +0 -329
  237. package/src/actions/encodings/position.js +0 -176
  238. package/src/actions/marks/line.js +0 -248
  239. package/src/grammar/regression.js +0 -578
  240. package/src/grammar/scales.js +0 -1
  241. package/src/materialization/marks.js +0 -110
  242. package/src/materialization/selection/items.js +0 -503
@@ -0,0 +1,2 @@
1
+ export { prepareSharedFacetLegend } from "./preparation.js";
2
+ export { applyFacetGuideComposition } from "./placement.js";
@@ -0,0 +1,125 @@
1
+ import {
2
+ resolveSharedFacetLegends
3
+ } from "../../grammar/facets/guides.js";
4
+ import { mapOrdinalValues } from "../../grammar/scales/index.js";
5
+ import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from "../../theme/defaults.js";
6
+
7
+ function textItem(text, x, y, options = {}) {
8
+ return {
9
+ type: "text",
10
+ properties: {
11
+ x,
12
+ y,
13
+ text: String(text),
14
+ fill: options.color ?? DEFAULT_COLORS.strongText,
15
+ fontSize: options.fontSize ?? 12,
16
+ fontFamily: options.fontFamily ?? DEFAULT_FONT_FAMILY,
17
+ fontWeight: options.fontWeight ?? "normal",
18
+ textAlign: options.textAlign ?? "left",
19
+ textBaseline: options.textBaseline ?? "middle"
20
+ }
21
+ };
22
+ }
23
+
24
+ function collection(program, id, items) {
25
+ return program
26
+ .createGraphics({ id, type: "collection", parent: "canvas" })
27
+ .editGraphics({ target: id, property: "items", value: items });
28
+ }
29
+
30
+ export function resolveLegacyCategoricalLegend(program) {
31
+ const encodings = program.semanticSpec.layers.flatMap(layer =>
32
+ layer.encoding?.color === undefined
33
+ ? []
34
+ : [{ layer, encoding: layer.encoding.color }]
35
+ );
36
+ if (encodings.length === 0) return undefined;
37
+ const fields = new Set(encodings.map(entry => entry.encoding.field));
38
+ const scales = new Set(encodings.map(entry => entry.encoding.scale));
39
+ const marks = new Set(encodings.map(entry => entry.layer.mark?.type));
40
+ if (fields.size !== 1 || scales.size !== 1 || marks.size !== 1) {
41
+ throw new Error(
42
+ "Shared facet legend requires one compatible color field, scale, and mark recipe."
43
+ );
44
+ }
45
+ if (encodings.some(entry =>
46
+ !["nominal", "ordinal"].includes(entry.encoding.fieldType)
47
+ )) {
48
+ return undefined;
49
+ }
50
+ const firstId = program.compositionSpec.children[0];
51
+ const scaleId = encodings[0].encoding.scale;
52
+ const scale = program.children[firstId].resolvedScales[scaleId];
53
+ if (scale?.type !== "ordinal") return undefined;
54
+ const config = {
55
+ kind: "color",
56
+ target: encodings[0].layer.id,
57
+ field: encodings[0].encoding.field,
58
+ scale: scaleId,
59
+ title: encodings[0].encoding.field
60
+ };
61
+ const compatibility = resolveSharedFacetLegends(
62
+ program.compositionSpec.children.map(id => ({
63
+ id,
64
+ guideConfigs: { legend: { color: config } },
65
+ resolvedScales: program.children[id].resolvedScales
66
+ }))
67
+ );
68
+ return {
69
+ mode: "legacyCategorical",
70
+ compatibility,
71
+ reservation: { gap: 18, width: 132 },
72
+ legend: {
73
+ field: config.field,
74
+ mark: encodings[0].layer.mark.type,
75
+ domain: scale.domain,
76
+ colors: mapOrdinalValues(scale.domain, scale.domain, scale.range)
77
+ }
78
+ };
79
+ }
80
+
81
+ export function materializeLegacyCategoricalLegend(program, prepared, layout) {
82
+ const legend = prepared.legend;
83
+ const items = [textItem(legend.field, layout.legend.x, layout.legend.y, {
84
+ fontSize: 12,
85
+ fontWeight: 700,
86
+ textBaseline: "top"
87
+ })];
88
+ legend.domain.forEach((value, index) => {
89
+ const y = layout.legend.y + 29 + index * 26;
90
+ items.push(legend.mark === "point"
91
+ ? {
92
+ type: "circle",
93
+ properties: {
94
+ x: layout.legend.x + 7,
95
+ y,
96
+ radius: 5.5,
97
+ fill: legend.colors[index],
98
+ stroke: "#ffffff",
99
+ strokeWidth: 0.35,
100
+ opacity: 1
101
+ }
102
+ }
103
+ : {
104
+ type: "rect",
105
+ properties: {
106
+ x: layout.legend.x + 1,
107
+ y: y - 6,
108
+ width: 12,
109
+ height: 12,
110
+ fill: legend.colors[index],
111
+ stroke: "#ffffff",
112
+ strokeWidth: 0.6,
113
+ opacity: 1
114
+ }
115
+ });
116
+ items.push(textItem(value, layout.legend.x + 22, y, {
117
+ fontSize: 10.5
118
+ }));
119
+ });
120
+ return collection(
121
+ program,
122
+ `${program.compositionSpec.id}-legend`,
123
+ items
124
+ );
125
+ }
@@ -0,0 +1,126 @@
1
+ import { isPlainObject } from "../../core/immutable.js";
2
+ import { planFacetGuideOwnership } from "../../grammar/facets/guides.js";
3
+ import {
4
+ axisGraphicIds,
5
+ allLegendGraphicIds
6
+ } from "../guides/resources.js";
7
+ import { attachSnapshotObject } from "../composition.js";
8
+ import { namespaceGraphicId } from "../compositionSnapshot.js";
9
+ import {
10
+ materializeLegacyCategoricalLegend
11
+ } from "./legacyCategorical.js";
12
+ import { legendKinds, prepareSharedFacetLegend } from "./preparation.js";
13
+
14
+ function copyLegendConfig(program, source) {
15
+ let next = program;
16
+ for (const [kind, config] of Object.entries(source.guideConfigs.legend ?? {})) {
17
+ next = next._withLegendConfig(kind, config);
18
+ }
19
+ return next;
20
+ }
21
+
22
+ function removeNamespacedGraphics(program, namespace, ids) {
23
+ let next = program;
24
+ for (const id of ids) {
25
+ const target = namespaceGraphicId(namespace, id);
26
+ if (next.graphicSpec.objects[target] !== undefined) {
27
+ next = next.editGraphics({ target, remove: true });
28
+ }
29
+ }
30
+ return next;
31
+ }
32
+
33
+ function gradientAnchor(prepared, plot, layout) {
34
+ const strips = prepared.source.graphicSpec.objects.colorGradientStrips;
35
+ if (strips?.items?.[0] === undefined) return undefined;
36
+ const properties = strips.items[0].properties;
37
+ const length = prepared.source.guideConfigs.legend.gradient.gradient.length;
38
+ return {
39
+ sourceX: properties.x,
40
+ sourceY: properties.y,
41
+ targetX: layout.legend.x,
42
+ targetY: plot.y + (plot.height - length) / 2
43
+ };
44
+ }
45
+
46
+ function legendTranslation(prepared, plot, layout) {
47
+ const gradient = gradientAnchor(prepared, plot, layout);
48
+ if (gradient !== undefined) {
49
+ return {
50
+ x: gradient.targetX - gradient.sourceX,
51
+ y: gradient.targetY - gradient.sourceY
52
+ };
53
+ }
54
+ const height = prepared.bounds.bottom - prepared.bounds.top;
55
+ return {
56
+ x: layout.legend.x - prepared.bounds.left,
57
+ y: plot.y + Math.max(0, (plot.height - height) / 2) - prepared.bounds.top
58
+ };
59
+ }
60
+
61
+ function attachParentLegend(program, prepared, plot, layout) {
62
+ const translation = legendTranslation(prepared, plot, layout);
63
+ const canvas = prepared.source.graphicSpec.objects.canvas;
64
+ const id = `${program.compositionSpec.id}-shared-legend`;
65
+ let next = copyLegendConfig(program, prepared.source)
66
+ .createGraphics({ id, type: "canvas", parent: "canvas" });
67
+ for (const [property, value] of Object.entries({
68
+ x: translation.x,
69
+ y: translation.y,
70
+ width: canvas.properties.width,
71
+ height: canvas.properties.height,
72
+ background: "transparent"
73
+ })) {
74
+ next = next.editGraphics({ target: id, property, value });
75
+ }
76
+ for (const root of prepared.roots) {
77
+ next = attachSnapshotObject(next, prepared.source.graphicSpec, root, id);
78
+ }
79
+ return next;
80
+ }
81
+
82
+ export function applyFacetGuideComposition(program, { layout, plot } = {}) {
83
+ if (!isPlainObject(layout) || !Array.isArray(layout.children)) {
84
+ throw new TypeError("Facet guide composition requires a resolved layout.");
85
+ }
86
+ if (!isPlainObject(plot)) {
87
+ throw new TypeError("Facet guide composition requires resolved plot bounds.");
88
+ }
89
+ const prepared = prepareSharedFacetLegend(program);
90
+ const children = layout.children.map(cell => {
91
+ const child = program.children[cell.id];
92
+ return {
93
+ id: cell.id,
94
+ axes: Object.keys(child.guideConfigs.axis ?? {})
95
+ .filter(channel => ["x", "y"].includes(channel)),
96
+ legendKinds: legendKinds(child)
97
+ };
98
+ });
99
+ const ownership = planFacetGuideOwnership({
100
+ placements: layout.children,
101
+ children,
102
+ axes: program.compositionSpec.facet.guides.axes,
103
+ legend: program.compositionSpec.facet.guides.legend,
104
+ ...(prepared === undefined ? {} : { sharedLegends: prepared.compatibility })
105
+ });
106
+ let next = program;
107
+ for (const cell of layout.children) {
108
+ const namespace = `${program.compositionSpec.id}-${cell.id}`;
109
+ const childPlan = ownership.children[cell.id];
110
+ for (const channel of childPlan.removeAxes) {
111
+ next = removeNamespacedGraphics(next, namespace, axisGraphicIds(channel));
112
+ }
113
+ if (childPlan.removeLegends.length > 0) {
114
+ next = removeNamespacedGraphics(
115
+ next,
116
+ namespace,
117
+ allLegendGraphicIds(childPlan.removeLegends)
118
+ );
119
+ }
120
+ }
121
+ if (prepared === undefined) return next;
122
+ if (prepared.mode === "legacyCategorical") {
123
+ return materializeLegacyCategoricalLegend(next, prepared, layout);
124
+ }
125
+ return attachParentLegend(next, prepared, plot, layout);
126
+ }
@@ -0,0 +1,188 @@
1
+ import {
2
+ unionConcreteGraphicBounds
3
+ } from "../../grammar/schemas/graphicBounds.js";
4
+ import {
5
+ resolveSharedFacetLegends
6
+ } from "../../grammar/facets/guides.js";
7
+ import { findLayer } from "../../selectors/layers.js";
8
+ import { findSemanticScale } from "../../selectors/scales.js";
9
+ import { allLegendGraphicIds } from "../guides/resources.js";
10
+ import { DEFAULT_COLORS } from "../../theme/defaults.js";
11
+ import { resolveLegacyCategoricalLegend } from "./legacyCategorical.js";
12
+
13
+ const LEGEND_SOURCE_CANVAS = Object.freeze({
14
+ width: 640,
15
+ height: 400,
16
+ margin: Object.freeze({ top: 40, right: 170, bottom: 60, left: 70 })
17
+ });
18
+
19
+ export function legendKinds(program) {
20
+ return Object.keys(program.guideConfigs.legend ?? {});
21
+ }
22
+
23
+ function legendCandidates(program) {
24
+ const layers = program.semanticSpec.layers;
25
+ const color = layers.filter(layer => {
26
+ const encoding = layer.encoding?.color;
27
+ return encoding?.scale !== undefined &&
28
+ findSemanticScale(program, encoding.scale) !== undefined;
29
+ });
30
+ if (color.length === 1) {
31
+ const layer = color[0];
32
+ return {
33
+ target: layer.id,
34
+ channels: ["color", "shape", "strokeDash"].filter(
35
+ channel => layer.encoding?.[channel]?.scale !== undefined
36
+ )
37
+ };
38
+ }
39
+ if (color.length > 1) {
40
+ throw new Error(
41
+ "Shared facet legend requires one unambiguous color legend target."
42
+ );
43
+ }
44
+ for (const channel of ["shape", "strokeDash", "size", "opacity"]) {
45
+ const candidates = layers.filter(
46
+ layer => layer.encoding?.[channel]?.scale !== undefined
47
+ );
48
+ if (candidates.length === 1) {
49
+ return { target: candidates[0].id, channels: [channel] };
50
+ }
51
+ if (candidates.length > 1) {
52
+ throw new Error(
53
+ `Shared facet legend requires one unambiguous ${channel} legend target.`
54
+ );
55
+ }
56
+ }
57
+ throw new Error("Shared facet legend requires one eligible encoded scale.");
58
+ }
59
+
60
+ function compactNumber(value) {
61
+ if (Math.abs(value) >= 1_000_000_000) {
62
+ return `${+(value / 1_000_000_000).toFixed(1)}B`;
63
+ }
64
+ if (Math.abs(value) >= 1_000_000) {
65
+ return `${Math.round(value / 1_000_000)}M`;
66
+ }
67
+ if (Math.abs(value) >= 1_000) {
68
+ return `${Math.round(value / 1_000)}K`;
69
+ }
70
+ return String(+value.toPrecision(3));
71
+ }
72
+
73
+ function continuousValues(domain, count) {
74
+ return Array.from(
75
+ { length: count },
76
+ (_, index) => domain[0] + index / (count - 1) * (domain[1] - domain[0])
77
+ );
78
+ }
79
+
80
+ function prepareAutoLegendSource(child) {
81
+ const request = legendCandidates(child);
82
+ const canvas = child.graphicSpec.objects.canvas;
83
+ let source = child
84
+ ._withCanvasConfig({
85
+ margin: LEGEND_SOURCE_CANVAS.margin,
86
+ size: { width: "explicit", height: "explicit" }
87
+ })
88
+ .editGraphics({
89
+ target: "canvas",
90
+ property: "width",
91
+ value: LEGEND_SOURCE_CANVAS.width
92
+ })
93
+ .editGraphics({
94
+ target: "canvas",
95
+ property: "height",
96
+ value: LEGEND_SOURCE_CANVAS.height
97
+ });
98
+ if (canvas?.properties.background !== undefined) {
99
+ source = source.editGraphics({
100
+ target: "canvas",
101
+ property: "background",
102
+ value: canvas.properties.background
103
+ });
104
+ }
105
+ const scale = findSemanticScale(
106
+ source,
107
+ findLayer(source, request.target)?.encoding?.color?.scale
108
+ );
109
+ const gradient = scale?.type === "sequential";
110
+ source = source.createLegend({
111
+ target: request.target,
112
+ channels: request.channels,
113
+ ...(gradient ? {
114
+ count: 5,
115
+ gradient: { length: 180, thickness: 12 },
116
+ labels: { offset: 9, fontSize: 10 },
117
+ titleStyle: { color: DEFAULT_COLORS.strongText, fontSize: 11 }
118
+ } : {})
119
+ });
120
+ if (gradient) {
121
+ const resolved = source.resolvedScales[scale.id];
122
+ source = source
123
+ .editGraphics({
124
+ target: "colorGradientLabels",
125
+ property: "text",
126
+ value: continuousValues(resolved.domain, 5).map(compactNumber)
127
+ })
128
+ .editGraphics({
129
+ target: "colorGradientTitle",
130
+ property: "y",
131
+ value: source.graphicSpec.objects.colorGradientTitle.properties.y + 8
132
+ });
133
+ }
134
+ return source;
135
+ }
136
+
137
+ function compatibleLegendChildren(program, source, inferred) {
138
+ return program.compositionSpec.children.map(id => {
139
+ const child = program.children[id];
140
+ return {
141
+ id,
142
+ guideConfigs: inferred ? source.guideConfigs : child.guideConfigs,
143
+ resolvedScales: child.resolvedScales
144
+ };
145
+ });
146
+ }
147
+
148
+ export function prepareSharedFacetLegend(program) {
149
+ if (program.compositionSpec.facet.guides.legend !== "shared") {
150
+ return undefined;
151
+ }
152
+ const firstId = program.compositionSpec.children[0];
153
+ const first = program.children[firstId];
154
+ const inferred = legendKinds(first).length === 0;
155
+ if (inferred) {
156
+ const legacy = resolveLegacyCategoricalLegend(program);
157
+ if (legacy !== undefined) return legacy;
158
+ }
159
+ const source = inferred ? prepareAutoLegendSource(first) : first;
160
+ const kinds = legendKinds(source);
161
+ const compatibility = resolveSharedFacetLegends(
162
+ compatibleLegendChildren(program, source, inferred)
163
+ );
164
+ const roots = allLegendGraphicIds(kinds).filter(
165
+ id => source.graphicSpec.objects[id] !== undefined
166
+ );
167
+ if (roots.length === 0) {
168
+ throw new Error("Shared facet legend has no concrete guide graphics.");
169
+ }
170
+ const bounds = unionConcreteGraphicBounds(source.graphicSpec, roots);
171
+ if (bounds === undefined) {
172
+ throw new Error("Shared facet legend has no measurable concrete bounds.");
173
+ }
174
+ const gradient = kinds.includes("gradient");
175
+ return {
176
+ mode: "promoted",
177
+ source,
178
+ inferred,
179
+ kinds,
180
+ roots,
181
+ bounds,
182
+ compatibility,
183
+ reservation: {
184
+ gap: gradient ? 24 : 18,
185
+ width: gradient ? 112 : Math.max(132, Math.ceil(bounds.right - bounds.left))
186
+ }
187
+ };
188
+ }
@@ -0,0 +1,214 @@
1
+ import { resolveGraphicBounds } from "../layout/canvas.js";
2
+ import { resolvePlacedPlotBounds } from "../layout/composition.js";
3
+ import {
4
+ alignedTextAnchor,
5
+ buildTitleReadingBlock
6
+ } from "../layout/title.js";
7
+ import { resolveFacetLayout } from "../layout/facets.js";
8
+ import { namespaceGraphicSnapshot } from "./compositionSnapshot.js";
9
+ import { prepareSharedFacetLegend } from "./facetGuides/index.js";
10
+ import {
11
+ attachSnapshotObject,
12
+ clearCompositionChildren,
13
+ compositionChildDescriptor
14
+ } from "./composition.js";
15
+ import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from "../theme/defaults.js";
16
+
17
+ const ZERO_MARGIN = Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 });
18
+
19
+ function facetConfig(program) {
20
+ const config = program.materializationConfigs.facets?.[program.compositionSpec.id];
21
+ if (config === undefined) {
22
+ throw new Error(`Facet "${program.compositionSpec.id}" requires materialization config.`);
23
+ }
24
+ return config;
25
+ }
26
+
27
+ function titleLayout(program) {
28
+ if (program.semanticSpec.title.text === undefined) {
29
+ return { height: 0 };
30
+ }
31
+ const config = program.titleConfig;
32
+ if (config === undefined) {
33
+ throw new Error("Facet title requires chart title configuration.");
34
+ }
35
+ if (config.position !== "top") {
36
+ throw new Error("Facet parent title currently supports only top position.");
37
+ }
38
+ const block = buildTitleReadingBlock({
39
+ text: program.semanticSpec.title.text,
40
+ subtitle: program.semanticSpec.title.subtitle
41
+ }, config);
42
+ const top = 8 + config.offset;
43
+ if (top < 0) {
44
+ throw new Error("Facet parent title offset places text outside the Canvas.");
45
+ }
46
+ return { config, block, top, height: Math.ceil(top + block.height) };
47
+ }
48
+
49
+ function textItem(text, x, y, options = {}) {
50
+ return {
51
+ type: "text",
52
+ properties: {
53
+ x,
54
+ y,
55
+ text: String(text),
56
+ fill: options.color ?? DEFAULT_COLORS.strongText,
57
+ fontSize: options.fontSize ?? 12,
58
+ fontFamily: options.fontFamily ?? DEFAULT_FONT_FAMILY,
59
+ fontWeight: options.fontWeight ?? "normal",
60
+ textAlign: options.textAlign ?? "left",
61
+ textBaseline: options.textBaseline ?? "middle"
62
+ }
63
+ };
64
+ }
65
+
66
+ function collection(program, id, items) {
67
+ return program
68
+ .createGraphics({ id, type: "collection", parent: "canvas" })
69
+ .editGraphics({ target: id, property: "items", value: items });
70
+ }
71
+
72
+ function materializeHeaders(program, layout, plots, config) {
73
+ const plotById = new Map(plots.map(plot => [plot.id, plot]));
74
+ const items = layout.children.map(cell => {
75
+ const plot = plotById.get(cell.id);
76
+ if (plot === undefined) {
77
+ throw new Error(`Facet header requires plot bounds for "${cell.id}".`);
78
+ }
79
+ return textItem(
80
+ cell.value,
81
+ cell.x + plot.x + plot.width / 2,
82
+ cell.y + config.offset,
83
+ {
84
+ color: config.color,
85
+ fontSize: config.fontSize,
86
+ fontFamily: config.fontFamily,
87
+ fontWeight: config.fontWeight,
88
+ textAlign: "center",
89
+ textBaseline: "top"
90
+ }
91
+ );
92
+ });
93
+ return collection(program, `${program.compositionSpec.id}-headers`, items);
94
+ }
95
+
96
+ function materializeTitleComponent(program, id, lines, centers, style, plot, top) {
97
+ if (lines.length === 0) return program;
98
+ const x = alignedTextAnchor(plot.x, plot.width, program.titleConfig.align);
99
+ const count = lines.length;
100
+ let next = program.createGraphics({
101
+ id,
102
+ type: "text",
103
+ ...(count > 1 ? { length: count } : {}),
104
+ parent: "canvas"
105
+ });
106
+ for (const [property, value] of Object.entries({
107
+ x,
108
+ y: count === 1 ? top + centers[0] : centers.map(center => top + center),
109
+ text: count === 1 ? lines[0] : lines,
110
+ fill: style.color,
111
+ fontSize: style.fontSize,
112
+ fontFamily: style.fontFamily,
113
+ fontWeight: style.fontWeight,
114
+ textAlign: program.titleConfig.align,
115
+ textBaseline: "middle"
116
+ })) {
117
+ next = next.editGraphics({ target: id, property, value });
118
+ }
119
+ return next;
120
+ }
121
+
122
+ function materializeTitle(program, plot, title) {
123
+ let next = materializeTitleComponent(
124
+ program,
125
+ "chartTitle",
126
+ title.block.titleLines,
127
+ title.block.titleCenters,
128
+ title.config.titleStyle,
129
+ plot,
130
+ title.top
131
+ );
132
+ if (title.block.subtitleLines.length > 0) {
133
+ next = materializeTitleComponent(
134
+ next,
135
+ "chartSubtitle",
136
+ title.block.subtitleLines,
137
+ title.block.subtitleCenters,
138
+ title.config.subtitleStyle,
139
+ plot,
140
+ title.top
141
+ );
142
+ }
143
+ return next;
144
+ }
145
+
146
+ export function resolveFacetProgramLayout(program, preparedLegend) {
147
+ program._assertCompositionProgram("resolveFacetProgramLayout");
148
+ if (program.compositionSpec.type !== "facet") {
149
+ throw new Error("resolveFacetProgramLayout requires a facet composition.");
150
+ }
151
+ const title = titleLayout(program);
152
+ const spec = program.compositionSpec;
153
+ const layout = resolveFacetLayout({
154
+ children: spec.children.map((id, index) => ({
155
+ ...compositionChildDescriptor(id, program.children[id]),
156
+ value: spec.facet.values[index]
157
+ })),
158
+ columns: spec.columns,
159
+ gap: spec.gap,
160
+ align: spec.align,
161
+ padding: spec.padding,
162
+ titleHeight: title.height,
163
+ sharedLegend: spec.facet.guides.legend === "shared",
164
+ ...(preparedLegend === undefined ? {} : {
165
+ sharedLegendGap: preparedLegend.reservation.gap,
166
+ sharedLegendWidth: preparedLegend.reservation.width
167
+ })
168
+ });
169
+ const plots = spec.children.map(id => ({
170
+ id,
171
+ ...resolveGraphicBounds(program.children[id])
172
+ }));
173
+ const plot = resolvePlacedPlotBounds({
174
+ placements: layout.children,
175
+ plots
176
+ });
177
+ return { layout, title, plot, plots };
178
+ }
179
+
180
+ export function materializeFacetGraphics(program) {
181
+ const preparedLegend = prepareSharedFacetLegend(program);
182
+ const { layout, title, plot, plots } = resolveFacetProgramLayout(
183
+ program,
184
+ preparedLegend
185
+ );
186
+ const config = facetConfig(program);
187
+ let next = clearCompositionChildren(program);
188
+ if (next.graphicSpec.objects.canvas === undefined) {
189
+ next = next.createGraphics({ id: "canvas", type: "canvas" });
190
+ }
191
+ for (const [property, value] of Object.entries({
192
+ width: layout.width,
193
+ height: layout.height,
194
+ background: "white"
195
+ })) {
196
+ next = next.editGraphics({ target: "canvas", property, value });
197
+ }
198
+ for (const placement of layout.children) {
199
+ const child = program.children[placement.id];
200
+ const snapshot = namespaceGraphicSnapshot(child.graphicSpec, {
201
+ namespace: `${program.compositionSpec.id}-${placement.id}`,
202
+ x: placement.x,
203
+ y: placement.y
204
+ });
205
+ next = attachSnapshotObject(next, snapshot, snapshot.order[0], "canvas");
206
+ }
207
+ next = materializeHeaders(next, layout, plots, config.headers);
208
+ next = next.composeFacetGuides({ layout, plot });
209
+ if (title.height > 0) next = materializeTitle(next, plot, title);
210
+ return next._withCanvasConfig({
211
+ margin: ZERO_MARGIN,
212
+ size: { width: "auto", height: "auto" }
213
+ });
214
+ }
@@ -7,6 +7,16 @@ import { findDataset } from "../selectors/datasets.js";
7
7
  export const CANVAS_GRAPHIC_ID = "canvas";
8
8
  export const PLOT_GRAPHIC_ID = "plot-main";
9
9
 
10
+ export function findCanvasGraphic(program) {
11
+ const graphic = program.graphicSpec.objects[CANVAS_GRAPHIC_ID];
12
+ return graphic?.type === "canvas" ? graphic : undefined;
13
+ }
14
+
15
+ export function findPlotGraphic(program) {
16
+ const graphic = program.graphicSpec.objects[PLOT_GRAPHIC_ID];
17
+ return graphic?.type === "collection" ? graphic : undefined;
18
+ }
19
+
10
20
  export function assertCanvasHierarchyAvailable(program) {
11
21
  const canvasByType = Object.entries(program.graphicSpec.objects).find(
12
22
  ([, graphic]) => graphic.type === "canvas"
@@ -22,8 +32,8 @@ export function assertCanvasHierarchyAvailable(program) {
22
32
  }
23
33
 
24
34
  export function resolvePlotGraphicPlacement(program, relative = {}) {
25
- const canvas = program.graphicSpec.objects[CANVAS_GRAPHIC_ID];
26
- const plot = program.graphicSpec.objects[PLOT_GRAPHIC_ID];
35
+ const canvas = findCanvasGraphic(program);
36
+ const plot = findPlotGraphic(program);
27
37
 
28
38
  if (canvas === undefined && plot === undefined) return relative;
29
39
  if (canvas?.type !== "canvas") {
@@ -74,15 +84,15 @@ export function resolveMarkGraphicPlacement(program, { data, markType }) {
74
84
  }
75
85
 
76
86
  export function resolveCanvasGraphicPlacement(program, relative = {}) {
77
- const canvas = program.graphicSpec.objects[CANVAS_GRAPHIC_ID];
78
- if (canvas?.type !== "canvas") {
87
+ const canvas = findCanvasGraphic(program);
88
+ if (canvas === undefined) {
79
89
  throw new Error("Canvas-owned graphics require the canonical canvas.");
80
90
  }
81
91
  return { parent: CANVAS_GRAPHIC_ID, ...relative };
82
92
  }
83
93
 
84
94
  export function resolveLegendGraphicPlacement(program, relative = {}) {
85
- const title = program.graphicSpec.objects[CANVAS_GRAPHIC_ID]?.children?.find(
95
+ const title = findCanvasGraphic(program)?.children?.find(
86
96
  id => id === "chartTitle" || id === "chartSubtitle"
87
97
  );
88
98
  const hasRelativeAnchor = relative.before !== undefined ||