ggaction 0.0.2 → 0.0.3

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 (199) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +12 -1
  3. package/package.json +6 -1
  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/encodings/appearance.js +11 -0
  16. package/src/actions/encodings/barWidth.js +5 -3
  17. package/src/actions/encodings/color.js +120 -25
  18. package/src/actions/encodings/index.js +2 -0
  19. package/src/actions/encodings/offset.js +90 -72
  20. package/src/actions/encodings/position/apply.js +66 -0
  21. package/src/actions/encodings/position/policies/arc.js +26 -0
  22. package/src/actions/encodings/position/policies/bar.js +9 -5
  23. package/src/actions/encodings/position/policies/index.js +5 -1
  24. package/src/actions/encodings/position/policies/line.js +20 -3
  25. package/src/actions/encodings/position/resolve.js +57 -8
  26. package/src/actions/encodings/position.js +44 -90
  27. package/src/actions/encodings/ranged.js +28 -6
  28. package/src/actions/encodings/shared.js +17 -21
  29. package/src/actions/encodings/text.js +73 -0
  30. package/src/actions/errorBands/create.js +37 -29
  31. package/src/actions/errorBands/edit.js +206 -0
  32. package/src/actions/errorBands/index.js +9 -0
  33. package/src/actions/errorBands/options.js +39 -0
  34. package/src/actions/errorBars/create.js +50 -43
  35. package/src/actions/errorBars/edit.js +130 -0
  36. package/src/actions/errorBars/index.js +3 -0
  37. package/src/actions/errorBars/options.js +52 -0
  38. package/src/actions/facets/actions.js +162 -0
  39. package/src/actions/facets/derive.js +200 -0
  40. package/src/actions/facets/guides.js +16 -0
  41. package/src/actions/facets/index.js +1 -0
  42. package/src/actions/facets/replay.js +69 -0
  43. package/src/actions/guides/applicability.js +113 -0
  44. package/src/actions/guides/axes/axes.js +70 -16
  45. package/src/actions/guides/axes/edit.js +126 -0
  46. package/src/actions/guides/axes/index.js +12 -0
  47. package/src/actions/guides/axes/labels.js +21 -18
  48. package/src/actions/guides/axes/remove.js +60 -0
  49. package/src/actions/guides/axes/tickGroups.js +7 -5
  50. package/src/actions/guides/axes/ticks.js +9 -4
  51. package/src/actions/guides/axes/titles.js +7 -5
  52. package/src/actions/guides/grids/grid.js +144 -15
  53. package/src/actions/guides/grids/resolve.js +5 -13
  54. package/src/actions/guides/guides.js +17 -75
  55. package/src/actions/guides/index.js +6 -0
  56. package/src/actions/guides/legends/categorical/actions.js +48 -9
  57. package/src/actions/guides/legends/categorical/components.js +16 -16
  58. package/src/actions/guides/legends/categorical/index.js +14 -14
  59. package/src/actions/guides/legends/categorical/layout.js +3 -1
  60. package/src/actions/guides/legends/categorical/options.js +2 -5
  61. package/src/actions/guides/legends/categorical/resolve.js +2 -2
  62. package/src/actions/guides/legends/categorical/symbols.js +14 -14
  63. package/src/actions/guides/legends/continuous/common.js +7 -7
  64. package/src/actions/guides/legends/continuous/interval.js +5 -5
  65. package/src/actions/guides/legends/edit.js +2 -6
  66. package/src/actions/guides/legends/focused.js +63 -0
  67. package/src/actions/guides/legends/index.js +4 -0
  68. package/src/actions/guides/legends/remove.js +64 -0
  69. package/src/actions/guides/legends/size.js +12 -14
  70. package/src/actions/guides/polar/axes/facade.js +219 -0
  71. package/src/actions/guides/polar/axes/labels.js +188 -0
  72. package/src/actions/guides/polar/axes/lines.js +113 -0
  73. package/src/actions/guides/polar/axes/shared.js +112 -0
  74. package/src/actions/guides/polar/axes/ticks.js +164 -0
  75. package/src/actions/guides/polar/axes/titles.js +165 -0
  76. package/src/actions/guides/polar/axes.js +55 -0
  77. package/src/actions/guides/polar/grids.js +252 -0
  78. package/src/actions/guides/polar/index.js +2 -0
  79. package/src/actions/guides/polar/resolve.js +269 -0
  80. package/src/actions/index.js +4 -0
  81. package/src/actions/marks/arc/actions.js +239 -0
  82. package/src/actions/marks/arc/index.js +1 -0
  83. package/src/actions/marks/{area.js → area/actions.js} +51 -38
  84. package/src/actions/marks/area/index.js +4 -0
  85. package/src/actions/marks/bar/create.js +26 -5
  86. package/src/actions/marks/bar/materialize.js +25 -31
  87. package/src/actions/marks/index.js +12 -4
  88. package/src/actions/marks/lifecycle.js +27 -0
  89. package/src/actions/marks/line/actions.js +373 -0
  90. package/src/actions/marks/line/index.js +1 -0
  91. package/src/actions/marks/{point.js → point/actions.js} +67 -108
  92. package/src/actions/marks/point/index.js +1 -0
  93. package/src/actions/marks/rect/actions.js +165 -0
  94. package/src/actions/marks/rect/index.js +1 -0
  95. package/src/actions/marks/remove.js +214 -0
  96. package/src/actions/marks/{rule.js → rule/actions.js} +34 -28
  97. package/src/actions/marks/rule/index.js +1 -0
  98. package/src/actions/marks/shared.js +156 -25
  99. package/src/actions/marks/text/actions.js +192 -0
  100. package/src/actions/marks/text/index.js +1 -0
  101. package/src/actions/primitives/createGraphics.js +13 -6
  102. package/src/actions/primitives/editGraphics.js +10 -3
  103. package/src/actions/primitives/semantic.js +26 -3
  104. package/src/actions/primitives/semanticValue.js +25 -3
  105. package/src/actions/regression/components.js +1 -1
  106. package/src/actions/regression/create.js +18 -1
  107. package/src/actions/regression/edit.js +247 -0
  108. package/src/actions/regression/index.js +3 -0
  109. package/src/actions/regression/resolve.js +3 -1
  110. package/src/actions/scales/consumers.js +15 -6
  111. package/src/actions/scales/definitions.js +13 -4
  112. package/src/actions/scales/edit.js +42 -19
  113. package/src/actions/scales/materialize.js +57 -408
  114. package/src/actions/selection/actions.js +38 -24
  115. package/src/actions/selection/index.js +2 -0
  116. package/src/actions/titles/actions.js +63 -2
  117. package/src/actions/titles/index.js +2 -0
  118. package/src/actions/titles/resolve.js +12 -17
  119. package/src/composition.js +10 -0
  120. package/src/core/ChartProgram.js +200 -2
  121. package/src/core/action.js +10 -0
  122. package/src/core/validation.js +16 -0
  123. package/src/core/vocabulary.js +154 -4
  124. package/src/grammar/arcs.js +154 -0
  125. package/src/grammar/bars/aggregate.js +2 -2
  126. package/src/grammar/bars/geometry.js +3 -3
  127. package/src/grammar/bars/policy.js +7 -1
  128. package/src/grammar/coordinates.js +3 -7
  129. package/src/grammar/density.js +20 -1
  130. package/src/grammar/facets/dependencies.js +185 -0
  131. package/src/grammar/facets/guides.js +257 -0
  132. package/src/grammar/facets/scales.js +254 -0
  133. package/src/grammar/facets.js +122 -0
  134. package/src/grammar/lineSeries.js +111 -3
  135. package/src/grammar/polar.js +102 -0
  136. package/src/grammar/polarGuides.js +243 -0
  137. package/src/grammar/polarLineCommands.js +43 -0
  138. package/src/grammar/polarPaths.js +161 -0
  139. package/src/grammar/positionCompatibility.js +18 -2
  140. package/src/grammar/rects.js +26 -0
  141. package/src/grammar/scales/continuous.js +6 -1
  142. package/src/grammar/scales/fields.js +7 -0
  143. package/src/grammar/scales/ordinal.js +5 -2
  144. package/src/grammar/scales/policies.js +1 -1
  145. package/src/grammar/schemas/graphic.js +1 -1
  146. package/src/grammar/schemas/graphicBounds.js +64 -9
  147. package/src/grammar/schemas/graphicTree.js +44 -10
  148. package/src/grammar/schemas/semanticPath.js +31 -9
  149. package/src/grammar/seriesLayout.js +13 -2
  150. package/src/grammar/text.js +71 -0
  151. package/src/grammar/ticks.js +50 -13
  152. package/src/index.js +1 -0
  153. package/src/layout/composition.js +310 -0
  154. package/src/layout/facets.js +131 -0
  155. package/src/layout/title.js +6 -0
  156. package/src/materialization/bars/aggregate.js +5 -2
  157. package/src/materialization/bars/grouped.js +75 -37
  158. package/src/materialization/composition.js +152 -0
  159. package/src/materialization/compositionSnapshot.js +114 -0
  160. package/src/materialization/dependencies.js +71 -16
  161. package/src/materialization/encodings.js +14 -28
  162. package/src/materialization/facetGuides.js +426 -0
  163. package/src/materialization/facets.js +214 -0
  164. package/src/materialization/graphicHierarchy.js +15 -5
  165. package/src/materialization/guides/resources.js +46 -0
  166. package/src/materialization/marks.js +220 -14
  167. package/src/materialization/planner.js +105 -12
  168. package/src/materialization/rect.js +138 -0
  169. package/src/materialization/rectConfig.js +38 -0
  170. package/src/materialization/rowEncoding.js +33 -0
  171. package/src/materialization/scales/map.js +40 -0
  172. package/src/materialization/scales/policies/arc.js +37 -0
  173. package/src/materialization/scales/policies/bar.js +132 -0
  174. package/src/materialization/scales/policies/series.js +76 -0
  175. package/src/materialization/scales/resolve.js +326 -0
  176. package/src/materialization/selection/items/arc.js +29 -0
  177. package/src/materialization/selection/items/bar.js +255 -0
  178. package/src/materialization/selection/items/common.js +131 -0
  179. package/src/materialization/selection/items/index.js +6 -0
  180. package/src/materialization/selection/items/path.js +59 -0
  181. package/src/materialization/selection/items/point.js +55 -0
  182. package/src/materialization/selection/items/rect.js +23 -0
  183. package/src/materialization/selection/items/rule.js +25 -0
  184. package/src/materialization/selection/policies/arc.js +10 -0
  185. package/src/materialization/selection/policies/area.js +1 -1
  186. package/src/materialization/selection/policies/bar.js +1 -1
  187. package/src/materialization/selection/policies/index.js +5 -1
  188. package/src/materialization/selection/policies/line.js +1 -1
  189. package/src/materialization/selection/policies/point.js +1 -1
  190. package/src/materialization/selection/policies/rect.js +10 -0
  191. package/src/materialization/selection/policies/rule.js +1 -1
  192. package/src/materialization/selection/styles.js +24 -16
  193. package/src/materialization/text.js +156 -0
  194. package/src/renderers/canvas/index.js +68 -22
  195. package/types/extension.d.ts +1 -0
  196. package/types/index.d.ts +56 -1
  197. package/types/program.d.ts +664 -27
  198. package/src/actions/marks/line.js +0 -248
  199. package/src/materialization/selection/items.js +0 -503
