ggaction 0.0.4 → 0.0.6

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 (169) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +35 -13
  3. package/package.json +18 -7
  4. package/src/actions/boxPlots/create.js +13 -7
  5. package/src/actions/boxPlots/edit.js +484 -36
  6. package/src/actions/boxPlots/materialize.js +3 -0
  7. package/src/actions/boxPlots/options.js +11 -4
  8. package/src/actions/boxPlots/resolve.js +10 -2
  9. package/src/actions/charts/bar.js +50 -0
  10. package/src/actions/charts/heatmap.js +205 -0
  11. package/src/actions/charts/histogram.js +58 -0
  12. package/src/actions/charts/index.js +15 -0
  13. package/src/actions/charts/line.js +69 -0
  14. package/src/actions/charts/parallel.js +76 -0
  15. package/src/actions/charts/scatter.js +56 -0
  16. package/src/actions/charts/shared.js +121 -0
  17. package/src/actions/composition/actions.js +13 -3
  18. package/src/actions/coordinates/parallel.js +50 -0
  19. package/src/actions/data/bin2d.js +260 -0
  20. package/src/actions/data/density.js +54 -13
  21. package/src/actions/data/derived.js +22 -0
  22. package/src/actions/data/filter.js +5 -0
  23. package/src/actions/data/gradientProfile.js +65 -0
  24. package/src/actions/data/horizon.js +71 -0
  25. package/src/actions/data/index.js +34 -2
  26. package/src/actions/data/intervalEdit.js +76 -0
  27. package/src/actions/data/window.js +50 -0
  28. package/src/actions/distributions/revision.js +326 -0
  29. package/src/actions/encodings/appearance.js +35 -1
  30. package/src/actions/encodings/color/index.js +17 -1
  31. package/src/actions/encodings/color/layout.js +8 -0
  32. package/src/actions/encodings/density/resolve.js +109 -0
  33. package/src/actions/encodings/density.js +330 -90
  34. package/src/actions/encodings/horizon/resolve.js +283 -0
  35. package/src/actions/encodings/horizon.js +364 -0
  36. package/src/actions/encodings/index.js +8 -0
  37. package/src/actions/encodings/parallel.js +115 -0
  38. package/src/actions/encodings/pathOrder.js +131 -0
  39. package/src/actions/encodings/position/apply.js +73 -4
  40. package/src/actions/encodings/position/index.js +15 -2
  41. package/src/actions/encodings/position/policies/arc.js +27 -4
  42. package/src/actions/encodings/position/policies/area.js +33 -2
  43. package/src/actions/encodings/position/policies/index.js +7 -2
  44. package/src/actions/encodings/position/policies/line.js +18 -2
  45. package/src/actions/encodings/position/resolve.js +24 -2
  46. package/src/actions/encodings/ranged.js +3 -1
  47. package/src/actions/encodings/remove.js +268 -0
  48. package/src/actions/encodings/ruleAppearance.js +88 -10
  49. package/src/actions/errorBands/edit.js +121 -13
  50. package/src/actions/errorBars/edit.js +45 -8
  51. package/src/actions/errorBars/options.js +7 -1
  52. package/src/actions/facets/actions.js +137 -3
  53. package/src/actions/facets/derive.js +33 -3
  54. package/src/actions/facets/replay.js +7 -36
  55. package/src/actions/gradientPlots/components.js +177 -0
  56. package/src/actions/gradientPlots/create.js +96 -0
  57. package/src/actions/gradientPlots/edit.js +501 -0
  58. package/src/actions/gradientPlots/index.js +24 -0
  59. package/src/actions/gradientPlots/materialize.js +287 -0
  60. package/src/actions/gradientPlots/options.js +120 -0
  61. package/src/actions/gradientPlots/paint.js +107 -0
  62. package/src/actions/gradientPlots/rebind.js +51 -0
  63. package/src/actions/gradientPlots/resolve.js +81 -0
  64. package/src/actions/guides/applicability.js +44 -17
  65. package/src/actions/guides/axes/axes.js +37 -3
  66. package/src/actions/guides/axes/edit.js +129 -47
  67. package/src/actions/guides/axes/index.js +2 -0
  68. package/src/actions/guides/axes/labels.js +11 -6
  69. package/src/actions/guides/axes/parallel/resolve.js +86 -0
  70. package/src/actions/guides/axes/parallel.js +228 -0
  71. package/src/actions/guides/axes/titles.js +11 -8
  72. package/src/actions/guides/guides.js +60 -5
  73. package/src/actions/guides/legends/categorical/actions.js +67 -19
  74. package/src/actions/guides/legends/continuous/common.js +3 -3
  75. package/src/actions/guides/legends/edit.js +63 -0
  76. package/src/actions/guides/legends/index.js +2 -0
  77. package/src/actions/guides/legends/remove.js +124 -30
  78. package/src/actions/guides/legends/strokeWidth.js +198 -0
  79. package/src/actions/index.js +6 -0
  80. package/src/actions/marks/arc/actions.js +46 -14
  81. package/src/actions/marks/area/actions.js +60 -136
  82. package/src/actions/marks/area/materialize.js +163 -0
  83. package/src/actions/marks/index.js +5 -1
  84. package/src/actions/marks/line/actions.js +99 -80
  85. package/src/actions/marks/line/materialize.js +146 -0
  86. package/src/actions/marks/point/create.js +76 -0
  87. package/src/actions/marks/point/edit.js +97 -0
  88. package/src/actions/marks/point/index.js +11 -1
  89. package/src/actions/marks/point/jitter.js +72 -0
  90. package/src/actions/marks/point/{actions.js → materialize.js} +183 -172
  91. package/src/actions/marks/rect/actions.js +3 -0
  92. package/src/actions/marks/remove.js +13 -2
  93. package/src/actions/marks/rule/actions.js +29 -4
  94. package/src/actions/marks/text/actions.js +19 -4
  95. package/src/actions/marks/text/index.js +7 -1
  96. package/src/actions/marks/text/layout.js +321 -0
  97. package/src/actions/primitives/semanticValidation/dataset.js +22 -0
  98. package/src/actions/primitives/semanticValidation/guide.js +43 -0
  99. package/src/actions/primitives/semanticValidation/index.js +23 -0
  100. package/src/actions/primitives/semanticValidation/layer.js +74 -0
  101. package/src/actions/primitives/semanticValidation/scale.js +74 -0
  102. package/src/actions/primitives/semanticValidation/shared.js +5 -0
  103. package/src/actions/primitives/semanticValue.js +1 -204
  104. package/src/actions/regression/edit.js +247 -88
  105. package/src/actions/scales/consumers/common.js +18 -0
  106. package/src/actions/scales/consumers/families.js +14 -3
  107. package/src/actions/scales/definitions.js +30 -0
  108. package/src/actions/scales/edit.js +6 -204
  109. package/src/actions/scales/editPolicy.js +214 -0
  110. package/src/actions/scales/materialize.js +1 -1
  111. package/src/actions/scales/patch.js +26 -0
  112. package/src/actions/selection/actions.js +150 -5
  113. package/src/actions/selection/index.js +6 -0
  114. package/src/actions/violinPlots/create.js +198 -0
  115. package/src/actions/violinPlots/index.js +5 -0
  116. package/src/core/textMetrics.js +47 -0
  117. package/src/core/vocabulary.js +14 -2
  118. package/src/grammar/arcs.js +49 -12
  119. package/src/grammar/areaSeries.js +41 -5
  120. package/src/grammar/bin2d.js +333 -0
  121. package/src/grammar/categoricalDensity.js +192 -0
  122. package/src/grammar/coordinates.js +1 -1
  123. package/src/grammar/curveCommands.js +3 -1
  124. package/src/grammar/density.js +252 -26
  125. package/src/grammar/facets/dependencies.js +3 -11
  126. package/src/grammar/facets/index.js +3 -1
  127. package/src/grammar/gradientProfile.js +213 -0
  128. package/src/grammar/horizon.js +455 -0
  129. package/src/grammar/jitter.js +197 -0
  130. package/src/grammar/lineSeries.js +69 -0
  131. package/src/grammar/paint.js +88 -0
  132. package/src/grammar/parallelCoordinates.js +213 -0
  133. package/src/grammar/pathOrder.js +35 -0
  134. package/src/grammar/pointShapes.js +24 -0
  135. package/src/grammar/rules.js +1 -0
  136. package/src/grammar/scales/appearance.js +21 -0
  137. package/src/grammar/schemas/concreteGraphic.js +11 -1
  138. package/src/grammar/schemas/graphicBounds.js +19 -37
  139. package/src/grammar/schemas/semanticPath.js +17 -2
  140. package/src/grammar/transforms.js +100 -11
  141. package/src/grammar/window.js +339 -0
  142. package/src/layout/labels.js +270 -0
  143. package/src/layout/text.js +4 -1
  144. package/src/layout/title.js +10 -20
  145. package/src/materialization/dataProvenance.js +56 -2
  146. package/src/materialization/dependencies.js +9 -14
  147. package/src/materialization/guides/resources.js +74 -24
  148. package/src/materialization/horizon.js +29 -0
  149. package/src/materialization/layout.js +15 -0
  150. package/src/materialization/marks/capabilities.js +21 -1
  151. package/src/materialization/marks/index.js +3 -0
  152. package/src/materialization/marks/pathOrder.js +71 -0
  153. package/src/materialization/marks/policies.js +6 -2
  154. package/src/materialization/scaleGuideDependencies.js +8 -0
  155. package/src/materialization/scales/resolve.js +2 -0
  156. package/src/materialization/selection/items/path.js +27 -1
  157. package/src/materialization/selection/items/point.js +1 -5
  158. package/src/materialization/selection/items/rect.js +21 -0
  159. package/src/materialization/selection/styles.js +20 -9
  160. package/src/materialization/text.js +12 -4
  161. package/src/renderers/canvas/fill.js +33 -0
  162. package/src/renderers/canvas/index.js +7 -0
  163. package/src/renderers/canvas/path.js +7 -4
  164. package/src/renderers/canvas/rect.js +9 -3
  165. package/src/selectors/datasets.js +4 -0
  166. package/src/theme/defaults.js +4 -0
  167. package/types/extension.d.ts +6 -0
  168. package/types/index.d.ts +77 -0
  169. package/types/program.d.ts +703 -17
