ggaction 0.0.3 → 0.0.5

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 (223) hide show
  1. package/CHANGELOG.md +58 -3
  2. package/README.md +117 -83
  3. package/package.json +27 -11
  4. package/src/actions/boxPlots/create.js +13 -7
  5. package/src/actions/boxPlots/edit.js +31 -35
  6. package/src/actions/boxPlots/materialize.js +3 -0
  7. package/src/actions/boxPlots/options.js +8 -1
  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/coordinates/parallel.js +50 -0
  18. package/src/actions/data/bin2d.js +137 -0
  19. package/src/actions/data/density.js +54 -13
  20. package/src/actions/data/derived.js +22 -0
  21. package/src/actions/data/filter.js +5 -0
  22. package/src/actions/data/gradientProfile.js +65 -0
  23. package/src/actions/data/horizon.js +71 -0
  24. package/src/actions/data/index.js +29 -2
  25. package/src/actions/data/regression.js +1 -1
  26. package/src/actions/data/window.js +50 -0
  27. package/src/actions/encodings/appearance.js +1 -1
  28. package/src/actions/encodings/color/continuous.js +137 -0
  29. package/src/actions/encodings/color/index.js +167 -0
  30. package/src/actions/encodings/color/layout.js +99 -0
  31. package/src/actions/encodings/color/policy.js +88 -0
  32. package/src/actions/encodings/density/resolve.js +109 -0
  33. package/src/actions/encodings/density.js +193 -88
  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 -2
  37. package/src/actions/encodings/offset.js +1 -1
  38. package/src/actions/encodings/parallel.js +115 -0
  39. package/src/actions/encodings/pathOrder.js +131 -0
  40. package/src/actions/encodings/position/apply.js +73 -4
  41. package/src/actions/encodings/{position.js → position/index.js} +21 -8
  42. package/src/actions/encodings/position/policies/arc.js +27 -4
  43. package/src/actions/encodings/position/policies/area.js +33 -2
  44. package/src/actions/encodings/position/policies/index.js +7 -2
  45. package/src/actions/encodings/position/policies/line.js +18 -2
  46. package/src/actions/encodings/position/resolve.js +25 -3
  47. package/src/actions/encodings/ranged.js +5 -3
  48. package/src/actions/encodings/ruleAppearance.js +88 -10
  49. package/src/actions/encodings/strokeDash.js +1 -1
  50. package/src/actions/encodings/text.js +1 -1
  51. package/src/actions/errorBands/options.js +1 -1
  52. package/src/actions/errorBars/options.js +1 -1
  53. package/src/actions/facets/actions.js +2 -3
  54. package/src/actions/facets/derive.js +35 -5
  55. package/src/actions/facets/guides.js +1 -1
  56. package/src/actions/facets/replay.js +7 -36
  57. package/src/actions/gradientPlots/components.js +177 -0
  58. package/src/actions/gradientPlots/create.js +96 -0
  59. package/src/actions/gradientPlots/edit.js +153 -0
  60. package/src/actions/gradientPlots/index.js +24 -0
  61. package/src/actions/gradientPlots/materialize.js +287 -0
  62. package/src/actions/gradientPlots/options.js +120 -0
  63. package/src/actions/gradientPlots/paint.js +107 -0
  64. package/src/actions/gradientPlots/rebind.js +51 -0
  65. package/src/actions/gradientPlots/resolve.js +81 -0
  66. package/src/actions/guides/applicability.js +44 -17
  67. package/src/actions/guides/axes/axes.js +37 -3
  68. package/src/actions/guides/axes/index.js +2 -0
  69. package/src/actions/guides/axes/labels.js +12 -7
  70. package/src/actions/guides/axes/parallel/resolve.js +86 -0
  71. package/src/actions/guides/axes/parallel.js +228 -0
  72. package/src/actions/guides/axes/ticks.js +1 -1
  73. package/src/actions/guides/axes/titles.js +12 -9
  74. package/src/actions/guides/grids/resolve.js +1 -1
  75. package/src/actions/guides/guides.js +60 -5
  76. package/src/actions/guides/legends/categorical/actions.js +48 -11
  77. package/src/actions/guides/legends/categorical/layout.js +4 -2
  78. package/src/actions/guides/legends/continuous/interval.js +1 -1
  79. package/src/actions/guides/legends/continuous/opacity.js +1 -1
  80. package/src/actions/guides/legends/edit.js +7 -20
  81. package/src/actions/guides/legends/index.js +2 -0
  82. package/src/actions/guides/legends/remove.js +6 -31
  83. package/src/actions/guides/legends/size.js +1 -1
  84. package/src/actions/guides/legends/strokeWidth.js +170 -0
  85. package/src/actions/guides/legends/target.js +25 -0
  86. package/src/actions/guides/polar/{axes.js → axes/index.js} +5 -5
  87. package/src/actions/guides/polar/index.js +1 -1
  88. package/src/actions/guides/polar/resolve.js +1 -1
  89. package/src/actions/guides/tickValues.js +1 -1
  90. package/src/actions/index.js +6 -0
  91. package/src/actions/intervals/resolve.js +1 -1
  92. package/src/actions/marks/arc/actions.js +2 -2
  93. package/src/actions/marks/area/actions.js +61 -137
  94. package/src/actions/marks/area/materialize.js +163 -0
  95. package/src/actions/marks/bar/edit.js +1 -1
  96. package/src/actions/marks/index.js +5 -1
  97. package/src/actions/marks/line/actions.js +100 -81
  98. package/src/actions/marks/line/materialize.js +146 -0
  99. package/src/actions/marks/point/create.js +76 -0
  100. package/src/actions/marks/point/edit.js +85 -0
  101. package/src/actions/marks/point/index.js +11 -1
  102. package/src/actions/marks/point/jitter.js +72 -0
  103. package/src/actions/marks/point/{actions.js → materialize.js} +175 -170
  104. package/src/actions/marks/rect/actions.js +4 -1
  105. package/src/actions/marks/remove.js +13 -2
  106. package/src/actions/marks/rule/actions.js +26 -4
  107. package/src/actions/marks/shared.js +1 -1
  108. package/src/actions/marks/text/actions.js +20 -5
  109. package/src/actions/marks/text/index.js +7 -1
  110. package/src/actions/marks/text/layout.js +321 -0
  111. package/src/actions/primitives/semanticValidation/dataset.js +22 -0
  112. package/src/actions/primitives/semanticValidation/guide.js +43 -0
  113. package/src/actions/primitives/semanticValidation/index.js +23 -0
  114. package/src/actions/primitives/semanticValidation/layer.js +74 -0
  115. package/src/actions/primitives/semanticValidation/scale.js +74 -0
  116. package/src/actions/primitives/semanticValidation/shared.js +5 -0
  117. package/src/actions/primitives/semanticValue.js +1 -204
  118. package/src/actions/regression/components.js +1 -1
  119. package/src/actions/regression/create.js +1 -1
  120. package/src/actions/regression/edit.js +20 -22
  121. package/src/actions/scales/consumers/common.js +109 -0
  122. package/src/actions/scales/consumers/families.js +58 -0
  123. package/src/actions/scales/consumers/index.js +41 -0
  124. package/src/actions/scales/{consumers.js → consumers/seriesLayout.js} +10 -132
  125. package/src/actions/scales/create.js +1 -1
  126. package/src/actions/scales/definitions.js +32 -2
  127. package/src/actions/scales/edit.js +8 -206
  128. package/src/actions/scales/editPolicy.js +214 -0
  129. package/src/actions/scales/materialize.js +3 -3
  130. package/src/actions/scales/patch.js +26 -0
  131. package/src/actions/selection/actions.js +28 -3
  132. package/src/actions/violinPlots/create.js +198 -0
  133. package/src/actions/violinPlots/index.js +5 -0
  134. package/src/core/ChartProgram.js +22 -236
  135. package/src/core/compositionState.js +169 -0
  136. package/src/core/materializationState.js +65 -0
  137. package/src/core/programState.js +28 -0
  138. package/src/core/textMetrics.js +47 -0
  139. package/src/core/vocabulary.js +7 -2
  140. package/src/grammar/arcs.js +50 -13
  141. package/src/grammar/areaSeries.js +42 -6
  142. package/src/grammar/bars/aggregate.js +1 -1
  143. package/src/grammar/bin2d.js +333 -0
  144. package/src/grammar/categoricalDensity.js +192 -0
  145. package/src/grammar/coordinates.js +1 -1
  146. package/src/grammar/curveCommands.js +3 -1
  147. package/src/grammar/density.js +253 -27
  148. package/src/grammar/facets/dependencies.js +3 -11
  149. package/src/grammar/{facets.js → facets/index.js} +8 -6
  150. package/src/grammar/gradientProfile.js +213 -0
  151. package/src/grammar/horizon.js +455 -0
  152. package/src/grammar/interval.js +2 -96
  153. package/src/grammar/jitter.js +197 -0
  154. package/src/grammar/lineSeries.js +70 -1
  155. package/src/grammar/paint.js +88 -0
  156. package/src/grammar/palettes.js +5 -7
  157. package/src/grammar/parallelCoordinates.js +213 -0
  158. package/src/grammar/pathOrder.js +35 -0
  159. package/src/grammar/pointShapes.js +24 -0
  160. package/src/grammar/polarLineCommands.js +1 -1
  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 +2 -1
  166. package/src/grammar/scales/appearance.js +21 -0
  167. package/src/grammar/scales/color.js +4 -2
  168. package/src/grammar/schemas/concreteGraphic.js +11 -1
  169. package/src/grammar/schemas/graphicBounds.js +19 -37
  170. package/src/grammar/schemas/semanticPath.js +17 -2
  171. package/src/grammar/statistics/studentT.js +98 -0
  172. package/src/grammar/transforms.js +101 -12
  173. package/src/grammar/window.js +339 -0
  174. package/src/layout/labels.js +270 -0
  175. package/src/layout/text.js +4 -1
  176. package/src/layout/title.js +10 -20
  177. package/src/materialization/bars/aggregate.js +1 -1
  178. package/src/materialization/bars/grouped.js +1 -1
  179. package/src/materialization/bars/histogram.js +1 -1
  180. package/src/materialization/bars/ranged.js +1 -1
  181. package/src/materialization/dataProvenance.js +56 -2
  182. package/src/materialization/density.js +1 -1
  183. package/src/materialization/dependencies.js +15 -101
  184. package/src/materialization/encodings.js +2 -2
  185. package/src/materialization/facetGuides/index.js +2 -0
  186. package/src/materialization/facetGuides/legacyCategorical.js +125 -0
  187. package/src/materialization/facetGuides/placement.js +126 -0
  188. package/src/materialization/facetGuides/preparation.js +188 -0
  189. package/src/materialization/facets.js +1 -1
  190. package/src/materialization/guides/resources.js +74 -24
  191. package/src/materialization/horizon.js +29 -0
  192. package/src/materialization/layout.js +15 -0
  193. package/src/materialization/marks/capabilities.js +161 -0
  194. package/src/materialization/marks/index.js +108 -0
  195. package/src/materialization/marks/pathOrder.js +71 -0
  196. package/src/materialization/marks/policies.js +91 -0
  197. package/src/materialization/rect.js +1 -1
  198. package/src/materialization/rowEncoding.js +1 -1
  199. package/src/materialization/scaleGuideDependencies.js +102 -0
  200. package/src/materialization/scales/map.js +1 -1
  201. package/src/materialization/scales/policies/series.js +1 -1
  202. package/src/materialization/scales/resolve.js +3 -1
  203. package/src/materialization/selection/items/bar.js +1 -1
  204. package/src/materialization/selection/items/path.js +27 -1
  205. package/src/materialization/selection/items/point.js +1 -5
  206. package/src/materialization/selection/items/rect.js +21 -0
  207. package/src/materialization/selection/styles.js +21 -10
  208. package/src/materialization/text.js +12 -4
  209. package/src/renderers/canvas/fill.js +33 -0
  210. package/src/renderers/canvas/index.js +7 -0
  211. package/src/renderers/canvas/path.js +7 -4
  212. package/src/renderers/canvas/rect.js +9 -3
  213. package/src/renderers/canvas/text.js +14 -1
  214. package/src/selectors/datasets.js +4 -0
  215. package/src/theme/defaults.js +4 -0
  216. package/types/extension.d.ts +10 -1
  217. package/types/index.d.ts +76 -0
  218. package/types/program.d.ts +632 -10
  219. package/src/actions/encodings/color.js +0 -424
  220. package/src/grammar/regression.js +0 -578
  221. package/src/grammar/scales.js +0 -1
  222. package/src/materialization/facetGuides.js +0 -426
  223. package/src/materialization/marks.js +0 -316