@@ -0,0 +1,257 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
2
+
3
+ const LEGEND_FAMILIES = Object.freeze({
4
+ series: "categorical",
5
+ color: "categorical",
6
+ gradient: "gradient",
7
+ interval: "discretized",
8
+ size: "size",
9
+ opacity: "opacity"
10
+ });
11
+
12
+ function requirePlacements(placements) {
13
+ if (!Array.isArray(placements) || placements.length === 0) {
14
+ throw new Error("Facet outer axes require occupied cell placements.");
15
+ }
16
+ const ids = new Set();
17
+ const slots = new Set();
18
+ for (const placement of placements) {
19
+ if (!isPlainObject(placement) || typeof placement.id !== "string") {
20
+ throw new TypeError("Facet cell placement requires a string id.");
21
+ }
22
+ if (
23
+ !Number.isInteger(placement.row) || placement.row < 0 ||
24
+ !Number.isInteger(placement.column) || placement.column < 0
25
+ ) {
26
+ throw new RangeError("Facet cell row and column must be non-negative integers.");
27
+ }
28
+ if (ids.has(placement.id)) {
29
+ throw new Error(`Facet cell placement id "${placement.id}" is duplicated.`);
30
+ }
31
+ const slot = `${placement.row}:${placement.column}`;
32
+ if (slots.has(slot)) {
33
+ throw new Error(`Facet cell slot ${slot} is occupied more than once.`);
34
+ }
35
+ ids.add(placement.id);
36
+ slots.add(slot);
37
+ }
38
+ return placements;
39
+ }
40
+
41
+ function validateBounds(bounds, label) {
42
+ if (!isPlainObject(bounds)) {
43
+ throw new TypeError(`${label} must be a plain bounds object.`);
44
+ }
45
+ for (const property of ["x", "y", "width", "height"]) {
46
+ if (!Number.isFinite(bounds[property])) {
47
+ throw new TypeError(`${label}.${property} must be finite.`);
48
+ }
49
+ }
50
+ if (bounds.width < 0 || bounds.height < 0) {
51
+ throw new RangeError(`${label} width and height must be non-negative.`);
52
+ }
53
+ return bounds;
54
+ }
55
+
56
+ function translatedBounds(placement, bounds, channel) {
57
+ if (bounds === undefined) return undefined;
58
+ validateBounds(bounds, `Facet ${channel} axis bounds for "${placement.id}"`);
59
+ if (!Number.isFinite(placement.x) || !Number.isFinite(placement.y)) {
60
+ throw new TypeError(
61
+ `Facet cell "${placement.id}" requires finite x/y for concrete axis placement.`
62
+ );
63
+ }
64
+ return {
65
+ x: placement.x + bounds.x,
66
+ y: placement.y + bounds.y,
67
+ width: bounds.width,
68
+ height: bounds.height
69
+ };
70
+ }
71
+
72
+ export function resolveFacetOuterAxes({ placements, axisBounds = {} } = {}) {
73
+ const cells = requirePlacements(placements);
74
+ if (!isPlainObject(axisBounds)) {
75
+ throw new TypeError("Facet axisBounds must be a plain object.");
76
+ }
77
+ const bottomByColumn = new Map();
78
+ const leftByRow = new Map();
79
+ for (const cell of cells) {
80
+ const bottom = bottomByColumn.get(cell.column);
81
+ if (bottom === undefined || cell.row > bottom.row) {
82
+ bottomByColumn.set(cell.column, cell);
83
+ }
84
+ const left = leftByRow.get(cell.row);
85
+ if (left === undefined || cell.column < left.column) {
86
+ leftByRow.set(cell.row, cell);
87
+ }
88
+ }
89
+ const xOwners = new Set([...bottomByColumn.values()].map(cell => cell.id));
90
+ const yOwners = new Set([...leftByRow.values()].map(cell => cell.id));
91
+ return cloneAndFreeze({
92
+ x: cells.filter(cell => xOwners.has(cell.id)).map(cell => cell.id),
93
+ y: cells.filter(cell => yOwners.has(cell.id)).map(cell => cell.id),
94
+ children: Object.fromEntries(cells.map(cell => {
95
+ const x = xOwners.has(cell.id);
96
+ const y = yOwners.has(cell.id);
97
+ const bounds = axisBounds[cell.id] ?? {};
98
+ return [cell.id, {
99
+ x,
100
+ y,
101
+ bounds: {
102
+ ...(x && bounds.x !== undefined
103
+ ? { x: translatedBounds(cell, bounds.x, "x") }
104
+ : {}),
105
+ ...(y && bounds.y !== undefined
106
+ ? { y: translatedBounds(cell, bounds.y, "y") }
107
+ : {})
108
+ }
109
+ }];
110
+ }))
111
+ });
112
+ }
113
+
114
+ function stableValue(value) {
115
+ if (Array.isArray(value)) return value.map(stableValue);
116
+ if (isPlainObject(value)) {
117
+ return Object.fromEntries(
118
+ Object.keys(value).sort().map(key => [key, stableValue(value[key])])
119
+ );
120
+ }
121
+ return value;
122
+ }
123
+
124
+ function comparableConfig(config) {
125
+ const { target: _target, ...rest } = config;
126
+ return stableValue(rest);
127
+ }
128
+
129
+ function sameValue(left, right) {
130
+ return JSON.stringify(stableValue(left)) === JSON.stringify(stableValue(right));
131
+ }
132
+
133
+ function requireLegendChildren(children) {
134
+ if (!Array.isArray(children) || children.length === 0) {
135
+ throw new Error("Shared facet legend requires at least one child.");
136
+ }
137
+ return children.map(child => {
138
+ if (!isPlainObject(child) || typeof child.id !== "string") {
139
+ throw new TypeError("Shared facet legend child requires a string id.");
140
+ }
141
+ const legends = child.guideConfigs?.legend;
142
+ if (!isPlainObject(legends) || Object.keys(legends).length === 0) {
143
+ throw new Error(`Facet child "${child.id}" has no materialized legend config.`);
144
+ }
145
+ if (!isPlainObject(child.resolvedScales)) {
146
+ throw new Error(`Facet child "${child.id}" has no resolved scales.`);
147
+ }
148
+ return { ...child, legends };
149
+ });
150
+ }
151
+
152
+ function scaleIds(config) {
153
+ const ids = config.scales ?? (config.scale === undefined ? [] : [config.scale]);
154
+ if (!Array.isArray(ids) || ids.length === 0 || !ids.every(id =>
155
+ typeof id === "string" && id.length > 0
156
+ )) {
157
+ throw new Error(`Shared ${config.kind} legend requires resolved scale ids.`);
158
+ }
159
+ return ids;
160
+ }
161
+
162
+ export function resolveSharedFacetLegends(children) {
163
+ const normalized = requireLegendChildren(children);
164
+ const first = normalized[0];
165
+ const kinds = Object.keys(first.legends);
166
+ const entries = kinds.map(kind => {
167
+ const family = LEGEND_FAMILIES[kind];
168
+ if (family === undefined) {
169
+ throw new Error(`Unsupported shared facet legend kind "${kind}".`);
170
+ }
171
+ const config = first.legends[kind];
172
+ if (
173
+ !isPlainObject(config) ||
174
+ (config.kind !== undefined && config.kind !== kind)
175
+ ) {
176
+ throw new Error(`Facet child "${first.id}" has invalid ${kind} legend config.`);
177
+ }
178
+ const scales = scaleIds(config);
179
+ const resolvedScales = Object.fromEntries(scales.map(id => {
180
+ const scale = first.resolvedScales[id];
181
+ if (!isPlainObject(scale)) {
182
+ throw new Error(`Facet child "${first.id}" is missing legend scale "${id}".`);
183
+ }
184
+ return [id, scale];
185
+ }));
186
+ for (const child of normalized.slice(1)) {
187
+ const candidate = child.legends[kind];
188
+ if (
189
+ !isPlainObject(candidate) ||
190
+ !sameValue(comparableConfig(config), comparableConfig(candidate))
191
+ ) {
192
+ throw new Error(
193
+ `Facet child "${child.id}" has an incompatible ${kind} legend config.`
194
+ );
195
+ }
196
+ for (const id of scales) {
197
+ if (!sameValue(resolvedScales[id], child.resolvedScales[id])) {
198
+ throw new Error(
199
+ `Facet child "${child.id}" has an incompatible resolved legend scale "${id}".`
200
+ );
201
+ }
202
+ }
203
+ }
204
+ return { kind, family, scales, config, resolvedScales };
205
+ });
206
+ for (const child of normalized.slice(1)) {
207
+ const childKinds = Object.keys(child.legends);
208
+ if (!sameValue([...kinds].sort(), [...childKinds].sort())) {
209
+ throw new Error(`Facet child "${child.id}" has incompatible legend kinds.`);
210
+ }
211
+ }
212
+ return cloneAndFreeze({ source: first.id, entries });
213
+ }
214
+
215
+ export function planFacetGuideOwnership({
216
+ placements,
217
+ children,
218
+ axes = "outer",
219
+ legend = "shared",
220
+ sharedLegends
221
+ } = {}) {
222
+ const cells = requirePlacements(placements);
223
+ if (!Array.isArray(children) || children.length !== cells.length) {
224
+ throw new Error("Facet guide ownership requires one child descriptor per placement.");
225
+ }
226
+ if (!["each", "outer"].includes(axes)) {
227
+ throw new Error("Facet axes must be each or outer.");
228
+ }
229
+ if (![false, "shared"].includes(legend)) {
230
+ throw new Error("Facet legend must be false or shared.");
231
+ }
232
+ const byId = new Map(children.map(child => [child.id, child]));
233
+ if (byId.size !== cells.length || cells.some(cell => !byId.has(cell.id))) {
234
+ throw new Error("Facet guide child ids must match occupied placements exactly.");
235
+ }
236
+ const outer = resolveFacetOuterAxes({ placements: cells });
237
+ if (legend === "shared" && !isPlainObject(sharedLegends)) {
238
+ throw new Error("Shared facet legend ownership requires a compatibility plan.");
239
+ }
240
+ return cloneAndFreeze({
241
+ children: Object.fromEntries(cells.map(cell => {
242
+ const descriptor = byId.get(cell.id);
243
+ const presentAxes = descriptor.axes ?? [];
244
+ const keepAxes = axes === "each"
245
+ ? presentAxes
246
+ : presentAxes.filter(channel => outer.children[cell.id][channel]);
247
+ return [cell.id, {
248
+ keepAxes,
249
+ removeAxes: presentAxes.filter(channel => !keepAxes.includes(channel)),
250
+ removeLegends: legend === "shared" ? descriptor.legendKinds ?? [] : []
251
+ }];
252
+ })),
253
+ parent: legend === "shared"
254
+ ? { legends: sharedLegends.entries, promoteFrom: sharedLegends.source }
255
+ : { legends: [] }
256
+ });
257
+ }
@@ -0,0 +1,254 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
2
+ import {
3
+ FACET_SCALE_CHANNELS,
4
+ FACET_SCALE_RESOLUTIONS
5
+ } from "../../core/vocabulary.js";
6
+ import { resolveHistogramBins } from "../histogram.js";
7
+ import {
8
+ hasOrdinalDomain,
9
+ isDiscretizedColorScaleType,
10
+ validateCompleteScaleType
11
+ } from "../scales/types.js";
12
+
13
+ export { FACET_SCALE_CHANNELS } from "../../core/vocabulary.js";
14
+
15
+ const RESOLUTION_VALUES = new Set(FACET_SCALE_RESOLUTIONS);
16
+
17
+ function requireScaleDefinitions(semanticSpec) {
18
+ if (!isPlainObject(semanticSpec)) {
19
+ throw new TypeError("Facet scale resolution requires a semantic spec.");
20
+ }
21
+ if (!Array.isArray(semanticSpec.layers) || semanticSpec.layers.length === 0) {
22
+ throw new Error("Facet scale resolution requires at least one layer.");
23
+ }
24
+ if (!Array.isArray(semanticSpec.scales)) {
25
+ throw new Error("Facet scale resolution requires semantic scales.");
26
+ }
27
+ const scales = new Map();
28
+ for (const scale of semanticSpec.scales) {
29
+ if (!isPlainObject(scale) || typeof scale.id !== "string") {
30
+ throw new TypeError("Facet semantic scales require string ids.");
31
+ }
32
+ if (scales.has(scale.id)) {
33
+ throw new Error(`Facet semantic scale id "${scale.id}" is duplicated.`);
34
+ }
35
+ validateCompleteScaleType(scale.type);
36
+ scales.set(scale.id, scale);
37
+ }
38
+ return scales;
39
+ }
40
+
41
+ function normalizeRequestedPolicies(requested) {
42
+ if (!isPlainObject(requested)) {
43
+ throw new TypeError("Facet scales must be a plain object.");
44
+ }
45
+ const unknown = Object.keys(requested).find(
46
+ key => !FACET_SCALE_CHANNELS.includes(key)
47
+ );
48
+ if (unknown !== undefined) {
49
+ throw new Error(`Unknown facet scale channel "${unknown}".`);
50
+ }
51
+ const normalized = {};
52
+ for (const channel of FACET_SCALE_CHANNELS) {
53
+ const policy = requested[channel] ?? "shared";
54
+ if (!RESOLUTION_VALUES.has(policy)) {
55
+ throw new Error(
56
+ `Facet scale channel "${channel}" must be shared or independent.`
57
+ );
58
+ }
59
+ normalized[channel] = policy;
60
+ }
61
+ return normalized;
62
+ }
63
+
64
+ function usedScaleChannels(semanticSpec, scales) {
65
+ const used = new Map();
66
+ for (const layer of semanticSpec.layers) {
67
+ for (const channel of FACET_SCALE_CHANNELS) {
68
+ const id = layer.encoding?.[channel]?.scale;
69
+ if (id === undefined) continue;
70
+ if (!scales.has(id)) {
71
+ throw new Error(
72
+ `Facet layer "${layer.id}" references missing scale "${id}".`
73
+ );
74
+ }
75
+ if (!used.has(id)) used.set(id, new Set());
76
+ used.get(id).add(channel);
77
+ }
78
+ }
79
+ return used;
80
+ }
81
+
82
+ export function normalizeFacetScalePolicies(semanticSpec, requested = {}) {
83
+ const scales = requireScaleDefinitions(semanticSpec);
84
+ const channels = normalizeRequestedPolicies(requested);
85
+ const used = usedScaleChannels(semanticSpec, scales);
86
+ for (const channel of Object.keys(requested)) {
87
+ const applicable = semanticSpec.layers.some(
88
+ layer => layer.encoding?.[channel]?.scale !== undefined
89
+ );
90
+ if (!applicable) {
91
+ throw new Error(
92
+ `Facet scale channel "${channel}" is not used by an affected layer.`
93
+ );
94
+ }
95
+ }
96
+ const scalePolicies = {};
97
+ for (const [id, channelSet] of used) {
98
+ const attached = [...channelSet];
99
+ const policies = [...new Set(attached.map(channel => channels[channel]))];
100
+ if (policies.length !== 1) {
101
+ throw new Error(
102
+ `Facet scale "${id}" has conflicting channel policies for ${attached.join(", ")}.`
103
+ );
104
+ }
105
+ scalePolicies[id] = {
106
+ policy: policies[0],
107
+ channels: attached
108
+ };
109
+ }
110
+ return cloneAndFreeze({ channels, scales: scalePolicies });
111
+ }
112
+
113
+ function requireChildDomains(resolvedByChild, scaleId) {
114
+ if (!isPlainObject(resolvedByChild) || Object.keys(resolvedByChild).length === 0) {
115
+ throw new Error("Facet scale domains require at least one resolved child.");
116
+ }
117
+ return Object.fromEntries(Object.entries(resolvedByChild).map(([childId, scales]) => {
118
+ const domain = scales?.[scaleId]?.domain;
119
+ if (!Array.isArray(domain) || domain.length === 0) {
120
+ throw new Error(
121
+ `Facet child "${childId}" is missing resolved domain for scale "${scaleId}".`
122
+ );
123
+ }
124
+ return [childId, domain];
125
+ }));
126
+ }
127
+
128
+ function continuousUnion(domains, id) {
129
+ if (!domains.every(domain =>
130
+ domain.length === 2 && domain.every(Number.isFinite)
131
+ )) {
132
+ throw new TypeError(`Facet shared scale "${id}" requires finite pair domains.`);
133
+ }
134
+ return [
135
+ Math.min(...domains.map(domain => Math.min(...domain))),
136
+ Math.max(...domains.map(domain => Math.max(...domain)))
137
+ ];
138
+ }
139
+
140
+ function stableUnion(domains) {
141
+ const output = [];
142
+ for (const domain of domains) {
143
+ for (const value of domain) {
144
+ if (!output.some(candidate => Object.is(candidate, value))) output.push(value);
145
+ }
146
+ }
147
+ return output;
148
+ }
149
+
150
+ function sharedAutoDomain(scale, domains, baseResolved) {
151
+ if (hasOrdinalDomain(scale.type)) {
152
+ return Array.isArray(baseResolved?.domain)
153
+ ? baseResolved.domain
154
+ : stableUnion(domains);
155
+ }
156
+ if (scale.type === "quantile") {
157
+ const merged = domains.flat();
158
+ if (!merged.every(Number.isFinite)) {
159
+ throw new TypeError(
160
+ `Facet shared quantile scale "${scale.id}" requires finite sample domains.`
161
+ );
162
+ }
163
+ return merged;
164
+ }
165
+ if (isDiscretizedColorScaleType(scale.type) && scale.type === "threshold") {
166
+ throw new Error(`Facet threshold scale "${scale.id}" requires an explicit domain.`);
167
+ }
168
+ return continuousUnion(domains, scale.id);
169
+ }
170
+
171
+ export function resolveFacetScaleDomains(
172
+ semanticSpec,
173
+ resolvedByChild,
174
+ requested = {},
175
+ baseResolved = undefined
176
+ ) {
177
+ const scales = requireScaleDefinitions(semanticSpec);
178
+ const normalized = normalizeFacetScalePolicies(semanticSpec, requested);
179
+ const resolved = {};
180
+ for (const [id, scalePolicy] of Object.entries(normalized.scales)) {
181
+ const scale = scales.get(id);
182
+ const observed = requireChildDomains(resolvedByChild, id);
183
+ const childIds = Object.keys(observed);
184
+ const explicit = scale.domain !== "auto";
185
+ if (scalePolicy.policy === "independent") {
186
+ resolved[id] = {
187
+ ...scalePolicy,
188
+ childDomains: Object.fromEntries(childIds.map(childId => [
189
+ childId,
190
+ explicit ? scale.domain : observed[childId]
191
+ ]))
192
+ };
193
+ continue;
194
+ }
195
+ const domain = explicit
196
+ ? scale.domain
197
+ : sharedAutoDomain(
198
+ scale,
199
+ Object.values(observed),
200
+ baseResolved?.[id]
201
+ );
202
+ resolved[id] = {
203
+ ...scalePolicy,
204
+ domain,
205
+ childDomains: Object.fromEntries(childIds.map(childId => [childId, domain]))
206
+ };
207
+ }
208
+ return cloneAndFreeze({
209
+ channels: normalized.channels,
210
+ scales: resolved
211
+ });
212
+ }
213
+
214
+ export function resolveFacetHistogramBoundaries({
215
+ policy = "shared",
216
+ valuesByChild,
217
+ bin,
218
+ domain = "auto",
219
+ nice = true,
220
+ zero = false
221
+ } = {}) {
222
+ if (!RESOLUTION_VALUES.has(policy)) {
223
+ throw new Error("Facet histogram policy must be shared or independent.");
224
+ }
225
+ if (!isPlainObject(valuesByChild) || Object.keys(valuesByChild).length === 0) {
226
+ throw new Error("Facet histogram boundaries require child values.");
227
+ }
228
+ const entries = Object.entries(valuesByChild);
229
+ for (const [id, values] of entries) {
230
+ if (!Array.isArray(values) || values.length === 0 || !values.every(Number.isFinite)) {
231
+ throw new TypeError(`Facet histogram child "${id}" requires finite values.`);
232
+ }
233
+ }
234
+ if (policy === "shared") {
235
+ const boundaries = resolveHistogramBins({
236
+ values: entries.flatMap(([, values]) => values),
237
+ bin,
238
+ domain,
239
+ nice,
240
+ zero
241
+ }).boundaries;
242
+ return cloneAndFreeze({
243
+ policy,
244
+ childBoundaries: Object.fromEntries(entries.map(([id]) => [id, boundaries]))
245
+ });
246
+ }
247
+ return cloneAndFreeze({
248
+ policy,
249
+ childBoundaries: Object.fromEntries(entries.map(([id, values]) => [
250
+ id,
251
+ resolveHistogramBins({ values, bin, domain, nice, zero }).boundaries
252
+ ]))
253
+ });
254
+ }
@@ -0,0 +1,122 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import { validateUserId } from "../core/identifiers.js";
3
+ import { BAR_GRAINS, resolveBarGrain } from "./bars/policy.js";
4
+ import { planFacetDependencies } from "./facets/dependencies.js";
5
+ import { readNominalField } from "./scales.js";
6
+
7
+ const SUPPORTED_MARKS = new Set(["point", "line", "area", "bar", "rule"]);
8
+ const SUPPORTED_BAR_GRAINS = new Set([
9
+ BAR_GRAINS.histogram,
10
+ BAR_GRAINS.aggregate,
11
+ BAR_GRAINS.ranged
12
+ ]);
13
+
14
+ function requireFacetField(field) {
15
+ if (typeof field !== "string" || field.length === 0) {
16
+ throw new TypeError("facet requires a non-empty field.");
17
+ }
18
+ return field;
19
+ }
20
+
21
+ function requireSupportedLayer(layer) {
22
+ if (!SUPPORTED_MARKS.has(layer.mark?.type)) {
23
+ throw new Error(
24
+ `facet does not support mark "${layer.id}" of type ${layer.mark?.type ?? "incomplete"}.`
25
+ );
26
+ }
27
+ if (
28
+ layer.mark.type === "bar" &&
29
+ !SUPPORTED_BAR_GRAINS.has(resolveBarGrain(layer))
30
+ ) {
31
+ throw new Error(
32
+ `facet requires bar mark "${layer.id}" to be a complete histogram, aggregate, or ranged bar.`
33
+ );
34
+ }
35
+ if (
36
+ layer.encoding?.x?.scale === undefined ||
37
+ layer.encoding?.y?.scale === undefined
38
+ ) {
39
+ throw new Error(
40
+ `Facet layer "${layer.id}" must be a complete materializable Cartesian mark.`
41
+ );
42
+ }
43
+ if (typeof layer.data !== "string" || layer.data.length === 0) {
44
+ throw new Error(`Facet layer "${layer.id}" requires a dataset.`);
45
+ }
46
+ }
47
+
48
+ function requireSupportedLayers(semanticSpec) {
49
+ if (!Array.isArray(semanticSpec.layers) || semanticSpec.layers.length === 0) {
50
+ throw new Error("facet requires at least one materializable layer.");
51
+ }
52
+ for (const layer of semanticSpec.layers) requireSupportedLayer(layer);
53
+ }
54
+
55
+ function requirePartitionDataset(semanticSpec, id) {
56
+ const dataset = semanticSpec.datasets?.find(candidate => candidate.id === id);
57
+ if (dataset === undefined) {
58
+ throw new Error(`Facet dataset "${id}" does not exist.`);
59
+ }
60
+ if (!Array.isArray(dataset.values)) {
61
+ throw new TypeError(`Facet dataset "${id}" requires array values.`);
62
+ }
63
+ return dataset;
64
+ }
65
+
66
+ function uniqueInOrder(values) {
67
+ return [...new Set(values)];
68
+ }
69
+
70
+ function resolveValues(observed, requested) {
71
+ if (requested === undefined) return observed;
72
+ if (!Array.isArray(requested) || requested.length === 0) {
73
+ throw new TypeError("facet values must be a non-empty array when provided.");
74
+ }
75
+ const normalized = readNominalField(
76
+ requested.map(value => ({ value })),
77
+ "value"
78
+ );
79
+ if (new Set(normalized).size !== normalized.length) {
80
+ throw new Error("facet values must be unique.");
81
+ }
82
+ const missing = normalized.find(value => !observed.includes(value));
83
+ if (missing !== undefined) {
84
+ throw new Error(`facet value ${JSON.stringify(missing)} is not present in the source field.`);
85
+ }
86
+ return normalized;
87
+ }
88
+
89
+ export function resolveFacetDefinition(semanticSpec, options = {}) {
90
+ if (!isPlainObject(semanticSpec)) {
91
+ throw new TypeError("resolveFacetDefinition requires a semantic spec.");
92
+ }
93
+ if (!isPlainObject(options)) {
94
+ throw new TypeError("Facet options must be a plain object.");
95
+ }
96
+ const field = requireFacetField(options.field);
97
+ const id = validateUserId(options.id ?? "facet", "Facet id");
98
+ requireSupportedLayers(semanticSpec);
99
+ const dependencies = planFacetDependencies(semanticSpec, {
100
+ field,
101
+ ...(options.data === undefined ? {} : { data: options.data })
102
+ });
103
+ const data = dependencies.anchor;
104
+ const dataset = requirePartitionDataset(semanticSpec, data);
105
+ const observed = uniqueInOrder(readNominalField(dataset.values, field));
106
+ if (observed.length === 0) {
107
+ throw new Error(`facet field "${field}" has no values.`);
108
+ }
109
+ const values = resolveValues(observed, options.values);
110
+ return cloneAndFreeze({
111
+ id,
112
+ data,
113
+ field,
114
+ values,
115
+ dependencies,
116
+ cells: values.map((value, index) => ({
117
+ id: `${id}-cell-${index + 1}`,
118
+ data: `${id}-cell-${index + 1}-data`,
119
+ value
120
+ }))
121
+ });
122
+ }