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
package/src/grammar/arcs.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
mapOrdinalPositionValues,
|
|
5
5
|
readNominalField,
|
|
6
6
|
readQuantitativeField
|
|
7
|
-
} from "./scales.js";
|
|
7
|
+
} from "./scales/index.js";
|
|
8
8
|
|
|
9
9
|
function requireArcLayer(layer) {
|
|
10
10
|
if (layer?.mark?.type !== "arc") {
|
|
@@ -36,42 +36,79 @@ function colorValues(rows, encoding) {
|
|
|
36
36
|
: readNominalField(rows, encoding.field);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function
|
|
39
|
+
export function readArcThetaWeights(rows, field, layerId = "arc") {
|
|
40
|
+
if (!Array.isArray(rows)) throw new TypeError("Arc theta weights require rows.");
|
|
41
|
+
if (typeof field !== "string" || field.length === 0) {
|
|
42
|
+
throw new TypeError("Arc theta weight must be a non-empty string.");
|
|
43
|
+
}
|
|
44
|
+
const weights = rows.map((row, index) => {
|
|
45
|
+
const value = row?.[field];
|
|
46
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
47
|
+
throw new TypeError(
|
|
48
|
+
`Arc theta weight field "${field}" must contain non-negative finite numbers at row ${index}.`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
});
|
|
53
|
+
if (weights.every(value => value === 0)) {
|
|
54
|
+
throw new Error(`Arc mark "${layerId}" theta weights must have a positive total.`);
|
|
55
|
+
}
|
|
56
|
+
return cloneAndFreeze(weights);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function proportionalSectors(rows, layer, thetaScale, frame, innerRadiusRatio) {
|
|
40
60
|
const theta = layer.encoding.theta;
|
|
41
|
-
if (theta.aggregate
|
|
42
|
-
throw new Error(`Arc mark "${layer.id}" requires count or radial layout.`);
|
|
61
|
+
if (!["count", "sum"].includes(theta.aggregate)) {
|
|
62
|
+
throw new Error(`Arc mark "${layer.id}" requires count, sum, or radial layout.`);
|
|
43
63
|
}
|
|
44
64
|
const values = readNominalField(rows, theta.field);
|
|
45
65
|
const colors = colorValues(rows, layer.encoding?.color);
|
|
66
|
+
const weights = theta.aggregate === "count"
|
|
67
|
+
? rows.map(() => 1)
|
|
68
|
+
: readArcThetaWeights(rows, theta.weight, layer.id);
|
|
46
69
|
const groups = new Map(thetaScale.domain.map(value => [value, []]));
|
|
47
70
|
for (let index = 0; index < rows.length; index += 1) {
|
|
48
71
|
const group = groups.get(values[index]);
|
|
49
72
|
if (group === undefined) {
|
|
50
73
|
throw new Error(`Arc theta value "${values[index]}" is outside the scale domain.`);
|
|
51
74
|
}
|
|
52
|
-
group.push({
|
|
75
|
+
group.push({
|
|
76
|
+
row: rows[index],
|
|
77
|
+
color: colors[index],
|
|
78
|
+
sourceIndex: index,
|
|
79
|
+
weight: weights[index]
|
|
80
|
+
});
|
|
53
81
|
}
|
|
54
|
-
const total = [...groups.values()].reduce(
|
|
55
|
-
|
|
82
|
+
const total = [...groups.values()].reduce(
|
|
83
|
+
(sum, group) => sum + group.reduce((groupSum, item) => groupSum + item.weight, 0),
|
|
84
|
+
0
|
|
85
|
+
);
|
|
86
|
+
if (total === 0) throw new Error(`Arc mark "${layer.id}" has no positive theta weight.`);
|
|
56
87
|
const start = thetaScale.range[0];
|
|
57
88
|
const span = thetaScale.range[1] - thetaScale.range[0];
|
|
58
89
|
const innerRadius = frame.availableRadius * innerRadiusRatio;
|
|
59
90
|
let cursor = start;
|
|
60
91
|
const sectors = [];
|
|
61
|
-
|
|
92
|
+
const positiveValues = thetaScale.domain.filter(value =>
|
|
93
|
+
groups.get(value).some(item => item.weight > 0)
|
|
94
|
+
);
|
|
95
|
+
for (const [positiveIndex, value] of positiveValues.entries()) {
|
|
62
96
|
const group = groups.get(value);
|
|
63
|
-
|
|
64
|
-
const endTheta =
|
|
97
|
+
const aggregateValue = group.reduce((sum, item) => sum + item.weight, 0);
|
|
98
|
+
const endTheta = positiveIndex === positiveValues.length - 1
|
|
99
|
+
? thetaScale.range[1]
|
|
100
|
+
: cursor + aggregateValue / total * span;
|
|
65
101
|
const distinctColors = [...new Set(group.map(item => item.color))];
|
|
66
102
|
if (distinctColors.length > 1) {
|
|
67
103
|
throw new Error(
|
|
68
|
-
`
|
|
104
|
+
`Aggregated arc theta group "${value}" must resolve to one color value.`
|
|
69
105
|
);
|
|
70
106
|
}
|
|
71
107
|
sectors.push({
|
|
72
108
|
key: value,
|
|
73
109
|
theta: value,
|
|
74
110
|
count: group.length,
|
|
111
|
+
aggregateValue,
|
|
75
112
|
color: distinctColors[0],
|
|
76
113
|
startTheta: cursor,
|
|
77
114
|
endTheta,
|
|
@@ -147,8 +184,8 @@ export function deriveArcSectors(rows, layer, {
|
|
|
147
184
|
) {
|
|
148
185
|
throw new RangeError("Arc innerRadius must be from 0 (inclusive) to 1 (exclusive).");
|
|
149
186
|
}
|
|
150
|
-
const sectors = theta.aggregate
|
|
151
|
-
?
|
|
187
|
+
const sectors = ["count", "sum"].includes(theta.aggregate)
|
|
188
|
+
? proportionalSectors(rows, layer, thetaScale, frame, innerRadiusRatio)
|
|
152
189
|
: radialSectors(rows, layer, thetaScale, radiusScale);
|
|
153
190
|
return cloneAndFreeze({ sectors });
|
|
154
191
|
}
|
|
@@ -3,17 +3,19 @@ import {
|
|
|
3
3
|
readNominalField,
|
|
4
4
|
readQuantitativeField,
|
|
5
5
|
readTemporalField
|
|
6
|
-
} from "./scales.js";
|
|
6
|
+
} from "./scales/index.js";
|
|
7
7
|
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:
|
|
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 =
|
|
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
|
+
}
|