@@ -1,4 +1,4 @@
1
- import { cloneAndFreeze } from "../core/immutable.js";
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
2
  import { isNominalValue } from "./scales/index.js";
3
3
 
4
4
  const SQRT_TWO_PI = Math.sqrt(2 * Math.PI);
@@ -10,6 +10,12 @@ export const DENSITY_KERNELS = Object.freeze([
10
10
  "triangular"
11
11
  ]);
12
12
  export const DENSITY_NORMALIZATIONS = Object.freeze(["unit", "count"]);
13
+ export const DENSITY_PLACEMENT_SIDES = Object.freeze([
14
+ "both", "left", "right", "top", "bottom"
15
+ ]);
16
+ export const DENSITY_WIDTH_RESOLUTIONS = Object.freeze([
17
+ "shared", "independent"
18
+ ]);
13
19
 
14
20
  export function validateDensityKernel(value) {
15
21
  if (!DENSITY_KERNELS.includes(value)) {
@@ -32,10 +38,169 @@ function requireField(value, label) {
32
38
  return value;
33
39
  }
34
40
 
41
+ function validateSplitDomain(value) {
42
+ if (
43
+ !Array.isArray(value) ||
44
+ value.length !== 2 ||
45
+ !value.every(isNominalValue) ||
46
+ Object.is(value[0], value[1])
47
+ ) {
48
+ throw new TypeError(
49
+ "Density split domain must contain two distinct nominal values."
50
+ );
51
+ }
52
+ return [...value];
53
+ }
54
+
55
+ function validatePlacementWidth(value = {}) {
56
+ if (!isPlainObject(value)) {
57
+ throw new TypeError("Density placement width must be a plain object.");
58
+ }
59
+ const unknown = Object.keys(value).find(key => !["band", "resolve"].includes(key));
60
+ if (unknown !== undefined) {
61
+ throw new Error(`Unknown density placement width property "${unknown}".`);
62
+ }
63
+ const band = value.band ?? 0.8;
64
+ if (!Number.isFinite(band) || band <= 0 || band > 1) {
65
+ throw new RangeError("Density placement width band must be in (0, 1].");
66
+ }
67
+ const resolve = value.resolve ?? "shared";
68
+ if (!DENSITY_WIDTH_RESOLUTIONS.includes(resolve)) {
69
+ throw new Error(`Unsupported density width resolve "${resolve}".`);
70
+ }
71
+ return { band, resolve };
72
+ }
73
+
74
+ export function normalizeDensityPlacement(value, {
75
+ densityChannel = "x",
76
+ groupBy,
77
+ categoryField
78
+ } = {}) {
79
+ if (!isPlainObject(value)) {
80
+ throw new TypeError("Density placement must be a plain object.");
81
+ }
82
+ const unknown = Object.keys(value).find(
83
+ key => !["type", "side", "width", "split", "scale"].includes(key)
84
+ );
85
+ if (unknown !== undefined) {
86
+ throw new Error(`Unknown density placement property "${unknown}".`);
87
+ }
88
+ if (value.type === "baseline") return undefined;
89
+ if (value.type !== "category") {
90
+ throw new Error(`Unsupported density placement type "${value.type}".`);
91
+ }
92
+ if (!["x", "y"].includes(densityChannel)) {
93
+ throw new Error(`Unsupported densityChannel "${densityChannel}".`);
94
+ }
95
+ const resolvedCategoryField = requireField(
96
+ categoryField ?? groupBy,
97
+ "Density placement category field"
98
+ );
99
+ const width = validatePlacementWidth(value.width);
100
+ let split;
101
+ if (value.split !== undefined) {
102
+ if (!isPlainObject(value.split)) {
103
+ throw new TypeError("Density placement split must be a plain object.");
104
+ }
105
+ const splitUnknown = Object.keys(value.split).find(
106
+ key => !["field", "domain"].includes(key)
107
+ );
108
+ if (splitUnknown !== undefined) {
109
+ throw new Error(`Unknown density split property "${splitUnknown}".`);
110
+ }
111
+ const field = requireField(value.split.field, "Density split field");
112
+ if (field === groupBy) {
113
+ throw new Error("Density split field must differ from groupBy.");
114
+ }
115
+ split = {
116
+ field,
117
+ ...(value.split.domain === undefined
118
+ ? {}
119
+ : { domain: validateSplitDomain(value.split.domain) })
120
+ };
121
+ }
122
+ if (split !== undefined && Object.hasOwn(value, "side")) {
123
+ throw new Error("Density split placement cannot also specify side.");
124
+ }
125
+ const side = split === undefined ? value.side ?? "both" : undefined;
126
+ if (side !== undefined && !DENSITY_PLACEMENT_SIDES.includes(side)) {
127
+ throw new Error(`Unsupported density placement side "${side}".`);
128
+ }
129
+ const horizontalWidth = densityChannel === "x";
130
+ if (
131
+ side !== undefined &&
132
+ !(
133
+ horizontalWidth
134
+ ? ["both", "left", "right"].includes(side)
135
+ : ["both", "top", "bottom"].includes(side)
136
+ )
137
+ ) {
138
+ throw new Error(
139
+ `Density ${densityChannel} placement does not support side "${side}".`
140
+ );
141
+ }
142
+ return cloneAndFreeze({
143
+ type: "category",
144
+ channel: densityChannel,
145
+ categoryField: resolvedCategoryField,
146
+ ...(side === undefined ? {} : { side }),
147
+ width,
148
+ ...(split === undefined ? {} : { split })
149
+ });
150
+ }
151
+
152
+ function validateStoredDensityPlacement(value, groupBy) {
153
+ if (!isPlainObject(value)) {
154
+ throw new TypeError("Density placement provenance must be a plain object.");
155
+ }
156
+ const unknown = Object.keys(value).find(
157
+ key => !["type", "channel", "categoryField", "side", "width", "split"].includes(key)
158
+ );
159
+ if (unknown !== undefined) {
160
+ throw new Error(`Unknown density placement provenance property "${unknown}".`);
161
+ }
162
+ const normalized = normalizeDensityPlacement({
163
+ type: value.type,
164
+ ...(value.side === undefined ? {} : { side: value.side }),
165
+ width: value.width,
166
+ ...(value.split === undefined ? {} : { split: value.split })
167
+ }, {
168
+ densityChannel: value.channel,
169
+ groupBy,
170
+ categoryField: value.categoryField
171
+ });
172
+ const sameSplit = normalized.split === undefined
173
+ ? value.split === undefined
174
+ : value.split !== undefined &&
175
+ normalized.split.field === value.split.field &&
176
+ (
177
+ normalized.split.domain === undefined
178
+ ? value.split.domain === undefined
179
+ : value.split.domain !== undefined &&
180
+ normalized.split.domain.length === value.split.domain.length &&
181
+ normalized.split.domain.every((item, index) =>
182
+ Object.is(item, value.split.domain[index])
183
+ )
184
+ );
185
+ if (
186
+ normalized.type !== value.type ||
187
+ normalized.channel !== value.channel ||
188
+ normalized.categoryField !== value.categoryField ||
189
+ normalized.side !== value.side ||
190
+ value.width === undefined ||
191
+ normalized.width.band !== value.width.band ||
192
+ normalized.width.resolve !== value.width.resolve ||
193
+ !sameSplit
194
+ ) {
195
+ throw new Error("Density placement provenance must be fully normalized.");
196
+ }
197
+ return value;
198
+ }
199
+
35
200
  export function validateDensityTransform(transform) {
36
201
  const supported = [
37
202
  "type", "field", "groupBy", "bandwidth", "extent", "steps", "as",
38
- "resolve", "kernel", "normalization", "resolved"
203
+ "resolve", "kernel", "normalization", "placement", "resolved"
39
204
  ];
40
205
  const unknown = Object.keys(transform).find(key => !supported.includes(key));
41
206
  if (unknown !== undefined) {
@@ -48,6 +213,9 @@ export function validateDensityTransform(transform) {
48
213
  if (transform.groupBy !== undefined) {
49
214
  requireField(transform.groupBy, "Density groupBy");
50
215
  }
216
+ if (transform.placement !== undefined) {
217
+ validateStoredDensityPlacement(transform.placement, transform.groupBy);
218
+ }
51
219
  validateDensityKernel(transform.kernel ?? "gaussian");
52
220
  validateDensityNormalization(transform.normalization ?? "unit");
53
221
  if (
@@ -82,7 +250,9 @@ export function validateDensityTransform(transform) {
82
250
  }
83
251
  const collisions = new Set([
84
252
  transform.field,
85
- transform.groupBy
253
+ transform.groupBy,
254
+ transform.placement?.categoryField,
255
+ transform.placement?.split?.field
86
256
  ].filter(Boolean));
87
257
  if (transform.as.some(value => collisions.has(value))) {
88
258
  throw new Error(
@@ -98,16 +268,26 @@ export function validateDensityTransform(transform) {
98
268
  resolved === null ||
99
269
  typeof resolved !== "object" ||
100
270
  Array.isArray(resolved) ||
101
- Object.keys(resolved).some(key => !["bandwidth", "extent"].includes(key)) ||
271
+ Object.keys(resolved).some(
272
+ key => !["bandwidth", "extent", "splitDomain"].includes(key)
273
+ ) ||
102
274
  !Number.isFinite(resolved.bandwidth) ||
103
275
  resolved.bandwidth <= 0 ||
104
276
  !Array.isArray(resolved.extent) ||
105
277
  resolved.extent.length !== 2 ||
106
278
  !resolved.extent.every(Number.isFinite) ||
107
- resolved.extent[0] >= resolved.extent[1]
279
+ resolved.extent[0] >= resolved.extent[1] ||
280
+ (resolved.splitDomain !== undefined && (() => {
281
+ try {
282
+ validateSplitDomain(resolved.splitDomain);
283
+ return transform.placement?.split === undefined;
284
+ } catch {
285
+ return true;
286
+ }
287
+ })())
108
288
  ) {
109
289
  throw new TypeError(
110
- "Density resolved provenance requires a positive bandwidth and ascending finite extent."
290
+ "Density resolved provenance requires a positive bandwidth, ascending finite extent, and optional two-value split domain."
111
291
  );
112
292
  }
113
293
  }
@@ -187,7 +367,7 @@ function resolveExtent(value, sourceValues) {
187
367
  return [...value];
188
368
  }
189
369
 
190
- function resolveOutputFields(as, field, groupBy) {
370
+ function resolveOutputFields(as, field, groupBy, placement) {
191
371
  const resolved = as ?? [`${field}_value`, `${field}_density`];
192
372
  if (
193
373
  !Array.isArray(resolved) ||
@@ -197,7 +377,12 @@ function resolveOutputFields(as, field, groupBy) {
197
377
  ) {
198
378
  throw new TypeError("Density as must contain two distinct non-empty fields.");
199
379
  }
200
- const collisions = new Set([field, groupBy].filter(Boolean));
380
+ const collisions = new Set([
381
+ field,
382
+ groupBy,
383
+ placement?.categoryField,
384
+ placement?.split?.field
385
+ ].filter(Boolean));
201
386
  if (resolved.some(value => collisions.has(value))) {
202
387
  throw new Error("Density output fields must not collide with source or group fields.");
203
388
  }
@@ -240,7 +425,8 @@ export function deriveKernelDensity(values, {
240
425
  steps = 100,
241
426
  kernel = "gaussian",
242
427
  normalization = "unit",
243
- as
428
+ as,
429
+ placement
244
430
  } = {}) {
245
431
  if (!Array.isArray(values)) {
246
432
  throw new TypeError("Density values must be an array.");
@@ -252,14 +438,18 @@ export function deriveKernelDensity(values, {
252
438
  if (!Number.isInteger(steps) || steps < 2) {
253
439
  throw new RangeError("Density steps must be an integer of at least 2.");
254
440
  }
255
- const outputFields = resolveOutputFields(as, sourceField, groupField);
441
+ if (placement !== undefined) {
442
+ validateStoredDensityPlacement(placement, groupField);
443
+ }
444
+ const outputFields = resolveOutputFields(as, sourceField, groupField, placement);
256
445
  const resolvedKernel = validateDensityKernel(kernel);
257
446
  const resolvedNormalization = validateDensityNormalization(normalization);
258
447
  const validRows = values.filter(row =>
259
448
  row !== null &&
260
449
  typeof row === "object" &&
261
450
  Number.isFinite(row[sourceField]) &&
262
- (groupField === undefined || isNominalValue(row[groupField]))
451
+ (groupField === undefined || isNominalValue(row[groupField])) &&
452
+ (placement?.split === undefined || isNominalValue(row[placement.split.field]))
263
453
  );
264
454
  if (validRows.length === 0) {
265
455
  throw new Error("Density requires at least one valid field/group row.");
@@ -270,30 +460,65 @@ export function deriveKernelDensity(values, {
270
460
  const groups = groupField === undefined
271
461
  ? [undefined]
272
462
  : [...new Set(validRows.map(row => row[groupField]))];
463
+ const observedSplits = placement?.split === undefined
464
+ ? []
465
+ : [...new Set(validRows.map(row => row[placement.split.field]))];
466
+ let splitDomain;
467
+ if (placement?.split !== undefined) {
468
+ splitDomain = placement.split.domain === undefined
469
+ ? observedSplits
470
+ : validateSplitDomain(placement.split.domain);
471
+ if (splitDomain.length !== 2) {
472
+ throw new Error(
473
+ "Density split inference requires exactly two observed values."
474
+ );
475
+ }
476
+ if (observedSplits.some(value => !splitDomain.some(item => Object.is(item, value)))) {
477
+ throw new Error("Density split domain must include every observed split value.");
478
+ }
479
+ }
273
480
  const sampleStep = (resolvedExtent[1] - resolvedExtent[0]) / (steps - 1);
274
481
  const samples = Array.from(
275
482
  { length: steps },
276
483
  (_, index) => index === steps - 1
277
484
  ? resolvedExtent[1]
278
- : resolvedExtent[0] + sampleStep * index
485
+ : placement === undefined
486
+ ? resolvedExtent[0] + sampleStep * index
487
+ : resolvedExtent[0] +
488
+ (resolvedExtent[1] - resolvedExtent[0]) * index / (steps - 1)
279
489
  );
280
490
  const rows = [];
281
491
  for (const group of groups) {
282
- const groupValues = validRows
283
- .filter(row => groupField === undefined || row[groupField] === group)
284
- .map(row => row[sourceField]);
285
- for (const sample of samples) {
286
- rows.push({
287
- ...(groupField === undefined ? {} : { [groupField]: group }),
288
- [outputFields[0]]: sample,
289
- [outputFields[1]]: estimateAt(
290
- sample,
291
- groupValues,
292
- resolvedBandwidth,
293
- resolvedKernel,
294
- resolvedNormalization
492
+ const splits = splitDomain ?? [undefined];
493
+ for (const split of splits) {
494
+ const groupValues = validRows
495
+ .filter(row =>
496
+ (groupField === undefined || Object.is(row[groupField], group)) &&
497
+ (placement?.split === undefined ||
498
+ Object.is(row[placement.split.field], split))
295
499
  )
296
- });
500
+ .map(row => row[sourceField]);
501
+ if (groupValues.length === 0) continue;
502
+ for (const sample of samples) {
503
+ rows.push({
504
+ ...(groupField === undefined
505
+ ? placement === undefined
506
+ ? {}
507
+ : { [placement.categoryField]: "density" }
508
+ : { [groupField]: group }),
509
+ ...(placement?.split === undefined
510
+ ? {}
511
+ : { [placement.split.field]: split }),
512
+ [outputFields[0]]: sample,
513
+ [outputFields[1]]: estimateAt(
514
+ sample,
515
+ groupValues,
516
+ resolvedBandwidth,
517
+ resolvedKernel,
518
+ resolvedNormalization
519
+ )
520
+ });
521
+ }
297
522
  }
298
523
  }
299
524
  return cloneAndFreeze({
@@ -304,6 +529,7 @@ export function deriveKernelDensity(values, {
304
529
  density: outputFields[1]
305
530
  },
306
531
  groups,
532
+ ...(splitDomain === undefined ? {} : { splitDomain }),
307
533
  bandwidth: resolvedBandwidth,
308
534
  kernel: resolvedKernel,
309
535
  normalization: resolvedNormalization,
@@ -1,14 +1,6 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
2
2
  import { validateUserId } from "../../core/identifiers.js";
3
-
4
- const ROW_PRESERVING_TRANSFORMS = new Set(["filter"]);
5
- const STATISTICAL_TRANSFORMS = new Set([
6
- "regression",
7
- "density",
8
- "interval",
9
- "boxSummary",
10
- "boxOutlier"
11
- ]);
3
+ import { findTransformPolicy } from "../transforms.js";
12
4
 
13
5
  function requireCollection(value, label) {
14
6
  if (!Array.isArray(value) || value.length === 0) {
@@ -54,8 +46,8 @@ function classifyDataset(dataset) {
54
46
  throw new Error(`Transformed dataset "${dataset.id}" requires a source dataset.`);
55
47
  }
56
48
  const type = transforms[0].type;
57
- if (ROW_PRESERVING_TRANSFORMS.has(type)) return "rowPreserving";
58
- if (STATISTICAL_TRANSFORMS.has(type)) return "statistical";
49
+ const topology = findTransformPolicy(type)?.facetTopology;
50
+ if (topology !== undefined) return topology;
59
51
  throw new Error(
60
52
  `Facet replay does not support dataset transform "${type ?? "unknown"}" on "${dataset.id}".`
61
53
  );
@@ -4,7 +4,9 @@ import { BAR_GRAINS, resolveBarGrain } from "../bars/policy.js";
4
4
  import { planFacetDependencies } from "./dependencies.js";
5
5
  import { readNominalField } from "../scales/index.js";
6
6
 
7
- const SUPPORTED_MARKS = new Set(["point", "line", "area", "bar", "rule"]);
7
+ const SUPPORTED_MARKS = new Set([
8
+ "point", "line", "area", "bar", "rule", "rect"
9
+ ]);
8
10
  const SUPPORTED_BAR_GRAINS = new Set([
9
11
  BAR_GRAINS.histogram,
10
12
  BAR_GRAINS.aggregate,
@@ -0,0 +1,213 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import {
3
+ deriveKernelDensity,
4
+ validateDensityKernel,
5
+ validateDensityNormalization
6
+ } from "./density.js";
7
+
8
+ export const GRADIENT_PROFILE_FIELDS = Object.freeze({
9
+ category: "__gradientPlot_category",
10
+ values: "__gradientPlot_values",
11
+ intensities: "__gradientPlot_intensities",
12
+ lower: "__gradientPlot_lower",
13
+ upper: "__gradientPlot_upper",
14
+ center: "__gradientPlot_center",
15
+ count: "__gradientPlot_count"
16
+ });
17
+
18
+ const TRANSFORM_KEYS = Object.freeze([
19
+ "type", "category", "field", "bandwidth", "extent", "steps", "kernel",
20
+ "normalization", "center", "as", "resolve", "resolved"
21
+ ]);
22
+ const OUTPUT_KEYS = Object.freeze(Object.keys(GRADIENT_PROFILE_FIELDS));
23
+
24
+ function requireField(value, label) {
25
+ if (typeof value !== "string" || value.length === 0) {
26
+ throw new TypeError(`${label} must be a non-empty string.`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function validatePair(value, label) {
32
+ if (
33
+ !Array.isArray(value) || value.length !== 2 ||
34
+ !value.every(Number.isFinite) || value[0] >= value[1]
35
+ ) {
36
+ throw new RangeError(`${label} must be an ascending pair of finite numbers.`);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function validateOutputs(as, { category, field }) {
42
+ if (!isPlainObject(as)) {
43
+ throw new TypeError("Gradient profile as must be a plain object.");
44
+ }
45
+ const keys = Object.keys(as);
46
+ if (
47
+ keys.length !== OUTPUT_KEYS.length ||
48
+ !OUTPUT_KEYS.every(key => Object.hasOwn(as, key))
49
+ ) {
50
+ throw new Error(`Gradient profile as requires exactly ${OUTPUT_KEYS.join(", ")}.`);
51
+ }
52
+ const values = OUTPUT_KEYS.map(key =>
53
+ requireField(as[key], `Gradient profile as.${key}`)
54
+ );
55
+ if (new Set(values).size !== values.length) {
56
+ throw new Error("Gradient profile output fields must be distinct.");
57
+ }
58
+ const inputCollisions = values.filter(value => value === category || value === field);
59
+ if (
60
+ inputCollisions.some(value => value !== category) ||
61
+ as.category !== category ||
62
+ values.slice(1).includes(category)
63
+ ) {
64
+ throw new Error("Gradient profile output fields must not collide with input fields.");
65
+ }
66
+ }
67
+
68
+ export function validateGradientProfileTransform(value) {
69
+ if (!isPlainObject(value)) {
70
+ throw new TypeError("Gradient profile transform must be a plain object.");
71
+ }
72
+ const unknown = Object.keys(value).find(key => !TRANSFORM_KEYS.includes(key));
73
+ if (unknown !== undefined) {
74
+ throw new Error(`Unknown gradient profile transform property "${unknown}".`);
75
+ }
76
+ if (value.type !== "gradientProfile") {
77
+ throw new Error(`Unsupported gradient profile transform "${value.type}".`);
78
+ }
79
+ const category = requireField(value.category, "Gradient profile category");
80
+ const field = requireField(value.field, "Gradient profile field");
81
+ if (category === field) {
82
+ throw new Error("Gradient profile category and field must be distinct.");
83
+ }
84
+ if (
85
+ value.bandwidth !== "auto" &&
86
+ (!Number.isFinite(value.bandwidth) || value.bandwidth <= 0)
87
+ ) {
88
+ throw new RangeError(
89
+ "Gradient profile bandwidth must be a positive finite number or auto."
90
+ );
91
+ }
92
+ if (value.extent !== "auto") {
93
+ validatePair(value.extent, "Gradient profile extent");
94
+ }
95
+ if (!Number.isInteger(value.steps) || value.steps < 2) {
96
+ throw new RangeError("Gradient profile steps must be an integer of at least 2.");
97
+ }
98
+ validateDensityKernel(value.kernel);
99
+ validateDensityNormalization(value.normalization);
100
+ if (!["mean", "median"].includes(value.center)) {
101
+ throw new Error(`Unsupported gradient profile center "${value.center}".`);
102
+ }
103
+ if (value.resolve !== "shared") {
104
+ throw new Error(`Unsupported gradient profile resolve "${value.resolve}".`);
105
+ }
106
+ validateOutputs(value.as, { category, field });
107
+ if (value.resolved !== undefined) {
108
+ const resolved = value.resolved;
109
+ if (
110
+ !isPlainObject(resolved) ||
111
+ Object.keys(resolved).some(key => ![
112
+ "bandwidth", "extent", "intensityDomain"
113
+ ].includes(key)) ||
114
+ !Number.isFinite(resolved.bandwidth) || resolved.bandwidth <= 0
115
+ ) {
116
+ throw new TypeError("Gradient profile resolved provenance is invalid.");
117
+ }
118
+ validatePair(resolved.extent, "Gradient profile resolved extent");
119
+ validatePair(
120
+ resolved.intensityDomain,
121
+ "Gradient profile resolved intensityDomain"
122
+ );
123
+ }
124
+ return value;
125
+ }
126
+
127
+ export function normalizeGradientProfileTransform(options = {}) {
128
+ const transform = {
129
+ type: "gradientProfile",
130
+ category: options.category,
131
+ field: options.field,
132
+ bandwidth: options.bandwidth ?? "auto",
133
+ extent: options.extent ?? "auto",
134
+ steps: options.steps ?? 64,
135
+ kernel: options.kernel ?? "gaussian",
136
+ normalization: options.normalization ?? "unit",
137
+ center: options.center ?? "median",
138
+ as: options.as ?? { ...GRADIENT_PROFILE_FIELDS, category: options.category },
139
+ resolve: "shared"
140
+ };
141
+ validateGradientProfileTransform(transform);
142
+ return cloneAndFreeze(transform);
143
+ }
144
+
145
+ export function requestedGradientProfileTransform(transform) {
146
+ const { resolved: _resolved, ...requested } = transform;
147
+ return cloneAndFreeze(requested);
148
+ }
149
+
150
+ function centerOf(values, type) {
151
+ if (type === "mean") {
152
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
153
+ }
154
+ const sorted = [...values].sort((left, right) => left - right);
155
+ const middle = (sorted.length - 1) / 2;
156
+ const lower = Math.floor(middle);
157
+ const upper = Math.ceil(middle);
158
+ return (sorted[lower] + sorted[upper]) / 2;
159
+ }
160
+
161
+ export function deriveGradientProfiles(rows, transform) {
162
+ if (!Array.isArray(rows)) {
163
+ throw new TypeError("Gradient profile rows must be an array.");
164
+ }
165
+ validateGradientProfileTransform(transform);
166
+ const density = deriveKernelDensity(rows, {
167
+ field: transform.field,
168
+ groupBy: transform.category,
169
+ bandwidth: transform.bandwidth,
170
+ extent: transform.extent,
171
+ steps: transform.steps,
172
+ kernel: transform.kernel,
173
+ normalization: transform.normalization,
174
+ as: ["__gradientProfile_value", "__gradientProfile_intensity"]
175
+ });
176
+ const validRows = rows.filter(row =>
177
+ row !== null && typeof row === "object" &&
178
+ row[transform.category] !== undefined && row[transform.category] !== null &&
179
+ row[transform.category] !== "" && Number.isFinite(row[transform.field])
180
+ );
181
+ const maximumIntensity = Math.max(
182
+ ...density.values.map(row => row.__gradientProfile_intensity)
183
+ );
184
+ if (!Number.isFinite(maximumIntensity) || maximumIntensity <= 0) {
185
+ throw new Error("Gradient profile requires a positive resolved density range.");
186
+ }
187
+ const values = density.groups.map(category => {
188
+ const samples = density.values.filter(
189
+ row => row[transform.category] === category
190
+ );
191
+ const observations = validRows
192
+ .filter(row => row[transform.category] === category)
193
+ .map(row => row[transform.field]);
194
+ return {
195
+ [transform.as.category]: category,
196
+ [transform.as.values]: samples.map(row => row.__gradientProfile_value),
197
+ [transform.as.intensities]: samples.map(
198
+ row => row.__gradientProfile_intensity
199
+ ),
200
+ [transform.as.lower]: density.extent[0],
201
+ [transform.as.upper]: density.extent[1],
202
+ [transform.as.center]: centerOf(observations, transform.center),
203
+ [transform.as.count]: observations.length
204
+ };
205
+ });
206
+ return cloneAndFreeze({
207
+ values,
208
+ categories: density.groups,
209
+ bandwidth: density.bandwidth,
210
+ extent: density.extent,
211
+ intensityDomain: [0, maximumIntensity]
212
+ });
213
+ }