ggaction 0.0.16 → 0.0.17
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 +20 -0
- package/README.md +2 -1
- package/knowledge/action-cards.json +1 -1
- package/knowledge/intent-taxonomy.json +1 -1
- package/knowledge/mcp-resources.json +1 -1
- package/package.json +8 -2
- package/src/actions/data/bin.js +1 -1
- package/src/actions/data/create.js +18 -5
- package/src/actions/data/density.js +1 -1
- package/src/actions/data/derived.js +15 -1
- package/src/actions/data/edit.js +19 -6
- package/src/actions/data/filter.js +15 -4
- package/src/actions/data/index.js +3 -0
- package/src/actions/data/interval.js +2 -0
- package/src/actions/data/regression.js +4 -1
- package/src/actions/data/revise.js +37 -5
- package/src/actions/data/shared.js +30 -2
- package/src/actions/data/sort.js +21 -0
- package/src/actions/data/summary.js +1 -1
- package/src/actions/encodings/angle.js +4 -1
- package/src/actions/encodings/appearance.js +6 -2
- package/src/actions/encodings/color/continuous.js +4 -1
- package/src/actions/encodings/color/index.js +4 -1
- package/src/actions/encodings/offset.js +4 -1
- package/src/actions/encodings/position/resolve.js +6 -1
- package/src/actions/encodings/ranged.js +6 -1
- package/src/actions/encodings/ruleAppearance.js +4 -1
- package/src/actions/encodings/stroke.js +4 -1
- package/src/actions/encodings/strokeDash.js +6 -1
- package/src/actions/encodings/text.js +6 -1
- package/src/actions/marks/bar/create.js +8 -1
- package/src/actions/marks/bar/edit.js +12 -5
- package/src/actions/marks/point/create.js +8 -1
- package/src/actions/marks/point/edit.js +12 -5
- package/src/actions/marks/point/materialize.js +4 -2
- package/src/actions/marks/rect/actions.js +23 -8
- package/src/actions/marks/rule/actions.js +24 -8
- package/src/actions/marks/text/actions.js +25 -6
- package/src/actions/marks/tick/actions.js +23 -8
- package/src/actions/primitives/semanticAction.js +37 -3
- package/src/actions/primitives/semanticValidation/dataset.js +13 -1
- package/src/actions/primitives/semanticValidation/layer.js +22 -2
- package/src/actions/primitives/semanticValidation/scale.js +6 -0
- package/src/actions/regression/create.js +25 -4
- package/src/actions/regression/edit.js +47 -12
- package/src/actions/regression/follow.js +22 -0
- package/src/actions/scales/channels.js +11 -11
- package/src/actions/scales/consumers/common.js +2 -1
- package/src/actions/scales/create.js +2 -1
- package/src/actions/scales/definitions.js +1 -1
- package/src/actions/scales/edit.js +1 -1
- package/src/actions/scales/parallel.js +1 -1
- package/src/actions/scales/patch.js +1 -1
- package/src/actions/scales/preview.js +28 -1
- package/src/actions/theme/reconcile.js +2 -0
- package/src/core/action.js +3 -2
- package/src/core/programState.js +5 -1
- package/src/grammar/bin.js +39 -10
- package/src/grammar/datasetSchema.js +386 -0
- package/src/grammar/density.js +53 -5
- package/src/grammar/filter.js +133 -39
- package/src/grammar/interval.js +32 -1
- package/src/grammar/itemMissing.js +59 -0
- package/src/grammar/regression/derive.js +57 -16
- package/src/grammar/regression/index.js +1 -0
- package/src/grammar/regression/models.js +51 -22
- package/src/grammar/regression/parameters.js +56 -4
- package/src/grammar/scales/definition.js +7 -0
- package/src/grammar/schemas/semanticPath.js +3 -2
- package/src/grammar/sort.js +88 -0
- package/src/grammar/summary.js +117 -19
- package/src/grammar/transformTopology.js +1 -0
- package/src/grammar/transforms.js +21 -11
- package/src/inspection.js +495 -0
- package/src/inspectionDescriptors.js +3 -0
- package/src/materialization/bars/resolve.js +4 -2
- package/src/materialization/revisionIdentity.js +19 -0
- package/src/materialization/scales/resolve.js +67 -4
- package/src/materialization/text.js +4 -2
- package/src/persistence.js +79 -6
- package/src/version.js +1 -1
- package/types/inspection.d.ts +17 -0
- package/types/program.d.ts +147 -27
package/src/grammar/filter.js
CHANGED
|
@@ -39,33 +39,62 @@ function validateRange(range) {
|
|
|
39
39
|
throw new TypeError("Filter range must be a plain object.");
|
|
40
40
|
}
|
|
41
41
|
const unknown = Object.keys(range).find(
|
|
42
|
-
key => !["min", "max", "inclusive"].includes(key)
|
|
42
|
+
key => !["min", "max", "inclusive", "minInclusive", "maxInclusive"].includes(key)
|
|
43
43
|
);
|
|
44
44
|
if (unknown !== undefined) {
|
|
45
45
|
throw new Error(`Unknown filter range property "${unknown}".`);
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
47
|
+
const hasMin = Object.hasOwn(range, "min");
|
|
48
|
+
const hasMax = Object.hasOwn(range, "max");
|
|
49
|
+
if (!hasMin && !hasMax) throw new TypeError("Filter range requires min or max.");
|
|
50
|
+
if ((hasMin && !isFiniteOrString(range.min)) || (hasMax && !isFiniteOrString(range.max))) {
|
|
51
|
+
throw new TypeError("Filter range endpoints must be finite numbers or strings.");
|
|
52
|
+
}
|
|
53
|
+
if (hasMin && hasMax && typeof range.min !== typeof range.max) {
|
|
54
|
+
throw new TypeError("Filter range endpoints must use one type.");
|
|
55
55
|
}
|
|
56
|
-
if (range.min > range.max) {
|
|
56
|
+
if (hasMin && hasMax && range.min > range.max) {
|
|
57
57
|
throw new RangeError("Filter range min must not exceed max.");
|
|
58
58
|
}
|
|
59
59
|
if (range.inclusive !== undefined && typeof range.inclusive !== "boolean") {
|
|
60
60
|
throw new TypeError("Filter range inclusive must be a boolean.");
|
|
61
61
|
}
|
|
62
|
+
if (range.inclusive !== undefined && (
|
|
63
|
+
range.minInclusive !== undefined || range.maxInclusive !== undefined
|
|
64
|
+
)) throw new Error("Filter range cannot combine inclusive with endpoint inclusivity.");
|
|
65
|
+
for (const [key, endpoint] of [["minInclusive", "min"], ["maxInclusive", "max"]]) {
|
|
66
|
+
if (range[key] !== undefined && typeof range[key] !== "boolean") {
|
|
67
|
+
throw new TypeError(`Filter range ${key} must be a boolean.`);
|
|
68
|
+
}
|
|
69
|
+
if (range[key] !== undefined && !Object.hasOwn(range, endpoint)) {
|
|
70
|
+
throw new Error(`Filter range ${key} requires ${endpoint}.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeRange(range) {
|
|
76
|
+
validateRange(range);
|
|
77
|
+
const shared = range.inclusive;
|
|
78
|
+
return {
|
|
79
|
+
...(Object.hasOwn(range, "min") ? { min: range.min, minInclusive: shared ?? range.minInclusive ?? true } : {}),
|
|
80
|
+
...(Object.hasOwn(range, "max") ? { max: range.max, maxInclusive: shared ?? range.maxInclusive ?? true } : {})
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeSet(values, name) {
|
|
85
|
+
if (!Array.isArray(values) || values.length === 0 ||
|
|
86
|
+
Array.from({ length: values.length }, (_, index) => index).some(index => !Object.hasOwn(values, index)) ||
|
|
87
|
+
values.some(value => value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value)))) {
|
|
88
|
+
throw new TypeError(`Filter ${name} must be a non-empty dense array of scalar values.`);
|
|
89
|
+
}
|
|
90
|
+
return [...new Set(values)];
|
|
62
91
|
}
|
|
63
92
|
|
|
64
93
|
export function validateFilterTransform(transform) {
|
|
65
94
|
if (!isPlainObject(transform)) {
|
|
66
95
|
throw new TypeError("Filter transform must be a plain object.");
|
|
67
96
|
}
|
|
68
|
-
const supported = ["type", "field", "oneOf", "predicate", "range"];
|
|
97
|
+
const supported = ["type", "field", "oneOf", "noneOf", "predicate", "range", "nulls"];
|
|
69
98
|
const unknown = Object.keys(transform).find(key => !supported.includes(key));
|
|
70
99
|
if (unknown !== undefined) {
|
|
71
100
|
throw new Error(`Unknown filter transform property "${unknown}".`);
|
|
@@ -73,12 +102,15 @@ export function validateFilterTransform(transform) {
|
|
|
73
102
|
if (typeof transform.field !== "string" || transform.field.length === 0) {
|
|
74
103
|
throw new TypeError("Filter field must be a non-empty field string.");
|
|
75
104
|
}
|
|
76
|
-
|
|
105
|
+
if (transform.nulls !== undefined && !["include", "exclude"].includes(transform.nulls)) {
|
|
106
|
+
throw new Error('Filter nulls must be "include" or "exclude".');
|
|
107
|
+
}
|
|
108
|
+
const modes = ["oneOf", "noneOf", "predicate", "range"].filter(
|
|
77
109
|
key => Object.hasOwn(transform, key)
|
|
78
110
|
);
|
|
79
111
|
if (modes.length !== 1) {
|
|
80
112
|
throw new Error(
|
|
81
|
-
"Filter transform requires exactly one of oneOf, predicate, or range."
|
|
113
|
+
"Filter transform requires exactly one of oneOf, noneOf, predicate, or range."
|
|
82
114
|
);
|
|
83
115
|
}
|
|
84
116
|
if (modes[0] === "predicate") {
|
|
@@ -89,46 +121,101 @@ export function validateFilterTransform(transform) {
|
|
|
89
121
|
validateRange(transform.range);
|
|
90
122
|
return;
|
|
91
123
|
}
|
|
92
|
-
|
|
93
|
-
!Array.isArray(transform.oneOf) ||
|
|
94
|
-
transform.oneOf.length === 0 ||
|
|
95
|
-
transform.oneOf.some(value =>
|
|
96
|
-
value !== null &&
|
|
97
|
-
typeof value !== "string" &&
|
|
98
|
-
typeof value !== "boolean" &&
|
|
99
|
-
!(typeof value === "number" && Number.isFinite(value))
|
|
100
|
-
)
|
|
101
|
-
) {
|
|
102
|
-
throw new TypeError("Filter oneOf must be a non-empty array of scalar values.");
|
|
103
|
-
}
|
|
124
|
+
normalizeSet(transform[modes[0]], modes[0]);
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
export function normalizeFilterTransform({
|
|
107
128
|
field,
|
|
108
129
|
oneOf,
|
|
130
|
+
noneOf,
|
|
109
131
|
predicate,
|
|
110
|
-
range
|
|
132
|
+
range,
|
|
133
|
+
nulls
|
|
111
134
|
}) {
|
|
112
|
-
const modes = { oneOf, predicate, range };
|
|
135
|
+
const modes = { oneOf, noneOf, predicate, range };
|
|
113
136
|
const selected = Object.keys(modes).filter(key => modes[key] !== undefined);
|
|
114
137
|
const transform = {
|
|
115
138
|
type: "filter",
|
|
116
139
|
field,
|
|
117
|
-
...(selected[0] === "oneOf" ? { oneOf } : {}),
|
|
140
|
+
...(selected[0] === "oneOf" ? { oneOf: normalizeSet(oneOf, "oneOf") } : {}),
|
|
141
|
+
...(selected[0] === "noneOf" ? { noneOf: normalizeSet(noneOf, "noneOf") } : {}),
|
|
118
142
|
...(selected[0] === "predicate" ? { predicate } : {}),
|
|
119
143
|
...(selected[0] === "range"
|
|
120
|
-
? { range:
|
|
121
|
-
: {})
|
|
144
|
+
? { range: normalizeRange(range) }
|
|
145
|
+
: {}),
|
|
146
|
+
...(nulls === undefined ? {} : { nulls })
|
|
122
147
|
};
|
|
123
148
|
if (selected.length !== 1) {
|
|
124
149
|
throw new Error(
|
|
125
|
-
"filterData requires exactly one of oneOf, predicate, or range."
|
|
150
|
+
"filterData requires exactly one of oneOf, noneOf, predicate, or range."
|
|
126
151
|
);
|
|
127
152
|
}
|
|
128
153
|
validateFilterTransform(transform);
|
|
129
154
|
return cloneAndFreeze(transform);
|
|
130
155
|
}
|
|
131
156
|
|
|
157
|
+
export function normalizeFilterTransformEdit(transform, patch) {
|
|
158
|
+
const modes = ["oneOf", "noneOf", "predicate", "range"];
|
|
159
|
+
const previousMode = modes.find(key => Object.hasOwn(transform, key));
|
|
160
|
+
const requestedModes = modes.filter(key => Object.hasOwn(patch, key));
|
|
161
|
+
if (requestedModes.length > 1) {
|
|
162
|
+
throw new Error("Filter edit can replace only one filter mode.");
|
|
163
|
+
}
|
|
164
|
+
const fieldChanged = Object.hasOwn(patch, "field") && patch.field !== transform.field;
|
|
165
|
+
if (fieldChanged && requestedModes.length === 0) {
|
|
166
|
+
throw new Error("Changing a filter field requires a complete filter mode.");
|
|
167
|
+
}
|
|
168
|
+
let mode = previousMode;
|
|
169
|
+
let modeValue = transform[previousMode];
|
|
170
|
+
if (requestedModes.length === 1) {
|
|
171
|
+
mode = requestedModes[0];
|
|
172
|
+
if (mode === "range" && previousMode === "range" && !fieldChanged) {
|
|
173
|
+
if (!isPlainObject(patch.range)) throw new TypeError("Filter range patch must be a plain object.");
|
|
174
|
+
const legacy = normalizeRange(transform.range);
|
|
175
|
+
const next = { ...legacy };
|
|
176
|
+
const rangePatch = patch.range;
|
|
177
|
+
const unknown = Object.keys(rangePatch).find(key => !["min", "max", "inclusive", "minInclusive", "maxInclusive"].includes(key));
|
|
178
|
+
if (unknown !== undefined) throw new Error(`Unknown filter range property "${unknown}".`);
|
|
179
|
+
if (rangePatch.inclusive !== undefined && (rangePatch.minInclusive !== undefined || rangePatch.maxInclusive !== undefined)) {
|
|
180
|
+
throw new Error("Filter range cannot combine inclusive with endpoint inclusivity.");
|
|
181
|
+
}
|
|
182
|
+
for (const endpoint of ["min", "max"]) {
|
|
183
|
+
if (!Object.hasOwn(rangePatch, endpoint)) continue;
|
|
184
|
+
if (rangePatch[endpoint] === false) {
|
|
185
|
+
delete next[endpoint];
|
|
186
|
+
delete next[`${endpoint}Inclusive`];
|
|
187
|
+
} else {
|
|
188
|
+
next[endpoint] = rangePatch[endpoint];
|
|
189
|
+
next[`${endpoint}Inclusive`] = Object.hasOwn(legacy, endpoint)
|
|
190
|
+
? legacy[`${endpoint}Inclusive`]
|
|
191
|
+
: true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (rangePatch.inclusive !== undefined) {
|
|
195
|
+
if (typeof rangePatch.inclusive !== "boolean") throw new TypeError("Filter range inclusive must be a boolean.");
|
|
196
|
+
if (Object.hasOwn(next, "min")) next.minInclusive = rangePatch.inclusive;
|
|
197
|
+
if (Object.hasOwn(next, "max")) next.maxInclusive = rangePatch.inclusive;
|
|
198
|
+
}
|
|
199
|
+
for (const endpoint of ["min", "max"]) {
|
|
200
|
+
const key = `${endpoint}Inclusive`;
|
|
201
|
+
if (rangePatch[key] !== undefined) {
|
|
202
|
+
if (!Object.hasOwn(next, endpoint)) throw new Error(`Filter range ${key} requires ${endpoint}.`);
|
|
203
|
+
next[key] = rangePatch[key];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
modeValue = next;
|
|
207
|
+
} else {
|
|
208
|
+
modeValue = patch[mode];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const nulls = patch.nulls === false ? undefined : (patch.nulls ?? transform.nulls);
|
|
212
|
+
return normalizeFilterTransform({
|
|
213
|
+
field: patch.field ?? transform.field,
|
|
214
|
+
[mode]: modeValue,
|
|
215
|
+
...(nulls === undefined ? {} : { nulls })
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
132
219
|
function comparable(value, operand) {
|
|
133
220
|
return (
|
|
134
221
|
Number.isFinite(value) && Number.isFinite(operand)
|
|
@@ -141,6 +228,7 @@ function matches(value, transform) {
|
|
|
141
228
|
if (transform.oneOf !== undefined) {
|
|
142
229
|
return transform.oneOf.includes(value);
|
|
143
230
|
}
|
|
231
|
+
if (transform.noneOf !== undefined) return !transform.noneOf.includes(value);
|
|
144
232
|
if (transform.predicate !== undefined) {
|
|
145
233
|
const { op, value: operand } = transform.predicate;
|
|
146
234
|
if (op === "eq") return value === operand;
|
|
@@ -151,11 +239,12 @@ function matches(value, transform) {
|
|
|
151
239
|
if (op === "gt") return value > operand;
|
|
152
240
|
return value >= operand;
|
|
153
241
|
}
|
|
154
|
-
const { min, max, inclusive = true } = transform.range;
|
|
155
|
-
if (
|
|
156
|
-
return
|
|
157
|
-
|
|
158
|
-
|
|
242
|
+
const { min, max, minInclusive = transform.range.inclusive ?? true, maxInclusive = transform.range.inclusive ?? true } = transform.range;
|
|
243
|
+
if (min !== undefined && !comparable(value, min)) return false;
|
|
244
|
+
if (max !== undefined && !comparable(value, max)) return false;
|
|
245
|
+
if (min !== undefined && (value < min || (value === min && !minInclusive))) return false;
|
|
246
|
+
if (max !== undefined && (value > max || (value === max && !maxInclusive))) return false;
|
|
247
|
+
return true;
|
|
159
248
|
}
|
|
160
249
|
|
|
161
250
|
export function deriveFilteredRows(values, transform) {
|
|
@@ -163,7 +252,12 @@ export function deriveFilteredRows(values, transform) {
|
|
|
163
252
|
throw new TypeError("Filter source values must be an array.");
|
|
164
253
|
}
|
|
165
254
|
validateFilterTransform(transform);
|
|
166
|
-
return values.filter(row =>
|
|
167
|
-
isPlainObject(row)
|
|
168
|
-
|
|
255
|
+
return values.filter(row => {
|
|
256
|
+
if (!isPlainObject(row)) return false;
|
|
257
|
+
const value = row[transform.field];
|
|
258
|
+
if (value === null || value === undefined) {
|
|
259
|
+
if (transform.nulls !== undefined) return transform.nulls === "include";
|
|
260
|
+
}
|
|
261
|
+
return matches(value, transform);
|
|
262
|
+
});
|
|
169
263
|
}
|
package/src/grammar/interval.js
CHANGED
|
@@ -16,6 +16,7 @@ const TRANSFORM_KEYS = [
|
|
|
16
16
|
"extent",
|
|
17
17
|
"method",
|
|
18
18
|
"level",
|
|
19
|
+
"missing",
|
|
19
20
|
"as"
|
|
20
21
|
];
|
|
21
22
|
const OUTPUT_KEYS = ["center", "lower", "upper"];
|
|
@@ -82,6 +83,9 @@ export function validateIntervalTransform(transform) {
|
|
|
82
83
|
if (!EXTENT_VALUES.includes(transform.extent)) {
|
|
83
84
|
throw new Error(`Unsupported interval extent "${transform.extent}".`);
|
|
84
85
|
}
|
|
86
|
+
if (transform.missing !== undefined && !["error", "drop"].includes(transform.missing)) {
|
|
87
|
+
throw new Error('Interval missing must be "error" or "drop".');
|
|
88
|
+
}
|
|
85
89
|
if (
|
|
86
90
|
(transform.center === "median") !== (transform.extent === "iqr")
|
|
87
91
|
) {
|
|
@@ -201,15 +205,24 @@ export function deriveInterval(rows, transform) {
|
|
|
201
205
|
}
|
|
202
206
|
validateIntervalTransform(transform);
|
|
203
207
|
const groups = new Map();
|
|
208
|
+
let missingRows = 0;
|
|
204
209
|
for (const row of rows) {
|
|
205
210
|
if (!isPlainObject(row)) continue;
|
|
206
211
|
const value = row[transform.field];
|
|
207
212
|
if (isMissing(value) || !Number.isFinite(value)) {
|
|
213
|
+
const nullish = value === undefined || value === null;
|
|
214
|
+
if (transform.missing === "error" && nullish) {
|
|
215
|
+
throw new TypeError(`Interval field "${transform.field}" is missing.`);
|
|
216
|
+
}
|
|
217
|
+
if (transform.missing !== undefined && !nullish) {
|
|
218
|
+
throw new TypeError(`Interval field "${transform.field}" must contain finite numbers.`);
|
|
219
|
+
}
|
|
208
220
|
if (value !== undefined && value !== null && typeof value !== "number") {
|
|
209
221
|
throw new TypeError(
|
|
210
222
|
`Interval field "${transform.field}" must contain numeric or missing values.`
|
|
211
223
|
);
|
|
212
224
|
}
|
|
225
|
+
if (nullish) missingRows += 1;
|
|
213
226
|
continue;
|
|
214
227
|
}
|
|
215
228
|
const groupValues = transform.groupBy.map(field => row[field]);
|
|
@@ -240,7 +253,23 @@ export function deriveInterval(rows, transform) {
|
|
|
240
253
|
[transform.as.upper]: stableDecimal(result.upper)
|
|
241
254
|
});
|
|
242
255
|
}
|
|
243
|
-
return cloneAndFreeze(output);
|
|
256
|
+
if (transform.missing === undefined) return cloneAndFreeze(output);
|
|
257
|
+
return cloneAndFreeze({
|
|
258
|
+
values: output,
|
|
259
|
+
report: {
|
|
260
|
+
version: 1,
|
|
261
|
+
owner: { kind: "data", id: "pending" },
|
|
262
|
+
inputs: [],
|
|
263
|
+
units: [{
|
|
264
|
+
role: transform.as.center,
|
|
265
|
+
group: {},
|
|
266
|
+
inputRows: rows.length,
|
|
267
|
+
usedRows: rows.length - missingRows,
|
|
268
|
+
excludedRows: missingRows,
|
|
269
|
+
excludedByReason: missingRows === 0 ? {} : { "missing-value": missingRows }
|
|
270
|
+
}]
|
|
271
|
+
}
|
|
272
|
+
});
|
|
244
273
|
}
|
|
245
274
|
|
|
246
275
|
export function normalizeIntervalTransform({
|
|
@@ -250,6 +279,7 @@ export function normalizeIntervalTransform({
|
|
|
250
279
|
extent,
|
|
251
280
|
method,
|
|
252
281
|
level,
|
|
282
|
+
missing,
|
|
253
283
|
as
|
|
254
284
|
}) {
|
|
255
285
|
nonEmptyString(field, "Interval field");
|
|
@@ -260,6 +290,7 @@ export function normalizeIntervalTransform({
|
|
|
260
290
|
field,
|
|
261
291
|
groupBy: grouping,
|
|
262
292
|
...parameters,
|
|
293
|
+
...(missing === undefined ? {} : { missing }),
|
|
263
294
|
as
|
|
264
295
|
};
|
|
265
296
|
validateIntervalTransform(transform);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { cloneAndFreeze } from "../core/immutable.js";
|
|
2
|
+
|
|
3
|
+
export const ITEM_MISSING_MARK_TYPES = Object.freeze([
|
|
4
|
+
"point", "bar", "rect", "tick", "rule", "text"
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
export function validateItemMissing(value, label = "Mark missing") {
|
|
8
|
+
if (!["error", "skip"].includes(value)) {
|
|
9
|
+
throw new Error(`${label} must be "error" or "skip".`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function collectFields(value, fields, seen) {
|
|
15
|
+
if (value === null || typeof value !== "object" || seen.has(value)) return;
|
|
16
|
+
seen.add(value);
|
|
17
|
+
if (typeof value.field === "string" && value.field.length > 0) {
|
|
18
|
+
fields.add(value.field);
|
|
19
|
+
}
|
|
20
|
+
for (const child of Object.values(value)) collectFields(child, fields, seen);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function itemMissingFields(layer) {
|
|
24
|
+
const fields = new Set();
|
|
25
|
+
collectFields(layer.encoding ?? {}, fields, new Set());
|
|
26
|
+
return [...fields];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyItemMissingPolicy(layer, dataset) {
|
|
30
|
+
const policy = layer.mark?.missing;
|
|
31
|
+
if (policy === undefined) return dataset;
|
|
32
|
+
if (!ITEM_MISSING_MARK_TYPES.includes(layer.mark?.type)) return dataset;
|
|
33
|
+
validateItemMissing(policy, `${layer.mark.type} missing`);
|
|
34
|
+
const fields = itemMissingFields(layer);
|
|
35
|
+
if (fields.length === 0) return dataset;
|
|
36
|
+
const eligible = [];
|
|
37
|
+
for (const [index, row] of dataset.values.entries()) {
|
|
38
|
+
const missing = fields.find(field => row[field] === null || row[field] === undefined);
|
|
39
|
+
if (missing === undefined) {
|
|
40
|
+
eligible.push(row);
|
|
41
|
+
} else if (policy === "error") {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`${layer.mark.type} mark "${layer.id}" field "${missing}" is missing at row ${index}.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (eligible.length === dataset.values.length) return dataset;
|
|
48
|
+
return cloneAndFreeze({ ...dataset, values: eligible });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function applyRequestedItemMissingPolicy(layer, dataset, field) {
|
|
52
|
+
if (typeof field !== "string" || field.length === 0) {
|
|
53
|
+
return applyItemMissingPolicy(layer, dataset);
|
|
54
|
+
}
|
|
55
|
+
return applyItemMissingPolicy({
|
|
56
|
+
...layer,
|
|
57
|
+
encoding: { ...layer.encoding, __requested: { field } }
|
|
58
|
+
}, dataset);
|
|
59
|
+
}
|
|
@@ -27,7 +27,9 @@ export function deriveRegression(values, {
|
|
|
27
27
|
confidenceMethod,
|
|
28
28
|
level,
|
|
29
29
|
confidence,
|
|
30
|
-
interval
|
|
30
|
+
interval,
|
|
31
|
+
predict,
|
|
32
|
+
missing
|
|
31
33
|
} = {}) {
|
|
32
34
|
if (!Array.isArray(values)) {
|
|
33
35
|
throw new TypeError("Regression values must be an array.");
|
|
@@ -46,15 +48,29 @@ export function deriveRegression(values, {
|
|
|
46
48
|
confidence,
|
|
47
49
|
interval
|
|
48
50
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
let missingRows = 0;
|
|
52
|
+
const eligibleValues = missing === undefined ? values : values.filter((row, index) => {
|
|
53
|
+
const xValue = row?.[x];
|
|
54
|
+
const yValue = row?.[y];
|
|
55
|
+
if (xValue === null || xValue === undefined || yValue === null || yValue === undefined) {
|
|
56
|
+
if (missing === "error") throw new TypeError(`Regression x/y pair is missing at row ${index}.`);
|
|
57
|
+
missingRows += 1;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isFinite(xValue) || !Number.isFinite(yValue)) {
|
|
61
|
+
throw new TypeError(`Regression x/y fields must contain finite numbers at row ${index}.`);
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
});
|
|
65
|
+
readQuantitativeField(eligibleValues, x);
|
|
66
|
+
readQuantitativeField(eligibleValues, y);
|
|
67
|
+
if (groupBy !== undefined) readNominalField(eligibleValues, groupBy);
|
|
52
68
|
|
|
53
69
|
const groupedRows = new Map();
|
|
54
70
|
if (groupBy === undefined) {
|
|
55
|
-
groupedRows.set(undefined,
|
|
71
|
+
groupedRows.set(undefined, eligibleValues);
|
|
56
72
|
} else {
|
|
57
|
-
for (const row of
|
|
73
|
+
for (const row of eligibleValues) {
|
|
58
74
|
const group = row[groupBy];
|
|
59
75
|
const rows = groupedRows.get(group) ?? [];
|
|
60
76
|
rows.push(row);
|
|
@@ -68,13 +84,25 @@ export function deriveRegression(values, {
|
|
|
68
84
|
|
|
69
85
|
for (const group of groups) {
|
|
70
86
|
const groupRows = groupedRows.get(group);
|
|
71
|
-
const
|
|
72
|
-
|
|
87
|
+
const observedX = [...new Set(groupRows.map(row => row[x]))]
|
|
88
|
+
.sort((left, right) => left - right);
|
|
89
|
+
const xValues = predict?.values ?? (predict?.domain === undefined
|
|
90
|
+
? observedX
|
|
91
|
+
: Array.from({ length: predict.steps }, (_, index) => {
|
|
92
|
+
if (index === 0) return predict.domain[0];
|
|
93
|
+
if (index === predict.steps - 1) return predict.domain[1];
|
|
94
|
+
const ratio = index / (predict.steps - 1);
|
|
95
|
+
return requireFiniteResult(
|
|
96
|
+
predict.domain[0] * (1 - ratio) + predict.domain[1] * ratio,
|
|
97
|
+
"Regression prediction grid"
|
|
98
|
+
);
|
|
99
|
+
}));
|
|
100
|
+
validateGeneratedItemLimit(rows.length + xValues.length, "Regression generated row count");
|
|
73
101
|
if (parameters.method === "polynomial") {
|
|
74
102
|
const size = parameters.degree + 1;
|
|
75
103
|
work += groupRows.length * size ** 2 + size ** 3;
|
|
76
104
|
} else if (parameters.method === "loess") {
|
|
77
|
-
work += groupRows.length *
|
|
105
|
+
work += groupRows.length * xValues.length *
|
|
78
106
|
Math.ceil(Math.log2(groupRows.length + 1));
|
|
79
107
|
}
|
|
80
108
|
validateWorkLimit(work, "Regression computation");
|
|
@@ -84,8 +112,6 @@ export function deriveRegression(values, {
|
|
|
84
112
|
group,
|
|
85
113
|
parameters
|
|
86
114
|
});
|
|
87
|
-
const xValues = [...new Set(groupRows.map(row => row[x]))]
|
|
88
|
-
.sort((left, right) => left - right);
|
|
89
115
|
models.push({ ...(groupBy === undefined ? {} : { group }), ...model, xValues });
|
|
90
116
|
|
|
91
117
|
for (const xValue of xValues) {
|
|
@@ -94,13 +120,13 @@ export function deriveRegression(values, {
|
|
|
94
120
|
prediction.prediction,
|
|
95
121
|
`Regression group "${group === undefined ? "all" : String(group)}" prediction`
|
|
96
122
|
);
|
|
97
|
-
const lower = parameters.method === "loess"
|
|
123
|
+
const lower = parameters.method === "loess" || parameters.interval === false
|
|
98
124
|
? undefined
|
|
99
125
|
: requireFiniteResult(
|
|
100
126
|
fitted - prediction.margin,
|
|
101
127
|
`Regression group "${group === undefined ? "all" : String(group)}" lower interval`
|
|
102
128
|
);
|
|
103
|
-
const upper = parameters.method === "loess"
|
|
129
|
+
const upper = parameters.method === "loess" || parameters.interval === false
|
|
104
130
|
? undefined
|
|
105
131
|
: requireFiniteResult(
|
|
106
132
|
fitted + prediction.margin,
|
|
@@ -110,7 +136,7 @@ export function deriveRegression(values, {
|
|
|
110
136
|
...(groupBy === undefined ? {} : { [groupBy]: group }),
|
|
111
137
|
[x]: xValue,
|
|
112
138
|
[y]: fitted,
|
|
113
|
-
...(parameters.method === "loess" ? {} : {
|
|
139
|
+
...(parameters.method === "loess" || parameters.interval === false ? {} : {
|
|
114
140
|
[REGRESSION_LOWER_FIELD]: lower,
|
|
115
141
|
[REGRESSION_UPPER_FIELD]: upper
|
|
116
142
|
})
|
|
@@ -123,7 +149,7 @@ export function deriveRegression(values, {
|
|
|
123
149
|
x,
|
|
124
150
|
y,
|
|
125
151
|
...(groupBy === undefined ? {} : { group: groupBy }),
|
|
126
|
-
...(parameters.method === "loess" ? {} : {
|
|
152
|
+
...(parameters.method === "loess" || parameters.interval === false ? {} : {
|
|
127
153
|
lower: REGRESSION_LOWER_FIELD,
|
|
128
154
|
upper: REGRESSION_UPPER_FIELD
|
|
129
155
|
})
|
|
@@ -131,7 +157,22 @@ export function deriveRegression(values, {
|
|
|
131
157
|
parameters,
|
|
132
158
|
groups,
|
|
133
159
|
models,
|
|
134
|
-
values: rows
|
|
160
|
+
values: rows,
|
|
161
|
+
...(missing === undefined ? {} : {
|
|
162
|
+
report: {
|
|
163
|
+
version: 1,
|
|
164
|
+
owner: { kind: "data", id: "pending" },
|
|
165
|
+
inputs: [],
|
|
166
|
+
units: [{
|
|
167
|
+
role: "fit",
|
|
168
|
+
group: {},
|
|
169
|
+
inputRows: values.length,
|
|
170
|
+
usedRows: eligibleValues.length,
|
|
171
|
+
excludedRows: missingRows,
|
|
172
|
+
excludedByReason: missingRows === 0 ? {} : { "missing-value": missingRows }
|
|
173
|
+
}]
|
|
174
|
+
}
|
|
175
|
+
})
|
|
135
176
|
});
|
|
136
177
|
}
|
|
137
178
|
|