@@ -0,0 +1,192 @@
1
+ import { cloneAndFreeze } from "../core/immutable.js";
2
+ import {
3
+ mapContinuousScaleValues,
4
+ mapOrdinalPositionValues,
5
+ readNominalField,
6
+ readQuantitativeField
7
+ } from "./scales/index.js";
8
+ import { buildAreaCurvePathCommands } from "./curveCommands.js";
9
+ import { buildLinearPathCommands } from "./pathCommands.js";
10
+
11
+ function sameKey(left, right) {
12
+ const leftEntries = Object.entries(left);
13
+ const rightEntries = Object.entries(right);
14
+ return leftEntries.length === rightEntries.length && leftEntries.every(
15
+ ([field, value]) => Object.hasOwn(right, field) && Object.is(right[field], value)
16
+ );
17
+ }
18
+
19
+ function seriesSide(placement, splitValue) {
20
+ if (placement.split === undefined) return placement.side;
21
+ const domain = placement.split.domain ?? placement.resolvedSplitDomain;
22
+ const index = domain.findIndex(value => Object.is(value, splitValue));
23
+ if (index < 0) {
24
+ throw new Error(`Density split value "${splitValue}" is outside its domain.`);
25
+ }
26
+ if (placement.channel === "x") return index === 0 ? "left" : "right";
27
+ return index === 0 ? "top" : "bottom";
28
+ }
29
+
30
+ export function deriveCategoricalDensitySeries(rows, layer, transform) {
31
+ const placement = transform?.placement;
32
+ if (layer?.mark?.type !== "area" || placement?.type !== "category") {
33
+ throw new Error("Categorical density series require a category-placed area.");
34
+ }
35
+ const categoryEncoding = layer.encoding?.[placement.channel];
36
+ const valueChannel = placement.channel === "x" ? "y" : "x";
37
+ const valueEncoding = layer.encoding?.[valueChannel];
38
+ if (
39
+ categoryEncoding?.field !== placement.categoryField ||
40
+ !["nominal", "ordinal"].includes(categoryEncoding.fieldType) ||
41
+ valueEncoding?.field !== transform.as?.[0] ||
42
+ valueEncoding.fieldType !== "quantitative"
43
+ ) {
44
+ throw new Error(
45
+ `Categorical density area mark "${layer.id}" requires its category and value encodings.`
46
+ );
47
+ }
48
+ const categories = readNominalField(rows, placement.categoryField);
49
+ const values = readQuantitativeField(rows, transform.as[0]);
50
+ const densities = readQuantitativeField(rows, transform.as[1]);
51
+ const splits = placement.split === undefined
52
+ ? rows.map(() => undefined)
53
+ : readNominalField(rows, placement.split.field);
54
+ const series = [];
55
+ for (let index = 0; index < rows.length; index += 1) {
56
+ const key = {
57
+ ...(transform.groupBy === undefined
58
+ ? {}
59
+ : { [transform.groupBy]: categories[index] }),
60
+ ...(placement.split === undefined
61
+ ? {}
62
+ : { [placement.split.field]: splits[index] })
63
+ };
64
+ let current = series.find(item => sameKey(item.key, key));
65
+ if (current === undefined) {
66
+ current = {
67
+ category: categories[index],
68
+ split: splits[index],
69
+ key,
70
+ values: []
71
+ };
72
+ series.push(current);
73
+ }
74
+ current.values.push({
75
+ value: values[index],
76
+ density: densities[index]
77
+ });
78
+ }
79
+ if (series.length === 0) {
80
+ throw new Error(`Categorical density area mark "${layer.id}" has no values.`);
81
+ }
82
+ const resolvedSplitDomain = placement.split === undefined
83
+ ? undefined
84
+ : transform.resolved?.splitDomain ?? placement.split.domain;
85
+ if (placement.split !== undefined && resolvedSplitDomain === undefined) {
86
+ throw new Error("Categorical density split requires a resolved two-value domain.");
87
+ }
88
+ return cloneAndFreeze({
89
+ mode: placement.channel === "x" ? "category-x" : "category-y",
90
+ placement: {
91
+ ...placement,
92
+ ...(resolvedSplitDomain === undefined ? {} : { resolvedSplitDomain })
93
+ },
94
+ series: series.map(item => ({
95
+ ...item,
96
+ side: seriesSide({
97
+ ...placement,
98
+ ...(resolvedSplitDomain === undefined ? {} : { resolvedSplitDomain })
99
+ }, item.split),
100
+ values: item.values.sort((left, right) => left.value - right.value)
101
+ }))
102
+ });
103
+ }
104
+
105
+ function maximumFor(derived, series) {
106
+ const candidates = derived.placement.width.resolve === "shared"
107
+ ? derived.series
108
+ : derived.series.filter(candidate => Object.is(
109
+ candidate.category,
110
+ series.category
111
+ ));
112
+ const maximum = Math.max(...candidates.flatMap(candidate =>
113
+ candidate.values.map(value => value.density)
114
+ ));
115
+ if (!Number.isFinite(maximum) || maximum <= 0) {
116
+ throw new Error("Categorical density width requires a positive maximum.");
117
+ }
118
+ return maximum;
119
+ }
120
+
121
+ function pathBoundaries(series, center, halfWidth, maximum, valueScale, channel) {
122
+ const valuePositions = mapContinuousScaleValues(
123
+ series.values.map(value => value.value),
124
+ valueScale
125
+ );
126
+ const widths = series.values.map(value => halfWidth * value.density / maximum);
127
+ const horizontal = channel === "x";
128
+ const direction = {
129
+ left: -1,
130
+ right: 1,
131
+ top: -1,
132
+ bottom: 1
133
+ }[series.side];
134
+ const point = (valuePosition, width) => horizontal
135
+ ? { x: center + width, y: valuePosition }
136
+ : { x: valuePosition, y: center + width };
137
+ if (series.side === "both") {
138
+ return {
139
+ lower: valuePositions.map((value, index) => point(value, -widths[index])),
140
+ upper: valuePositions.map((value, index) => point(value, widths[index]))
141
+ };
142
+ }
143
+ const baseline = valuePositions.map(value => point(value, 0));
144
+ const outer = valuePositions.map((value, index) =>
145
+ point(value, direction * widths[index])
146
+ );
147
+ return {
148
+ lower: baseline,
149
+ upper: outer,
150
+ outline: [baseline[0], ...outer, baseline.at(-1)]
151
+ };
152
+ }
153
+
154
+ export function buildCategoricalDensityPaths(
155
+ derived,
156
+ { categoryScale, valueScale, curve = "linear" } = {}
157
+ ) {
158
+ if (!Number.isFinite(categoryScale?.bandwidth) || categoryScale.bandwidth <= 0) {
159
+ throw new Error("Categorical density placement requires a positive band scale.");
160
+ }
161
+ const halfWidth = categoryScale.bandwidth * derived.placement.width.band / 2;
162
+ const centers = mapOrdinalPositionValues(
163
+ derived.series.map(series => series.category),
164
+ categoryScale
165
+ );
166
+ const commands = derived.series.map((series, index) => {
167
+ const boundaries = pathBoundaries(
168
+ series,
169
+ centers[index],
170
+ halfWidth,
171
+ maximumFor(derived, series),
172
+ valueScale,
173
+ derived.placement.channel
174
+ );
175
+ if (curve !== "linear") {
176
+ return buildAreaCurvePathCommands(
177
+ boundaries.lower,
178
+ boundaries.upper,
179
+ curve,
180
+ derived.placement.channel === "x" ? { independentAxis: "y" } : {}
181
+ );
182
+ }
183
+ return buildLinearPathCommands(
184
+ boundaries.outline ?? [
185
+ ...boundaries.lower,
186
+ ...[...boundaries.upper].reverse()
187
+ ],
188
+ { close: true }
189
+ );
190
+ });
191
+ return cloneAndFreeze(commands);
192
+ }
@@ -1,6 +1,6 @@
1
1
  import { getPositionChannelDefinition } from "../core/vocabulary.js";
