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.
Files changed (83) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -1
  3. package/knowledge/action-cards.json +1 -1
  4. package/knowledge/intent-taxonomy.json +1 -1
  5. package/knowledge/mcp-resources.json +1 -1
  6. package/package.json +8 -2
  7. package/src/actions/data/bin.js +1 -1
  8. package/src/actions/data/create.js +18 -5
  9. package/src/actions/data/density.js +1 -1
  10. package/src/actions/data/derived.js +15 -1
  11. package/src/actions/data/edit.js +19 -6
  12. package/src/actions/data/filter.js +15 -4
  13. package/src/actions/data/index.js +3 -0
  14. package/src/actions/data/interval.js +2 -0
  15. package/src/actions/data/regression.js +4 -1
  16. package/src/actions/data/revise.js +37 -5
  17. package/src/actions/data/shared.js +30 -2
  18. package/src/actions/data/sort.js +21 -0
  19. package/src/actions/data/summary.js +1 -1
  20. package/src/actions/encodings/angle.js +4 -1
  21. package/src/actions/encodings/appearance.js +6 -2
  22. package/src/actions/encodings/color/continuous.js +4 -1
  23. package/src/actions/encodings/color/index.js +4 -1
  24. package/src/actions/encodings/offset.js +4 -1
  25. package/src/actions/encodings/position/resolve.js +6 -1
  26. package/src/actions/encodings/ranged.js +6 -1
  27. package/src/actions/encodings/ruleAppearance.js +4 -1
  28. package/src/actions/encodings/stroke.js +4 -1
  29. package/src/actions/encodings/strokeDash.js +6 -1
  30. package/src/actions/encodings/text.js +6 -1
  31. package/src/actions/marks/bar/create.js +8 -1
  32. package/src/actions/marks/bar/edit.js +12 -5
  33. package/src/actions/marks/point/create.js +8 -1
  34. package/src/actions/marks/point/edit.js +12 -5
  35. package/src/actions/marks/point/materialize.js +4 -2
  36. package/src/actions/marks/rect/actions.js +23 -8
  37. package/src/actions/marks/rule/actions.js +24 -8
  38. package/src/actions/marks/text/actions.js +25 -6
  39. package/src/actions/marks/tick/actions.js +23 -8
  40. package/src/actions/primitives/semanticAction.js +37 -3
  41. package/src/actions/primitives/semanticValidation/dataset.js +13 -1
  42. package/src/actions/primitives/semanticValidation/layer.js +22 -2
  43. package/src/actions/primitives/semanticValidation/scale.js +6 -0
  44. package/src/actions/regression/create.js +25 -4
  45. package/src/actions/regression/edit.js +47 -12
  46. package/src/actions/regression/follow.js +22 -0
  47. package/src/actions/scales/channels.js +11 -11
  48. package/src/actions/scales/consumers/common.js +2 -1
  49. package/src/actions/scales/create.js +2 -1
  50. package/src/actions/scales/definitions.js +1 -1
  51. package/src/actions/scales/edit.js +1 -1
  52. package/src/actions/scales/parallel.js +1 -1
  53. package/src/actions/scales/patch.js +1 -1
  54. package/src/actions/scales/preview.js +28 -1
  55. package/src/actions/theme/reconcile.js +2 -0
  56. package/src/core/action.js +3 -2
  57. package/src/core/programState.js +5 -1
  58. package/src/grammar/bin.js +39 -10
  59. package/src/grammar/datasetSchema.js +386 -0
  60. package/src/grammar/density.js +53 -5
  61. package/src/grammar/filter.js +133 -39
  62. package/src/grammar/interval.js +32 -1
  63. package/src/grammar/itemMissing.js +59 -0
  64. package/src/grammar/regression/derive.js +57 -16
  65. package/src/grammar/regression/index.js +1 -0
  66. package/src/grammar/regression/models.js +51 -22
  67. package/src/grammar/regression/parameters.js +56 -4
  68. package/src/grammar/scales/definition.js +7 -0
  69. package/src/grammar/schemas/semanticPath.js +3 -2
  70. package/src/grammar/sort.js +88 -0
  71. package/src/grammar/summary.js +117 -19
  72. package/src/grammar/transformTopology.js +1 -0
  73. package/src/grammar/transforms.js +21 -11
  74. package/src/inspection.js +495 -0
  75. package/src/inspectionDescriptors.js +3 -0
  76. package/src/materialization/bars/resolve.js +4 -2
  77. package/src/materialization/revisionIdentity.js +19 -0
  78. package/src/materialization/scales/resolve.js +67 -4
  79. package/src/materialization/text.js +4 -2
  80. package/src/persistence.js +79 -6
  81. package/src/version.js +1 -1
  82. package/types/inspection.d.ts +17 -0
  83. package/types/program.d.ts +147 -27
