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.
- package/CHANGELOG.md +58 -3
- package/README.md +117 -83
- package/package.json +27 -11
- package/src/actions/boxPlots/create.js +13 -7
- package/src/actions/boxPlots/edit.js +31 -35
- package/src/actions/boxPlots/materialize.js +3 -0
- package/src/actions/boxPlots/options.js +8 -1
- 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/coordinates/parallel.js +50 -0
- package/src/actions/data/bin2d.js +137 -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 +29 -2
- package/src/actions/data/regression.js +1 -1
- package/src/actions/data/window.js +50 -0
- package/src/actions/encodings/appearance.js +1 -1
- package/src/actions/encodings/color/continuous.js +137 -0
- package/src/actions/encodings/color/index.js +167 -0
- package/src/actions/encodings/color/layout.js +99 -0
- package/src/actions/encodings/color/policy.js +88 -0
- package/src/actions/encodings/density/resolve.js +109 -0
- package/src/actions/encodings/density.js +193 -88
- package/src/actions/encodings/horizon/resolve.js +283 -0
- package/src/actions/encodings/horizon.js +364 -0
- package/src/actions/encodings/index.js +8 -2
- package/src/actions/encodings/offset.js +1 -1
- 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.js → position/index.js} +21 -8
- 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 +25 -3
- package/src/actions/encodings/ranged.js +5 -3
- package/src/actions/encodings/ruleAppearance.js +88 -10
- package/src/actions/encodings/strokeDash.js +1 -1
- package/src/actions/encodings/text.js +1 -1
- package/src/actions/errorBands/options.js +1 -1
- package/src/actions/errorBars/options.js +1 -1
- package/src/actions/facets/actions.js +2 -3
- package/src/actions/facets/derive.js +35 -5
- package/src/actions/facets/guides.js +1 -1
- 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 +153 -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/index.js +2 -0
- package/src/actions/guides/axes/labels.js +12 -7
- package/src/actions/guides/axes/parallel/resolve.js +86 -0
- package/src/actions/guides/axes/parallel.js +228 -0
- package/src/actions/guides/axes/ticks.js +1 -1
- package/src/actions/guides/axes/titles.js +12 -9
- package/src/actions/guides/grids/resolve.js +1 -1
- package/src/actions/guides/guides.js +60 -5
- package/src/actions/guides/legends/categorical/actions.js +48 -11
- package/src/actions/guides/legends/categorical/layout.js +4 -2
- package/src/actions/guides/legends/continuous/interval.js +1 -1
- package/src/actions/guides/legends/continuous/opacity.js +1 -1
- package/src/actions/guides/legends/edit.js +7 -20
- package/src/actions/guides/legends/index.js +2 -0
- package/src/actions/guides/legends/remove.js +6 -31
- package/src/actions/guides/legends/size.js +1 -1
- package/src/actions/guides/legends/strokeWidth.js +170 -0
- package/src/actions/guides/legends/target.js +25 -0
- package/src/actions/guides/polar/{axes.js → axes/index.js} +5 -5
- package/src/actions/guides/polar/index.js +1 -1
- package/src/actions/guides/polar/resolve.js +1 -1
- package/src/actions/guides/tickValues.js +1 -1
- package/src/actions/index.js +6 -0
- package/src/actions/intervals/resolve.js +1 -1
- package/src/actions/marks/arc/actions.js +2 -2
- package/src/actions/marks/area/actions.js +61 -137
- package/src/actions/marks/area/materialize.js +163 -0
- package/src/actions/marks/bar/edit.js +1 -1
- package/src/actions/marks/index.js +5 -1
- package/src/actions/marks/line/actions.js +100 -81
- 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 +85 -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} +175 -170
- package/src/actions/marks/rect/actions.js +4 -1
- package/src/actions/marks/remove.js +13 -2
- package/src/actions/marks/rule/actions.js +26 -4
- package/src/actions/marks/shared.js +1 -1
- package/src/actions/marks/text/actions.js +20 -5
- 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/components.js +1 -1
- package/src/actions/regression/create.js +1 -1
- package/src/actions/regression/edit.js +20 -22
- package/src/actions/scales/consumers/common.js +109 -0
- package/src/actions/scales/consumers/families.js +58 -0
- package/src/actions/scales/consumers/index.js +41 -0
- package/src/actions/scales/{consumers.js → consumers/seriesLayout.js} +10 -132
- package/src/actions/scales/create.js +1 -1
- package/src/actions/scales/definitions.js +32 -2
- package/src/actions/scales/edit.js +8 -206
- package/src/actions/scales/editPolicy.js +214 -0
- package/src/actions/scales/materialize.js +3 -3
- package/src/actions/scales/patch.js +26 -0
- package/src/actions/selection/actions.js +28 -3
- package/src/actions/violinPlots/create.js +198 -0
- package/src/actions/violinPlots/index.js +5 -0
- package/src/core/ChartProgram.js +22 -236
- package/src/core/compositionState.js +169 -0
- package/src/core/materializationState.js +65 -0
- package/src/core/programState.js +28 -0
- package/src/core/textMetrics.js +47 -0
- package/src/core/vocabulary.js +7 -2
- package/src/grammar/arcs.js +50 -13
- package/src/grammar/areaSeries.js +42 -6
- package/src/grammar/bars/aggregate.js +1 -1
- 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 +253 -27
- package/src/grammar/facets/dependencies.js +3 -11
- package/src/grammar/{facets.js → facets/index.js} +8 -6
- package/src/grammar/gradientProfile.js +213 -0
- package/src/grammar/horizon.js +455 -0
- package/src/grammar/interval.js +2 -96
- package/src/grammar/jitter.js +197 -0
- package/src/grammar/lineSeries.js +70 -1
- package/src/grammar/paint.js +88 -0
- package/src/grammar/palettes.js +5 -7
- package/src/grammar/parallelCoordinates.js +213 -0
- package/src/grammar/pathOrder.js +35 -0
- package/src/grammar/pointShapes.js +24 -0
- package/src/grammar/polarLineCommands.js +1 -1
- package/src/grammar/regression/derive.js +97 -0
- package/src/grammar/regression/index.js +22 -0
- package/src/grammar/regression/models.js +282 -0
- package/src/grammar/regression/parameters.js +106 -0
- package/src/grammar/rules.js +2 -1
- package/src/grammar/scales/appearance.js +21 -0
- package/src/grammar/scales/color.js +4 -2
- 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/statistics/studentT.js +98 -0
- package/src/grammar/transforms.js +101 -12
- 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/bars/aggregate.js +1 -1
- package/src/materialization/bars/grouped.js +1 -1
- package/src/materialization/bars/histogram.js +1 -1
- package/src/materialization/bars/ranged.js +1 -1
- package/src/materialization/dataProvenance.js +56 -2
- package/src/materialization/density.js +1 -1
- package/src/materialization/dependencies.js +15 -101
- package/src/materialization/encodings.js +2 -2
- package/src/materialization/facetGuides/index.js +2 -0
- package/src/materialization/facetGuides/legacyCategorical.js +125 -0
- package/src/materialization/facetGuides/placement.js +126 -0
- package/src/materialization/facetGuides/preparation.js +188 -0
- package/src/materialization/facets.js +1 -1
- 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 +161 -0
- package/src/materialization/marks/index.js +108 -0
- package/src/materialization/marks/pathOrder.js +71 -0
- package/src/materialization/marks/policies.js +91 -0
- package/src/materialization/rect.js +1 -1
- package/src/materialization/rowEncoding.js +1 -1
- package/src/materialization/scaleGuideDependencies.js +102 -0
- package/src/materialization/scales/map.js +1 -1
- package/src/materialization/scales/policies/series.js +1 -1
- package/src/materialization/scales/resolve.js +3 -1
- package/src/materialization/selection/items/bar.js +1 -1
- 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 +21 -10
- 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/renderers/canvas/text.js +14 -1
- package/src/selectors/datasets.js +4 -0
- package/src/theme/defaults.js +4 -0
- package/types/extension.d.ts +10 -1
- package/types/index.d.ts +76 -0
- package/types/program.d.ts +632 -10
- package/src/actions/encodings/color.js +0 -424
- package/src/grammar/regression.js +0 -578
- package/src/grammar/scales.js +0 -1
- package/src/materialization/facetGuides.js +0 -426
- 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) &&
|
|
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")) {
|
package/src/grammar/density.js
CHANGED
|
@@ -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(
|
|
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
|
);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { cloneAndFreeze, isPlainObject } from "
|
|
2
|
-
import { validateUserId } from "
|
|
3
|
-
import { BAR_GRAINS, resolveBarGrain } from "
|
|
4
|
-
import { planFacetDependencies } from "./
|
|
5
|
-
import { readNominalField } from "
|
|
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([
|
|
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,
|