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.
- package/CHANGELOG.md +55 -0
- package/README.md +35 -13
- package/package.json +18 -7
- package/src/actions/boxPlots/create.js +13 -7
- package/src/actions/boxPlots/edit.js +484 -36
- package/src/actions/boxPlots/materialize.js +3 -0
- package/src/actions/boxPlots/options.js +11 -4
- package/src/actions/boxPlots/resolve.js +10 -2
- package/src/actions/charts/bar.js +50 -0
- package/src/actions/charts/heatmap.js +205 -0
- package/src/actions/charts/histogram.js +58 -0
- package/src/actions/charts/index.js +15 -0
- package/src/actions/charts/line.js +69 -0
- package/src/actions/charts/parallel.js +76 -0
- package/src/actions/charts/scatter.js +56 -0
- package/src/actions/charts/shared.js +121 -0
- package/src/actions/composition/actions.js +13 -3
- package/src/actions/coordinates/parallel.js +50 -0
- package/src/actions/data/bin2d.js +260 -0
- package/src/actions/data/density.js +54 -13
- package/src/actions/data/derived.js +22 -0
- package/src/actions/data/filter.js +5 -0
- package/src/actions/data/gradientProfile.js +65 -0
- package/src/actions/data/horizon.js +71 -0
- package/src/actions/data/index.js +34 -2
- package/src/actions/data/intervalEdit.js +76 -0
- package/src/actions/data/window.js +50 -0
- package/src/actions/distributions/revision.js +326 -0
- package/src/actions/encodings/appearance.js +35 -1
- package/src/actions/encodings/color/index.js +17 -1
- package/src/actions/encodings/color/layout.js +8 -0
- package/src/actions/encodings/density/resolve.js +109 -0
- package/src/actions/encodings/density.js +330 -90
- package/src/actions/encodings/horizon/resolve.js +283 -0
- package/src/actions/encodings/horizon.js +364 -0
- package/src/actions/encodings/index.js +8 -0
- package/src/actions/encodings/parallel.js +115 -0
- package/src/actions/encodings/pathOrder.js +131 -0
- package/src/actions/encodings/position/apply.js +73 -4
- package/src/actions/encodings/position/index.js +15 -2
- package/src/actions/encodings/position/policies/arc.js +27 -4
- package/src/actions/encodings/position/policies/area.js +33 -2
- package/src/actions/encodings/position/policies/index.js +7 -2
- package/src/actions/encodings/position/policies/line.js +18 -2
- package/src/actions/encodings/position/resolve.js +24 -2
- package/src/actions/encodings/ranged.js +3 -1
- package/src/actions/encodings/remove.js +268 -0
- package/src/actions/encodings/ruleAppearance.js +88 -10
- package/src/actions/errorBands/edit.js +121 -13
- package/src/actions/errorBars/edit.js +45 -8
- package/src/actions/errorBars/options.js +7 -1
- package/src/actions/facets/actions.js +137 -3
- package/src/actions/facets/derive.js +33 -3
- package/src/actions/facets/replay.js +7 -36
- package/src/actions/gradientPlots/components.js +177 -0
- package/src/actions/gradientPlots/create.js +96 -0
- package/src/actions/gradientPlots/edit.js +501 -0
- package/src/actions/gradientPlots/index.js +24 -0
- package/src/actions/gradientPlots/materialize.js +287 -0
- package/src/actions/gradientPlots/options.js +120 -0
- package/src/actions/gradientPlots/paint.js +107 -0
- package/src/actions/gradientPlots/rebind.js +51 -0
- package/src/actions/gradientPlots/resolve.js +81 -0
- package/src/actions/guides/applicability.js +44 -17
- package/src/actions/guides/axes/axes.js +37 -3
- package/src/actions/guides/axes/edit.js +129 -47
- package/src/actions/guides/axes/index.js +2 -0
- package/src/actions/guides/axes/labels.js +11 -6
- package/src/actions/guides/axes/parallel/resolve.js +86 -0
- package/src/actions/guides/axes/parallel.js +228 -0
- package/src/actions/guides/axes/titles.js +11 -8
- package/src/actions/guides/guides.js +60 -5
- package/src/actions/guides/legends/categorical/actions.js +67 -19
- package/src/actions/guides/legends/continuous/common.js +3 -3
- package/src/actions/guides/legends/edit.js +63 -0
- package/src/actions/guides/legends/index.js +2 -0
- package/src/actions/guides/legends/remove.js +124 -30
- package/src/actions/guides/legends/strokeWidth.js +198 -0
- package/src/actions/index.js +6 -0
- package/src/actions/marks/arc/actions.js +46 -14
- package/src/actions/marks/area/actions.js +60 -136
- package/src/actions/marks/area/materialize.js +163 -0
- package/src/actions/marks/index.js +5 -1
- package/src/actions/marks/line/actions.js +99 -80
- package/src/actions/marks/line/materialize.js +146 -0
- package/src/actions/marks/point/create.js +76 -0
- package/src/actions/marks/point/edit.js +97 -0
- package/src/actions/marks/point/index.js +11 -1
- package/src/actions/marks/point/jitter.js +72 -0
- package/src/actions/marks/point/{actions.js → materialize.js} +183 -172
- package/src/actions/marks/rect/actions.js +3 -0
- package/src/actions/marks/remove.js +13 -2
- package/src/actions/marks/rule/actions.js +29 -4
- package/src/actions/marks/text/actions.js +19 -4
- package/src/actions/marks/text/index.js +7 -1
- package/src/actions/marks/text/layout.js +321 -0
- package/src/actions/primitives/semanticValidation/dataset.js +22 -0
- package/src/actions/primitives/semanticValidation/guide.js +43 -0
- package/src/actions/primitives/semanticValidation/index.js +23 -0
- package/src/actions/primitives/semanticValidation/layer.js +74 -0
- package/src/actions/primitives/semanticValidation/scale.js +74 -0
- package/src/actions/primitives/semanticValidation/shared.js +5 -0
- package/src/actions/primitives/semanticValue.js +1 -204
- package/src/actions/regression/edit.js +247 -88
- package/src/actions/scales/consumers/common.js +18 -0
- package/src/actions/scales/consumers/families.js +14 -3
- package/src/actions/scales/definitions.js +30 -0
- package/src/actions/scales/edit.js +6 -204
- package/src/actions/scales/editPolicy.js +214 -0
- package/src/actions/scales/materialize.js +1 -1
- package/src/actions/scales/patch.js +26 -0
- package/src/actions/selection/actions.js +150 -5
- package/src/actions/selection/index.js +6 -0
- package/src/actions/violinPlots/create.js +198 -0
- package/src/actions/violinPlots/index.js +5 -0
- package/src/core/textMetrics.js +47 -0
- package/src/core/vocabulary.js +14 -2
- package/src/grammar/arcs.js +49 -12
- package/src/grammar/areaSeries.js +41 -5
- package/src/grammar/bin2d.js +333 -0
- package/src/grammar/categoricalDensity.js +192 -0
- package/src/grammar/coordinates.js +1 -1
- package/src/grammar/curveCommands.js +3 -1
- package/src/grammar/density.js +252 -26
- package/src/grammar/facets/dependencies.js +3 -11
- package/src/grammar/facets/index.js +3 -1
- package/src/grammar/gradientProfile.js +213 -0
- package/src/grammar/horizon.js +455 -0
- package/src/grammar/jitter.js +197 -0
- package/src/grammar/lineSeries.js +69 -0
- package/src/grammar/paint.js +88 -0
- package/src/grammar/parallelCoordinates.js +213 -0
- package/src/grammar/pathOrder.js +35 -0
- package/src/grammar/pointShapes.js +24 -0
- package/src/grammar/rules.js +1 -0
- package/src/grammar/scales/appearance.js +21 -0
- package/src/grammar/schemas/concreteGraphic.js +11 -1
- package/src/grammar/schemas/graphicBounds.js +19 -37
- package/src/grammar/schemas/semanticPath.js +17 -2
- package/src/grammar/transforms.js +100 -11
- package/src/grammar/window.js +339 -0
- package/src/layout/labels.js +270 -0
- package/src/layout/text.js +4 -1
- package/src/layout/title.js +10 -20
- package/src/materialization/dataProvenance.js +56 -2
- package/src/materialization/dependencies.js +9 -14
- package/src/materialization/guides/resources.js +74 -24
- package/src/materialization/horizon.js +29 -0
- package/src/materialization/layout.js +15 -0
- package/src/materialization/marks/capabilities.js +21 -1
- package/src/materialization/marks/index.js +3 -0
- package/src/materialization/marks/pathOrder.js +71 -0
- package/src/materialization/marks/policies.js +6 -2
- package/src/materialization/scaleGuideDependencies.js +8 -0
- package/src/materialization/scales/resolve.js +2 -0
- package/src/materialization/selection/items/path.js +27 -1
- package/src/materialization/selection/items/point.js +1 -5
- package/src/materialization/selection/items/rect.js +21 -0
- package/src/materialization/selection/styles.js +20 -9
- package/src/materialization/text.js +12 -4
- package/src/renderers/canvas/fill.js +33 -0
- package/src/renderers/canvas/index.js +7 -0
- package/src/renderers/canvas/path.js +7 -4
- package/src/renderers/canvas/rect.js +9 -3
- package/src/selectors/datasets.js +4 -0
- package/src/theme/defaults.js +4 -0
- package/types/extension.d.ts +6 -0
- package/types/index.d.ts +77 -0
- package/types/program.d.ts +703 -17
package/src/grammar/density.js
CHANGED
|
@@ -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(
|
|
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
|
|
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([
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
58
|
-
if (
|
|
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([
|
|
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
|
+
}
|