@@ -0,0 +1,386 @@
1
+ import { annotateError } from "../core/diagnostics.js";
2
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
3
+
4
+ const STORAGE_TYPES = new Set([
5
+ "number", "string", "boolean", "array", "object", "unknown", "mixed"
6
+ ]);
7
+ const DECLARED_STORAGE_TYPES = new Set([
8
+ "number", "string", "boolean", "array", "object"
9
+ ]);
10
+ const FIELD_KEYS = Object.freeze([
11
+ "name", "storageType", "nullable", "optional", "lineage"
12
+ ]);
13
+ const SOURCE_FIELD_KEYS = Object.freeze([
14
+ "name", "storageType", "nullable", "optional"
15
+ ]);
16
+
17
+ function rejectUnknownKeys(value, keys, label) {
18
+ const unknown = Object.keys(value).find(key => !keys.includes(key));
19
+ if (unknown !== undefined) throw new Error(`Unknown ${label} property "${unknown}".`);
20
+ }
21
+
22
+ function requireName(value, label) {
23
+ if (typeof value !== "string" || value.length === 0) {
24
+ throw new TypeError(`${label} must be a non-empty string.`);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ function valueStorageType(value) {
30
+ if (typeof value === "number") return "number";
31
+ if (typeof value === "string") return "string";
32
+ if (typeof value === "boolean") return "boolean";
33
+ if (Array.isArray(value)) return "array";
34
+ if (isPlainObject(value)) return "object";
35
+ return "unknown";
36
+ }
37
+
38
+ function normalizeLineage(value, label) {
39
+ if (!isPlainObject(value)) throw new TypeError(`${label} must be a plain object.`);
40
+ rejectUnknownKeys(value, ["inputs", "owner", "role"], label);
41
+ if (!Array.isArray(value.inputs)) throw new TypeError(`${label} inputs must be an array.`);
42
+ const inputs = value.inputs.map((input, index) => {
43
+ if (!isPlainObject(input)) throw new TypeError(`${label} input ${index} must be a plain object.`);
44
+ rejectUnknownKeys(input, ["data", "field"], `${label} input ${index}`);
45
+ return {
46
+ data: requireName(input.data, `${label} input ${index} data`),
47
+ field: requireName(input.field, `${label} input ${index} field`)
48
+ };
49
+ });
50
+ return {
51
+ inputs,
52
+ owner: requireName(value.owner, `${label} owner`),
53
+ role: requireName(value.role, `${label} role`)
54
+ };
55
+ }
56
+
57
+ function normalizeField(value, index, { declared = false } = {}) {
58
+ if (!isPlainObject(value)) throw new TypeError(`Dataset schema field ${index} must be a plain object.`);
59
+ rejectUnknownKeys(
60
+ value,
61
+ declared ? SOURCE_FIELD_KEYS : FIELD_KEYS,
62
+ `dataset schema field ${index}`
63
+ );
64
+ const name = requireName(value.name, `Dataset schema field ${index} name`);
65
+ const allowed = declared ? DECLARED_STORAGE_TYPES : STORAGE_TYPES;
66
+ if (!allowed.has(value.storageType)) {
67
+ throw new Error(`Unsupported dataset schema storageType "${value.storageType}".`);
68
+ }
69
+ for (const key of ["nullable", "optional"]) {
70
+ if (value[key] !== undefined && typeof value[key] !== "boolean") {
71
+ throw new TypeError(`Dataset schema field ${index} ${key} must be a boolean.`);
72
+ }
73
+ }
74
+ return {
75
+ name,
76
+ storageType: value.storageType,
77
+ nullable: value.nullable ?? false,
78
+ optional: value.optional ?? false,
79
+ ...(value.lineage === undefined
80
+ ? {}
81
+ : { lineage: normalizeLineage(value.lineage, `Dataset schema field ${index} lineage`) })
82
+ };
83
+ }
84
+
85
+ export function normalizeSourceSchema(value) {
86
+ if (!isPlainObject(value)) throw new TypeError("Dataset schema must be a plain object.");
87
+ rejectUnknownKeys(value, ["fields"], "dataset schema");
88
+ if (!Array.isArray(value.fields)) throw new TypeError("Dataset schema fields must be an array.");
89
+ const fields = value.fields.map((field, index) => normalizeField(field, index, { declared: true }));
90
+ if (new Set(fields.map(field => field.name)).size !== fields.length) {
91
+ throw new Error("Dataset schema field names must be unique.");
92
+ }
93
+ return cloneAndFreeze({
94
+ version: 1,
95
+ completeness: "known",
96
+ origin: "declared",
97
+ fields
98
+ });
99
+ }
100
+
101
+ export function validateDatasetSchema(value) {
102
+ if (!isPlainObject(value)) throw new TypeError("Dataset schema must be a plain object.");
103
+ rejectUnknownKeys(value, ["version", "completeness", "origin", "fields"], "dataset schema");
104
+ if (value.version !== 1) throw new Error(`Unsupported dataset schema version "${value.version}".`);
105
+ if (!["known", "unknown"].includes(value.completeness)) {
106
+ throw new Error(`Unsupported dataset schema completeness "${value.completeness}".`);
107
+ }
108
+ if (!["declared", "inferred", "derived"].includes(value.origin)) {
109
+ throw new Error(`Unsupported dataset schema origin "${value.origin}".`);
110
+ }
111
+ if (!Array.isArray(value.fields)) throw new TypeError("Dataset schema fields must be an array.");
112
+ const fields = value.fields.map((field, index) => normalizeField(field, index));
113
+ if (new Set(fields.map(field => field.name)).size !== fields.length) {
114
+ throw new Error("Dataset schema field names must be unique.");
115
+ }
116
+ if (value.completeness === "unknown" && fields.length > 0) {
117
+ throw new Error("Unknown dataset schema cannot contain known fields.");
118
+ }
119
+ return cloneAndFreeze({
120
+ version: 1,
121
+ completeness: value.completeness,
122
+ origin: value.origin,
123
+ fields
124
+ });
125
+ }
126
+
127
+ export function inferDatasetSchema(values, { origin = "inferred" } = {}) {
128
+ if (!Array.isArray(values)) throw new TypeError("Dataset schema inference requires row values.");
129
+ if (values.length === 0) {
130
+ return cloneAndFreeze({ version: 1, completeness: "unknown", origin, fields: [] });
131
+ }
132
+ const records = new Map();
133
+ for (const row of values) {
134
+ for (const name of Object.keys(row)) {
135
+ if (!records.has(name)) records.set(name, { types: new Set(), nullable: false, present: 0 });
136
+ const record = records.get(name);
137
+ const value = row[name];
138
+ if (value !== undefined) record.present += 1;
139
+ if (value === null) record.nullable = true;
140
+ else if (value !== undefined) record.types.add(valueStorageType(value));
141
+ }
142
+ }
143
+ const fields = [...records].map(([name, record]) => ({
144
+ name,
145
+ storageType: record.types.size === 0
146
+ ? "unknown"
147
+ : record.types.size === 1 ? [...record.types][0] : "mixed",
148
+ nullable: record.nullable,
149
+ optional: record.present < values.length
150
+ }));
151
+ return cloneAndFreeze({ version: 1, completeness: "known", origin, fields });
152
+ }
153
+
154
+ export function validateRowsAgainstSchema(values, schema, label = "Dataset") {
155
+ const normalized = validateDatasetSchema(schema);
156
+ if (normalized.completeness !== "known") return normalized;
157
+ const fields = new Map(normalized.fields.map(field => [field.name, field]));
158
+ for (let rowIndex = 0; rowIndex < values.length; rowIndex += 1) {
159
+ const row = values[rowIndex];
160
+ if (normalized.origin === "declared") {
161
+ const unknown = Object.keys(row).find(name => !fields.has(name));
162
+ if (unknown !== undefined) throw new Error(`${label} row ${rowIndex} contains undeclared field "${unknown}".`);
163
+ }
164
+ for (const field of normalized.fields) {
165
+ const present = Object.hasOwn(row, field.name) && row[field.name] !== undefined;
166
+ if (!present) {
167
+ if (!field.optional) throw new Error(`${label} row ${rowIndex} requires field "${field.name}".`);
168
+ continue;
169
+ }
170
+ const value = row[field.name];
171
+ if (value === null) {
172
+ if (!field.nullable) throw new TypeError(`${label} field "${field.name}" cannot be null at row ${rowIndex}.`);
173
+ continue;
174
+ }
175
+ if (!["unknown", "mixed"].includes(field.storageType) && valueStorageType(value) !== field.storageType) {
176
+ throw new TypeError(`${label} field "${field.name}" must contain ${field.storageType} values; invalid row ${rowIndex}.`);
177
+ }
178
+ }
179
+ }
180
+ return normalized;
181
+ }
182
+
183
+ export function schemaField(schema, name) {
184
+ return validateDatasetSchema(schema).fields.find(field => field.name === name);
185
+ }
186
+
187
+ export function assertFieldsAvailable(schema, fields, { data, operation } = {}) {
188
+ const normalized = validateDatasetSchema(schema);
189
+ const unique = [...new Set(fields.filter(field => typeof field === "string" && field.length > 0))];
190
+ for (const field of unique) {
191
+ if (normalized.completeness !== "known" || schemaField(normalized, field) === undefined) {
192
+ throw annotateError(new Error(
193
+ `Dataset "${data ?? "unknown"}" does not contain field "${field}".`
194
+ ), {
195
+ code: "missing-resource",
196
+ operation,
197
+ optionPath: "field",
198
+ resourceId: data
199
+ });
200
+ }
201
+ }
202
+ return normalized;
203
+ }
204
+
205
+ function expressionFields(expression, output = []) {
206
+ if (!isPlainObject(expression)) return output;
207
+ if (typeof expression.field === "string") output.push(expression.field);
208
+ for (const value of Object.values(expression)) {
209
+ if (Array.isArray(value)) value.forEach(item => expressionFields(item, output));
210
+ else if (isPlainObject(value)) expressionFields(value, output);
211
+ }
212
+ return output;
213
+ }
214
+
215
+ export function transformInputFields(transform) {
216
+ const result = [];
217
+ const add = value => {
218
+ if (typeof value === "string" && value.length > 0) result.push(value);
219
+ else if (Array.isArray(value)) value.forEach(add);
220
+ };
221
+ add(transform.field);
222
+ add(transform.x);
223
+ add(transform.x?.field);
224
+ add(transform.y);
225
+ add(transform.y?.field);
226
+ add(transform.category);
227
+ add(transform.group);
228
+ add(transform.value);
229
+ add(transform.key);
230
+ add(transform.groupBy);
231
+ add(transform.partitionBy);
232
+ add(transform.fields);
233
+ add(transform.keys);
234
+ add(transform.weight?.field ?? transform.weight);
235
+ add(transform.placement?.categoryField);
236
+ add(transform.placement?.split?.field);
237
+ add(transform.sortBy?.map(item => item.field));
238
+ if (transform.type === "window") {
239
+ const generated = new Set();
240
+ for (const item of transform.operations ?? []) {
241
+ if (typeof item.field === "string" && !generated.has(item.field)) add(item.field);
242
+ add(item.orderBy);
243
+ if (typeof item.as === "string") generated.add(item.as);
244
+ }
245
+ } else {
246
+ add(transform.operations?.flatMap(item => [item.field, item.orderBy]));
247
+ }
248
+ add(transform.aggregates?.flatMap(item => [item.field, item.op?.orderBy]));
249
+ add(expressionFields(transform.expression));
250
+ return [...new Set(result)];
251
+ }
252
+
253
+ function inheritedField(schema, name) {
254
+ const field = schema.fields.find(item => item.name === name);
255
+ return field === undefined ? undefined : { ...field };
256
+ }
257
+
258
+ function generatedField(name, storageType, transform, role, inputs = []) {
259
+ return {
260
+ name,
261
+ storageType,
262
+ nullable: !["count", "valid", "missing", "rowNumber", "rank", "denseRank"].includes(role),
263
+ optional: false,
264
+ lineage: {
265
+ inputs: inputs
266
+ .filter(field => typeof field === "string" && field.length > 0)
267
+ .map(field => ({ data: transform.__sourceId ?? "source", field })),
268
+ owner: transform.__ownerId ?? transform.type,
269
+ role
270
+ }
271
+ };
272
+ }
273
+
274
+ export function deriveTransformSchema(inputSchema, transform, values, { sourceId, ownerId } = {}) {
275
+ const input = validateDatasetSchema(inputSchema);
276
+ const definition = { ...transform, __sourceId: sourceId, __ownerId: ownerId };
277
+ const retain = ["filter", "markFilter", "sort", "computed", "timeUnit", "window", "normalize", "stack", "impute", "complete"];
278
+ let fields = retain.includes(transform.type) ? input.fields.map(field => ({ ...field })) : [];
279
+ const push = field => {
280
+ if (field === undefined) return;
281
+ fields = fields.filter(item => item.name !== field.name);
282
+ fields.push(field);
283
+ };
284
+ if (transform.type === "summary") {
285
+ fields = transform.groupBy.map(name => inheritedField(input, name)).filter(Boolean);
286
+ for (const item of transform.aggregates) {
287
+ const op = typeof item.op === "string" ? item.op : item.op.op;
288
+ push(generatedField(item.as, "number", definition, op, item.field ? [item.field] : []));
289
+ }
290
+ if (transform.members !== undefined) push(generatedField(transform.members, "array", definition, "members"));
291
+ } else if (transform.type === "bin") {
292
+ fields = [
293
+ generatedField(transform.as.lower, "number", definition, "lower", [transform.field]),
294
+ generatedField(transform.as.upper, "number", definition, "upper", [transform.field]),
295
+ generatedField(transform.as.count, "number", definition, "count", [transform.field])
296
+ ];
297
+ if (transform.as.members !== undefined) push(generatedField(transform.as.members, "array", definition, "members"));
298
+ } else if (transform.type === "bin2d") {
299
+ fields = [
300
+ generatedField(transform.as.x0, "number", definition, "x0", [transform.x]),
301
+ generatedField(transform.as.x1, "number", definition, "x1", [transform.x]),
302
+ generatedField(transform.as.y0, "number", definition, "y0", [transform.y]),
303
+ generatedField(transform.as.y1, "number", definition, "y1", [transform.y]),
304
+ generatedField(transform.as.count, "number", definition, "count", [transform.x, transform.y])
305
+ ];
306
+ if (transform.as.members !== undefined) push(generatedField(transform.as.members, "array", definition, "members"));
307
+ } else if (transform.type === "regression") {
308
+ fields = [inheritedField(input, transform.x), inheritedField(input, transform.y)].filter(Boolean);
309
+ if (transform.groupBy !== undefined) fields.unshift(inheritedField(input, transform.groupBy));
310
+ if (transform.interval !== false && transform.method !== "loess") {
311
+ push(generatedField("__regression_ci_lower", "number", definition, "lower", [transform.x, transform.y]));
312
+ push(generatedField("__regression_ci_upper", "number", definition, "upper", [transform.x, transform.y]));
313
+ }
314
+ } else if (transform.type === "density") {
315
+ fields = [
316
+ ...(transform.groupBy === undefined ? [] : [inheritedField(input, transform.groupBy)]),
317
+ ...(transform.placement?.split === undefined ? [] : [inheritedField(input, transform.placement.split.field)]),
318
+ generatedField(transform.as[0], "number", definition, "value", [transform.field]),
319
+ generatedField(transform.as[1], "number", definition, "density", [transform.field])
320
+ ].filter(Boolean);
321
+ if (transform.groupBy === undefined && transform.placement !== undefined) {
322
+ push(generatedField(transform.placement.categoryField, "string", definition, "category"));
323
+ }
324
+ } else if (transform.type === "interval") {
325
+ fields = transform.groupBy.map(name => inheritedField(input, name)).filter(Boolean);
326
+ push(generatedField(transform.as.center, "number", definition, "center", [transform.field]));
327
+ push(generatedField(transform.as.lower, "number", definition, "lower", [transform.field]));
328
+ push(generatedField(transform.as.upper, "number", definition, "upper", [transform.field]));
329
+ } else if (transform.type === "ecdf") {
330
+ fields = transform.groupBy.map(name => inheritedField(input, name)).filter(Boolean);
331
+ push(generatedField(transform.as.value, "number", definition, "value", [transform.field]));
332
+ push(generatedField(transform.as.cumulative, "number", definition, "cumulative", [transform.field]));
333
+ push(generatedField(transform.as.probability, "number", definition, "probability", [transform.field]));
334
+ } else if (transform.type === "computed") {
335
+ push(generatedField(transform.as, "unknown", definition, "value", expressionFields(transform.expression)));
336
+ } else if (transform.type === "timeUnit") {
337
+ push(generatedField(transform.as, "number", definition, "value", [transform.field]));
338
+ } else if (transform.type === "window") {
339
+ for (const item of transform.operations) {
340
+ push(generatedField(
341
+ item.as,
342
+ ["lag", "lead"].includes(item.op) ? "unknown" : "number",
343
+ definition,
344
+ item.op,
345
+ item.field ? [item.field] : []
346
+ ));
347
+ }
348
+ } else if (transform.type === "normalize") {
349
+ push(generatedField(transform.as, "number", definition, "value", [transform.field]));
350
+ } else if (transform.type === "stack") {
351
+ for (const [role, name] of Object.entries(transform.as ?? {})) {
352
+ push(generatedField(name, "number", definition, role, [transform.field]));
353
+ }
354
+ } else if (transform.type === "fold") {
355
+ fields = input.fields.filter(field => !transform.fields.includes(field.name)).map(field => ({ ...field }));
356
+ push(generatedField(transform.as.key, "string", definition, "key", transform.fields));
357
+ push(generatedField(transform.as.value, "unknown", definition, "value", transform.fields));
358
+ }
359
+ const inferred = Array.isArray(values) && values.length > 0
360
+ ? inferDatasetSchema(values, { origin: "derived" })
361
+ : undefined;
362
+ if (inferred !== undefined) {
363
+ const actual = new Map(inferred.fields.map(field => [field.name, field]));
364
+ fields = fields.map(planned => {
365
+ const field = actual.get(planned.name);
366
+ if (field === undefined) {
367
+ return ["complete", "impute"].includes(transform.type)
368
+ ? { ...planned, optional: true }
369
+ : planned;
370
+ }
371
+ if (planned.lineage === undefined && !["complete", "impute"].includes(transform.type)) {
372
+ return planned;
373
+ }
374
+ return { ...planned, storageType: field.storageType, nullable: field.nullable, optional: field.optional };
375
+ });
376
+ for (const field of inferred.fields) {
377
+ if (!fields.some(planned => planned.name === field.name)) fields.push(field);
378
+ }
379
+ }
380
+ return cloneAndFreeze({
381
+ version: 1,
382
+ completeness: input.completeness === "known" || fields.length > 0 ? "known" : "unknown",
383
+ origin: "derived",
384
+ fields: input.completeness === "known" || fields.length > 0 ? fields : []
385
+ });
386
+ }
@@ -59,6 +59,7 @@ export function normalizeDensityTransform(args = {}) {
59
59
  as: args.as ?? [`${args.field}_value`, `${args.field}_density`],
60
60
  resolve: args.resolve ?? "shared",
61
61
  ...(args.weight === undefined ? {} : { weight: args.weight }),
62
+ ...(args.missing === undefined ? {} : { missing: args.missing }),
62
63
  ...(args.placement === undefined ? {} : { placement: args.placement })
63
64
  };
64
65
  validateDensityTransform(transform);
@@ -370,7 +371,7 @@ function hasValidResolvedBandwidthState(resolved, transform) {
370
371
  export function validateDensityTransform(transform) {
371
372
  const supported = [
372
373
  "type", "field", "groupBy", "bandwidth", "extent", "steps", "as",
373
- "resolve", "kernel", "normalization", "placement", "weight", "resolved"
374
+ "resolve", "kernel", "normalization", "placement", "weight", "missing", "resolved"
374
375
  ];
375
376
  const unknown = Object.keys(transform).find(key => !supported.includes(key));
376
377
  if (unknown !== undefined) {
@@ -383,6 +384,9 @@ export function validateDensityTransform(transform) {
383
384
  if (transform.weight !== undefined) {
384
385
  normalizeStatisticalWeight(transform.weight, "Density weight");
385
386
  }
387
+ if (transform.missing !== undefined && !["error", "drop"].includes(transform.missing)) {
388
+ throw new Error('Density missing must be "error" or "drop".');
389
+ }
386
390
  if (transform.groupBy !== undefined) {
387
391
  requireField(transform.groupBy, "Density groupBy");
388
392
  }
@@ -540,7 +544,8 @@ export function deriveKernelDensity(values, {
540
544
  normalization = "unit",
541
545
  as,
542
546
  placement,
543
- weight
547
+ weight,
548
+ missing
544
549
  } = {}) {
545
550
  if (!Array.isArray(values)) {
546
551
  throw new TypeError("Density values must be an array.");
@@ -561,9 +566,31 @@ export function deriveKernelDensity(values, {
561
566
  )];
562
567
  const resolvedKernel = validateDensityKernel(kernel);
563
568
  const resolvedNormalization = validateDensityNormalization(normalization);
569
+ let missingValueRows = 0;
570
+ let missingWeightRows = 0;
571
+ const inputValues = missing === undefined ? values : values.filter((row, index) => {
572
+ const source = row?.[sourceField];
573
+ if (source === null || source === undefined) {
574
+ if (missing === "error") throw new TypeError(`Density field "${sourceField}" is missing at row ${index}.`);
575
+ missingValueRows += 1;
576
+ return false;
577
+ }
578
+ if (!Number.isFinite(source)) {
579
+ throw new TypeError(`Density field "${sourceField}" must contain a finite number at row ${index}.`);
580
+ }
581
+ if (weight !== undefined) {
582
+ const weightValue = row?.[weight.field];
583
+ if (weightValue === null || weightValue === undefined) {
584
+ if (missing === "error") throw new TypeError(`Density weight "${weight.field}" is missing at row ${index}.`);
585
+ missingWeightRows += 1;
586
+ return false;
587
+ }
588
+ }
589
+ return true;
590
+ });
564
591
  const statisticalWeights = weight === undefined
565
592
  ? undefined
566
- : readStatisticalWeights(values, weight, "Density");
593
+ : readStatisticalWeights(inputValues, weight, "Density");
567
594
  if (statisticalWeights !== undefined) {
568
595
  validateWeightedNumericFields(
569
596
  statisticalWeights.entries,
@@ -579,7 +606,7 @@ export function deriveKernelDensity(values, {
579
606
  isNominalValue(entry.row[placement.split.field]))
580
607
  );
581
608
  const validRows = statisticalWeights === undefined
582
- ? values.filter(row =>
609
+ ? inputValues.filter(row =>
583
610
  row !== null &&
584
611
  typeof row === "object" &&
585
612
  Number.isFinite(row[sourceField]) &&
@@ -759,6 +786,27 @@ export function deriveKernelDensity(values, {
759
786
  extent: resolvedExtent,
760
787
  steps,
761
788
  samples,
762
- values: rows
789
+ values: rows,
790
+ ...(missing === undefined ? {} : {
791
+ report: {
792
+ version: 1,
793
+ owner: { kind: "data", id: "pending" },
794
+ inputs: [],
795
+ units: [{
796
+ role: outputFields[1],
797
+ group: {},
798
+ inputRows: values.length,
799
+ usedRows: validRows.length,
800
+ excludedRows: values.length - validRows.length,
801
+ excludedByReason: {
802
+ ...(missingValueRows === 0 ? {} : { "missing-value": missingValueRows }),
803
+ ...(missingWeightRows === 0 ? {} : { "missing-weight": missingWeightRows })
804
+ },
805
+ ...(statisticalWeights === undefined ? {} : {
806
+ zeroWeightRows: statisticalWeights.entries.filter(entry => entry.weight === 0).length
807
+ })
808
+ }]
809
+ }
810
+ })
763
811
  });
764
812
  }