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
@@ -8,12 +8,14 @@ import {
8
8
  layoutSeriesPartition,
9
9
  validateColorLayout
10
10
  } from "./seriesLayout.js";
11
+ import { stableOrderPathValues } from "./pathOrder.js";
12
+ import { deriveCategoricalDensitySeries } from "./categoricalDensity.js";
11
13
 
12
14
  export function deriveAreaSeries(rows, layer) {
13
15
  if (layer?.mark?.type !== "area") {
14
16
  throw new Error("Area series derivation requires a semantic area mark.");
15
17
  }
16
- const { x, y, x2, y2, group } = layer.encoding ?? {};
18
+ const { x, y, x2, y2, group, color } = layer.encoding ?? {};
17
19
  const vertical =
18
20
  ["quantitative", "temporal"].includes(x?.fieldType) &&
19
21
  y?.fieldType === "quantitative" &&
@@ -46,24 +48,44 @@ export function deriveAreaSeries(rows, layer) {
46
48
  const groupValues = group === undefined
47
49
  ? rows.map(() => undefined)
48
50
  : readNominalField(rows, group.field);
51
+ const colorValues = color === undefined
52
+ ? rows.map(() => undefined)
53
+ : readNominalField(rows, color.field);
54
+ const pathOrder = layer.encoding?.pathOrder;
55
+ const orderValues = pathOrder === undefined
56
+ ? undefined
57
+ : readQuantitativeField(rows, pathOrder.field);
49
58
  const groups = new Map();
50
59
 
51
60
  for (let index = 0; index < rows.length; index += 1) {
52
61
  const key = groupValues[index];
53
62
  const series = groups.get(key) ?? {
54
- key: group === undefined ? {} : { [group.field]: key },
63
+ key: {
64
+ ...(group === undefined ? {} : { [group.field]: key }),
65
+ ...(color === undefined ? {} : { [color.field]: colorValues[index] })
66
+ },
55
67
  values: []
56
68
  };
69
+ if (
70
+ color !== undefined &&
71
+ !Object.is(series.key[color.field], colorValues[index])
72
+ ) {
73
+ throw new Error(
74
+ `Area series "${String(key)}" must have one color value.`
75
+ );
76
+ }
57
77
  series.values.push(vertical
58
78
  ? {
59
79
  x: independentValues[index],
60
80
  y: lower[index],
61
- y2: upper[index]
81
+ y2: upper[index],
82
+ ...(orderValues === undefined ? {} : { pathOrder: orderValues[index] })
62
83
  }
63
84
  : {
64
85
  x: lower[index],
65
86
  x2: upper[index],
66
- y: independentValues[index]
87
+ y: independentValues[index],
88
+ ...(orderValues === undefined ? {} : { pathOrder: orderValues[index] })
67
89
  });
68
90
  groups.set(key, series);
69
91
  }
@@ -72,7 +94,13 @@ export function deriveAreaSeries(rows, layer) {
72
94
  }
73
95
  const series = [...groups.values()].map(item => {
74
96
  const key = vertical ? "x" : "y";
75
- const values = item.values.sort((left, right) => left[key] - right[key]);
97
+ const values = pathOrder === undefined
98
+ ? item.values.sort((left, right) => left[key] - right[key])
99
+ : stableOrderPathValues(
100
+ item.values.map(({ pathOrder: _pathOrder, ...value }) => value),
101
+ item.values.map(value => value.pathOrder),
102
+ pathOrder.order
103
+ );
76
104
  if (values.length < 2) {
77
105
  throw new Error(
78
106
  `Area series on mark "${layer.id}" requires at least two points.`
@@ -96,9 +124,17 @@ export function deriveDensityAreaSeries(rows, layer, transform) {
96
124
  if (layer?.mark?.type !== "area") {
97
125
  throw new Error("Density area derivation requires a semantic area mark.");
98
126
  }
127
+ if (layer.encoding?.pathOrder !== undefined) {
128
+ throw new Error(
129
+ `Density area mark "${layer.id}" does not support path order.`
130
+ );
131
+ }
99
132
  if (transform?.type !== "density" || !Array.isArray(transform.as)) {
100
133
  throw new Error(`Area mark "${layer.id}" requires density provenance.`);
101
134
  }
135
+ if (transform.placement?.type === "category") {
136
+ return deriveCategoricalDensitySeries(rows, layer, transform);
137
+ }
102
138
  const { x, y, group } = layer.encoding ?? {};
103
139
  if (
104
140
  x?.fieldType !== "quantitative" ||
@@ -0,0 +1,333 @@
1
+ import {
2
+ cloneAndFreeze,
3
+ isPlainObject
4
+ } from "../core/immutable.js";
5
+
6
+ const TRANSFORM_KEYS = Object.freeze([
7
+ "type", "x", "y", "bins", "extent", "includeEmpty", "members", "as",
8
+ "resolved"
9
+ ]);
10
+ const BIN_KEYS = Object.freeze(["x", "y"]);
11
+ const EXTENT_KEYS = Object.freeze(["x", "y"]);
12
+ const FIELD_KEYS = Object.freeze(["x0", "x1", "y0", "y1", "count", "members"]);
13
+ const RESOLVED_KEYS = Object.freeze([
14
+ "extent", "edges", "eligibleCount", "occupiedCount"
15
+ ]);
16
+
17
+ function requireField(value, label) {
18
+ if (typeof value !== "string" || value.length === 0) {
19
+ throw new TypeError(`${label} must be a non-empty string.`);
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function rejectUnknownKeys(value, supported, label) {
25
+ const unknown = Object.keys(value).find(key => !supported.includes(key));
26
+ if (unknown !== undefined) {
27
+ throw new Error(`Unknown ${label} property "${unknown}".`);
28
+ }
29
+ }
30
+
31
+ function requirePositiveInteger(value, label) {
32
+ if (!Number.isInteger(value) || value <= 0) {
33
+ throw new RangeError(`${label} must be a positive integer.`);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ function normalizeBins(value) {
39
+ if (value === undefined) return { x: 10, y: 10 };
40
+ if (Number.isInteger(value)) return { x: value, y: value };
41
+ if (!isPlainObject(value)) {
42
+ throw new TypeError(
43
+ "2D bin bins must be a positive integer or an object with x and y."
44
+ );
45
+ }
46
+ rejectUnknownKeys(value, BIN_KEYS, "2D bin bins");
47
+ return { x: value.x, y: value.y };
48
+ }
49
+
50
+ function validateBins(value) {
51
+ if (!isPlainObject(value)) {
52
+ throw new TypeError("2D bin bins must be an object.");
53
+ }
54
+ rejectUnknownKeys(value, BIN_KEYS, "2D bin bins");
55
+ requirePositiveInteger(value.x, "2D bin x bins");
56
+ requirePositiveInteger(value.y, "2D bin y bins");
57
+ }
58
+
59
+ function normalizeRequestedExtent(value) {
60
+ if (value === undefined) return { x: "auto", y: "auto" };
61
+ if (!isPlainObject(value)) {
62
+ throw new TypeError("2D bin extent must be an object.");
63
+ }
64
+ rejectUnknownKeys(value, EXTENT_KEYS, "2D bin extent");
65
+ return {
66
+ x: value.x ?? "auto",
67
+ y: value.y ?? "auto"
68
+ };
69
+ }
70
+
71
+ function validateExtentValue(value, label, { allowAuto = true } = {}) {
72
+ if (allowAuto && value === "auto") return;
73
+ if (
74
+ !Array.isArray(value) ||
75
+ value.length !== 2 ||
76
+ !value.every(Number.isFinite) ||
77
+ value[0] >= value[1]
78
+ ) {
79
+ throw new RangeError(`${label} must be two increasing finite numbers.`);
80
+ }
81
+ }
82
+
83
+ function validateRequestedExtent(value) {
84
+ if (!isPlainObject(value)) {
85
+ throw new TypeError("2D bin extent must be an object.");
86
+ }
87
+ rejectUnknownKeys(value, EXTENT_KEYS, "2D bin extent");
88
+ validateExtentValue(value.x, "2D bin x extent");
89
+ validateExtentValue(value.y, "2D bin y extent");
90
+ }
91
+
92
+ function normalizeOutputFields(id, value, members) {
93
+ if (value !== undefined && !isPlainObject(value)) {
94
+ throw new TypeError("2D bin as must be an object.");
95
+ }
96
+ if (value !== undefined) rejectUnknownKeys(value, FIELD_KEYS, "2D bin as");
97
+ if (!members && value?.members !== undefined) {
98
+ throw new Error("2D bin as.members requires members: true.");
99
+ }
100
+ return {
101
+ x0: value?.x0 ?? `__${id}_x0`,
102
+ x1: value?.x1 ?? `__${id}_x1`,
103
+ y0: value?.y0 ?? `__${id}_y0`,
104
+ y1: value?.y1 ?? `__${id}_y1`,
105
+ count: value?.count ?? `__${id}_count`,
106
+ ...(members ? { members: value?.members ?? `__${id}_members` } : {})
107
+ };
108
+ }
109
+
110
+ function validateOutputFields(value, members) {
111
+ if (!isPlainObject(value)) {
112
+ throw new TypeError("2D bin as must be an object.");
113
+ }
114
+ rejectUnknownKeys(value, FIELD_KEYS, "2D bin as");
115
+ const required = ["x0", "x1", "y0", "y1", "count"];
116
+ if (members) required.push("members");
117
+ if (!members && value.members !== undefined) {
118
+ throw new Error("2D bin as.members requires members: true.");
119
+ }
120
+ const fields = required.map(role => requireField(
121
+ value[role],
122
+ `2D bin ${role} output field`
123
+ ));
124
+ if (new Set(fields).size !== fields.length) {
125
+ throw new Error("2D bin output fields must be unique.");
126
+ }
127
+ }
128
+
129
+ function validateEdges(value, extent, count, axis) {
130
+ if (
131
+ !Array.isArray(value) ||
132
+ value.length !== count + 1 ||
133
+ !value.every(Number.isFinite)
134
+ ) {
135
+ throw new TypeError(`Resolved 2D bin ${axis} edges are invalid.`);
136
+ }
137
+ if (value[0] !== extent[0] || value.at(-1) !== extent[1]) {
138
+ throw new Error(`Resolved 2D bin ${axis} edges must match its extent.`);
139
+ }
140
+ for (let index = 1; index < value.length; index += 1) {
141
+ if (value[index] <= value[index - 1]) {
142
+ throw new Error(`Resolved 2D bin ${axis} edges must be increasing.`);
143
+ }
144
+ }
145
+ }
146
+
147
+ function validateResolved(value, bins) {
148
+ if (!isPlainObject(value)) {
149
+ throw new TypeError("Resolved 2D bin state must be a plain object.");
150
+ }
151
+ rejectUnknownKeys(value, RESOLVED_KEYS, "resolved 2D bin");
152
+ if (!isPlainObject(value.extent) || !isPlainObject(value.edges)) {
153
+ throw new TypeError("Resolved 2D bin extent and edges must be objects.");
154
+ }
155
+ rejectUnknownKeys(value.extent, EXTENT_KEYS, "resolved 2D bin extent");
156
+ rejectUnknownKeys(value.edges, EXTENT_KEYS, "resolved 2D bin edges");
157
+ for (const axis of ["x", "y"]) {
158
+ validateExtentValue(
159
+ value.extent[axis],
160
+ `Resolved 2D bin ${axis} extent`,
161
+ { allowAuto: false }
162
+ );
163
+ validateEdges(value.edges[axis], value.extent[axis], bins[axis], axis);
164
+ }
165
+ for (const property of ["eligibleCount", "occupiedCount"]) {
166
+ if (!Number.isInteger(value[property]) || value[property] < 0) {
167
+ throw new RangeError(`Resolved 2D bin ${property} must be a non-negative integer.`);
168
+ }
169
+ }
170
+ if (value.occupiedCount > bins.x * bins.y) {
171
+ throw new RangeError("Resolved 2D bin occupiedCount exceeds the grid size.");
172
+ }
173
+ }
174
+
175
+ export function validateBin2DTransform(transform) {
176
+ if (!isPlainObject(transform)) {
177
+ throw new TypeError("2D bin transform must be a plain object.");
178
+ }
179
+ rejectUnknownKeys(transform, TRANSFORM_KEYS, "2D bin transform");
180
+ if (transform.type !== "bin2d") {
181
+ throw new Error(`Unsupported 2D bin transform "${transform.type}".`);
182
+ }
183
+ requireField(transform.x, "2D bin x field");
184
+ requireField(transform.y, "2D bin y field");
185
+ validateBins(transform.bins);
186
+ validateRequestedExtent(transform.extent);
187
+ if (typeof transform.includeEmpty !== "boolean") {
188
+ throw new TypeError("2D bin includeEmpty must be a boolean.");
189
+ }
190
+ if (typeof transform.members !== "boolean") {
191
+ throw new TypeError("2D bin members must be a boolean.");
192
+ }
193
+ validateOutputFields(transform.as, transform.members);
194
+ if (transform.resolved !== undefined) {
195
+ validateResolved(transform.resolved, transform.bins);
196
+ }
197
+ return transform;
198
+ }
199
+
200
+ export function normalizeBin2DTransform({
201
+ id,
202
+ x,
203
+ y,
204
+ bins,
205
+ extent,
206
+ includeEmpty = false,
207
+ members = false,
208
+ as
209
+ } = {}) {
210
+ const normalizedBins = normalizeBins(bins);
211
+ const transform = {
212
+ type: "bin2d",
213
+ x,
214
+ y,
215
+ bins: normalizedBins,
216
+ extent: normalizeRequestedExtent(extent),
217
+ includeEmpty,
218
+ members,
219
+ as: normalizeOutputFields(id, as, members)
220
+ };
221
+ validateBin2DTransform(transform);
222
+ return cloneAndFreeze(transform);
223
+ }
224
+
225
+ function resolveExtent(values, requested, axis) {
226
+ const minimum = Math.min(...values);
227
+ const maximum = Math.max(...values);
228
+ const extent = requested === "auto" ? [minimum, maximum] : requested;
229
+ if (extent[0] > minimum || extent[1] < maximum) {
230
+ throw new RangeError(
231
+ `2D bin ${axis} extent must contain every eligible value.`
232
+ );
233
+ }
234
+ if (extent[0] === extent[1]) {
235
+ throw new RangeError(`2D bin ${axis} extent must have positive span.`);
236
+ }
237
+ return [...extent];
238
+ }
239
+
240
+ function createEdges(extent, count) {
241
+ const [minimum, maximum] = extent;
242
+ const step = (maximum - minimum) / count;
243
+ return Array.from({ length: count + 1 }, (_, index) =>
244
+ index === count ? maximum : minimum + step * index
245
+ );
246
+ }
247
+
248
+ function findBinIndex(value, edges) {
249
+ if (value === edges.at(-1)) return edges.length - 2;
250
+ let low = 0;
251
+ let high = edges.length - 1;
252
+ while (low + 1 < high) {
253
+ const middle = Math.floor((low + high) / 2);
254
+ if (value < edges[middle]) high = middle;
255
+ else low = middle;
256
+ }
257
+ return low;
258
+ }
259
+
260
+ export function deriveBin2DRows(rows, transform) {
261
+ if (!Array.isArray(rows)) {
262
+ throw new TypeError("2D bin source rows must be an array.");
263
+ }
264
+ validateBin2DTransform(transform);
265
+ const eligible = rows.flatMap((row, index) =>
266
+ isPlainObject(row) &&
267
+ Number.isFinite(row[transform.x]) &&
268
+ Number.isFinite(row[transform.y])
269
+ ? [{ index, row }]
270
+ : []
271
+ );
272
+ if (eligible.length === 0) {
273
+ throw new Error(
274
+ "2D bin requires at least one row with finite x and y values."
275
+ );
276
+ }
277
+ const extent = {
278
+ x: resolveExtent(
279
+ eligible.map(item => item.row[transform.x]),
280
+ transform.extent.x,
281
+ "x"
282
+ ),
283
+ y: resolveExtent(
284
+ eligible.map(item => item.row[transform.y]),
285
+ transform.extent.y,
286
+ "y"
287
+ )
288
+ };
289
+ const edges = {
290
+ x: createEdges(extent.x, transform.bins.x),
291
+ y: createEdges(extent.y, transform.bins.y)
292
+ };
293
+ const buckets = Array.from(
294
+ { length: transform.bins.x * transform.bins.y },
295
+ () => []
296
+ );
297
+ for (const item of eligible) {
298
+ const xIndex = findBinIndex(item.row[transform.x], edges.x);
299
+ const yIndex = findBinIndex(item.row[transform.y], edges.y);
300
+ buckets[yIndex * transform.bins.x + xIndex].push(item.index);
301
+ }
302
+ const values = [];
303
+ for (let yIndex = 0; yIndex < transform.bins.y; yIndex += 1) {
304
+ for (let xIndex = 0; xIndex < transform.bins.x; xIndex += 1) {
305
+ const memberIndexes = buckets[yIndex * transform.bins.x + xIndex];
306
+ if (!transform.includeEmpty && memberIndexes.length === 0) continue;
307
+ values.push({
308
+ [transform.as.x0]: edges.x[xIndex],
309
+ [transform.as.x1]: edges.x[xIndex + 1],
310
+ [transform.as.y0]: edges.y[yIndex],
311
+ [transform.as.y1]: edges.y[yIndex + 1],
312
+ [transform.as.count]: memberIndexes.length,
313
+ ...(transform.members
314
+ ? { [transform.as.members]: [...memberIndexes] }
315
+ : {})
316
+ });
317
+ }
318
+ }
319
+ return cloneAndFreeze({
320
+ values,
321
+ resolved: {
322
+ extent,
323
+ edges,
324
+ eligibleCount: eligible.length,
325
+ occupiedCount: buckets.filter(bucket => bucket.length > 0).length
326
+ }
327
+ });
328
+ }
329
+
330
+ export function requestedBin2DTransform(transform) {
331
+ const { resolved: _resolved, ...requested } = transform;
332
+ return cloneAndFreeze(requested);
333
+ }
@@ -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")) {