2
2
 
3
- const COORDINATE_TYPES = new Set(["cartesian", "polar"]);
3
+ const COORDINATE_TYPES = new Set(["cartesian", "polar", "parallel"]);
4
4
 
5
5
  export function validateCoordinateType(value) {
6
6
  if (!COORDINATE_TYPES.has(value)) {
@@ -267,7 +267,9 @@ export function buildCurvePathCommands(points, curve = "linear") {
267
267
  const validatedPoints = validatePoints(points);
268
268
  const validatedCurve = validateCurveInterpolation(curve);
269
269
  if (validatedCurve === "linear" ||
270
- (SMOOTH_CURVES.has(validatedCurve) && points.length < 3)) {
270
+ (SMOOTH_CURVES.has(validatedCurve) &&
271
+ validatedCurve !== "monotone" &&
272
+ points.length < 3)) {
271
273
  return buildLinearPathCommands(validatedPoints);
272
274
  }
273
275
  if (validatedCurve.startsWith("step")) {
@@ -1,5 +1,5 @@
1
- import { cloneAndFreeze } from "../core/immutable.js";
2
- import { isNominalValue } from "./scales.js";
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import { isNominalValue } from "./scales/index.js";
3
3
 
4
4
  const SQRT_TWO_PI = Math.sqrt(2 * Math.PI);
5
5
 
@@ -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
  );
@@ -1,10 +1,12 @@
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";
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 "./dependencies.js";
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,