ggaction 0.0.22 → 0.0.23
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 +17 -0
- package/README.md +81 -89
- package/knowledge/action-card.schema.json +1 -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 +6 -3
- package/src/actions/charts/interval-regression.js +2 -2
- package/src/actions/data/edit.js +1 -1
- package/src/actions/data/timeUnit.js +1 -1
- package/src/actions/facets/actions.js +1 -1
- package/src/actions/guides/legends/categorical/components.js +1 -4
- package/src/actions/regression/create.js +6 -1
- package/src/actions/regression/edit.js +10 -1
- package/src/core/compositionState.js +5 -3
- package/src/core/facetAxisPolicy.js +30 -0
- package/src/grammar/curveCommands.js +35 -2
- package/src/grammar/datasetSchema.js +1 -1
- package/src/grammar/facetHeaders.js +1 -1
- package/src/grammar/facets/guides.js +7 -5
- package/src/grammar/regression/parameters.js +7 -0
- package/src/grammar/timeUnit.js +9 -3
- package/src/inspectionDescriptors.js +1 -1
- package/src/layout/facets.js +1 -1
- package/src/layout/guideCollisions.js +9 -3
- package/src/materialization/facetContentBounds.js +96 -0
- package/src/materialization/facetGuides/layout.js +1 -1
- package/src/materialization/facetGuides/placement.js +16 -0
- package/src/materialization/facetHeaders.js +1 -1
- package/src/materialization/facets.js +1 -1
- package/src/materialization/graphicHierarchy.js +25 -1
- package/src/persistence/validation.js +6 -0
- package/src/version.js +1 -1
- package/types/program.d.ts +17 -11
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { reconcileRegressionPlacement } from "../../materialization/graphicHierarchy.js";
|
|
2
|
+
import { validateRegressionPlacement } from "../../grammar/regression/parameters.js";
|
|
1
3
|
import { closedAction } from "../../core/action.js";
|
|
2
4
|
import { isPlainObject } from "../../core/immutable.js";
|
|
3
5
|
import { validateUserId } from "../../core/identifiers.js";
|
|
@@ -24,7 +26,7 @@ import { buildSourceRevisionPlan, applyRevisionPlan } from "../data/edit.js";
|
|
|
24
26
|
const OPTIONS = Object.freeze([
|
|
25
27
|
"target", "data", "x", "y", "groupBy", "method", "degree", "span", "robustIterations",
|
|
26
28
|
"confidenceMethod", "level", "confidence", "interval", "band", "line"
|
|
27
|
-
, "predict", "sourceBinding", "missing"
|
|
29
|
+
, "predict", "sourceBinding", "missing", "placement"
|
|
28
30
|
]);
|
|
29
31
|
const BAND_OPTIONS = Object.freeze([
|
|
30
32
|
"color", "opacity", "stroke", "strokeWidth", "curve",
|
|
@@ -259,6 +261,7 @@ export const editRegression = /* @__PURE__ */ closedAction(
|
|
|
259
261
|
}
|
|
260
262
|
const owner = resolveRegressionOwner(this, args.target);
|
|
261
263
|
const current = this.markConfigs[owner.id].regression;
|
|
264
|
+
const placement = validateRegressionPlacement(Object.hasOwn(args, "placement") ? args.placement : current.placement);
|
|
262
265
|
const currentBinding = owner.derivedBindings?.regression?.mode === "follow" ? "follow" : "fixed";
|
|
263
266
|
const sourceBinding = args.sourceBinding ?? currentBinding;
|
|
264
267
|
if (!["fixed", "follow"].includes(sourceBinding)) throw new Error('Regression sourceBinding must be "fixed" or "follow".');
|
|
@@ -350,6 +353,8 @@ export const editRegression = /* @__PURE__ */ closedAction(
|
|
|
350
353
|
}
|
|
351
354
|
}
|
|
352
355
|
|
|
356
|
+
next = next._withMarkConfig(owner.id, { ...next.markConfigs[owner.id],
|
|
357
|
+
regression: { ...current, dataId } });
|
|
353
358
|
let bandId = current.bandId;
|
|
354
359
|
if (hadBand && !wantsBand) {
|
|
355
360
|
next = removeOwnedMark(next, current.bandId);
|
|
@@ -411,6 +416,7 @@ export const editRegression = /* @__PURE__ */ closedAction(
|
|
|
411
416
|
...next.markConfigs[owner.id],
|
|
412
417
|
regression: {
|
|
413
418
|
...current,
|
|
419
|
+
...(Object.hasOwn(args, "placement") ? { placement } : {}),
|
|
414
420
|
source,
|
|
415
421
|
x,
|
|
416
422
|
y,
|
|
@@ -432,6 +438,9 @@ export const editRegression = /* @__PURE__ */ closedAction(
|
|
|
432
438
|
} else if (sourceBinding === "fixed" && binding !== undefined) {
|
|
433
439
|
next = next.editSemantic({ property: `layer[${owner.id}].derivedBindings.regression`, remove: true });
|
|
434
440
|
}
|
|
441
|
+
if (placement === "plot-last" || Object.hasOwn(args, "placement")) {
|
|
442
|
+
next = reconcileRegressionPlacement(next, owner.id);
|
|
443
|
+
}
|
|
435
444
|
return next;
|
|
436
445
|
};
|
|
437
446
|
return applyEdit(this);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeFacetAxes } from "./facetAxisPolicy.js";
|
|
1
2
|
import { isOwned, isPlainObject } from "./immutable.js";
|
|
2
3
|
import { ownProgramState } from "./programState.js";
|
|
3
4
|
import {
|
|
@@ -43,6 +44,7 @@ function validateFacetSpec(compositionSpec) {
|
|
|
43
44
|
}
|
|
44
45
|
const grid = compositionSpec.facet.grid;
|
|
45
46
|
const repeat = compositionSpec.facet.repeat;
|
|
47
|
+
if (grid !== undefined && compositionSpec.spacing === "content") throw new Error("Content flow requires a wrapped facet.");
|
|
46
48
|
if (grid !== undefined && repeat !== undefined) {
|
|
47
49
|
throw new Error("Facet compositionSpec cannot combine grid and repeat recipes.");
|
|
48
50
|
}
|
|
@@ -204,10 +206,10 @@ function validateFacetSpec(compositionSpec) {
|
|
|
204
206
|
);
|
|
205
207
|
}
|
|
206
208
|
const guides = compositionSpec.facet.guides;
|
|
209
|
+
normalizeFacetAxes(guides?.axes, "each", { stored: true });
|
|
207
210
|
if (
|
|
208
211
|
!isPlainObject(guides) ||
|
|
209
212
|
Object.keys(guides).some(key => !["axes", "legend"].includes(key)) ||
|
|
210
|
-
!["each", "outer"].includes(guides.axes) ||
|
|
211
213
|
(![false, "shared"].includes(guides.legend) && !isPlainObject(guides.legend))
|
|
212
214
|
) {
|
|
213
215
|
throw new Error(
|
|
@@ -289,8 +291,8 @@ export function ownCompositionSpec(compositionSpec, children) {
|
|
|
289
291
|
throw new TypeError("compositionSpec.id must be a non-empty string.");
|
|
290
292
|
}
|
|
291
293
|
const facet = compositionSpec.type === "facet";
|
|
292
|
-
if (compositionSpec.spacing !== undefined && (!facet || !["plot", "canvas"].includes(compositionSpec.spacing))) {
|
|
293
|
-
throw new Error("compositionSpec.spacing requires a facet and canvas or
|
|
294
|
+
if (compositionSpec.spacing !== undefined && (!facet || !["plot", "canvas", "content"].includes(compositionSpec.spacing))) {
|
|
295
|
+
throw new Error("compositionSpec.spacing requires a facet and canvas, plot, or content spacing.");
|
|
294
296
|
}
|
|
295
297
|
if (compositionSpec.topLane !== undefined && (!facet ||
|
|
296
298
|
!FACET_TOP_LANE_MODES.includes(compositionSpec.topLane) ||
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cloneAndFreeze, isPlainObject } from "./immutable.js";
|
|
2
|
+
|
|
3
|
+
export function normalizeFacetAxes(value, previous = "each", { stored = false } = {}) {
|
|
4
|
+
if (value === undefined && !stored) value = "each";
|
|
5
|
+
if (["each", "outer"].includes(value)) return value;
|
|
6
|
+
if (!isPlainObject(value) || Object.keys(value).length === 0 ||
|
|
7
|
+
Object.keys(value).some(key => !["x", "y"].includes(key)) ||
|
|
8
|
+
Object.values(value).some(mode => !["each", "outer"].includes(mode)) ||
|
|
9
|
+
stored && (!Object.hasOwn(value, "x") || !Object.hasOwn(value, "y"))) {
|
|
10
|
+
throw new Error('Facet axes must be each, outer, or a non-empty x/y policy object.');
|
|
11
|
+
}
|
|
12
|
+
return cloneAndFreeze({ x: value.x ?? facetAxisMode(previous, "x"), y: value.y ?? facetAxisMode(previous, "y") });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function facetAxisMode(policy, channel) {
|
|
16
|
+
return typeof policy === "string" ? policy : policy[channel];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hasOuterFacetAxes(policy) {
|
|
20
|
+
return ["x", "y"].some(channel => facetAxisMode(policy, channel) === "outer");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateFacetAxisPolicy(policy, family, repeat = false) {
|
|
24
|
+
if (family !== "cartesian" && (hasOuterFacetAxes(policy) || isPlainObject(policy))) {
|
|
25
|
+
throw new Error("Polar and Parallel facets do not support outer axes or per-channel axis policies.");
|
|
26
|
+
}
|
|
27
|
+
if (repeat && hasOuterFacetAxes(policy)) {
|
|
28
|
+
throw new Error("repeatCharts does not promote axes across different repeated fields.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -9,6 +9,7 @@ export const CURVE_INTERPOLATIONS = Object.freeze([
|
|
|
9
9
|
"basis",
|
|
10
10
|
"cardinal",
|
|
11
11
|
"monotone",
|
|
12
|
+
"monotone-steffen",
|
|
12
13
|
"natural"
|
|
13
14
|
]);
|
|
14
15
|
|
|
@@ -16,6 +17,7 @@ const SMOOTH_CURVES = new Set([
|
|
|
16
17
|
"basis",
|
|
17
18
|
"cardinal",
|
|
18
19
|
"monotone",
|
|
20
|
+
"monotone-steffen",
|
|
19
21
|
"natural"
|
|
20
22
|
]);
|
|
21
23
|
|
|
@@ -196,8 +198,38 @@ function monotoneTangents(points) {
|
|
|
196
198
|
return tangents;
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
function
|
|
200
|
-
const
|
|
201
|
+
function steffenTangents(points) {
|
|
202
|
+
const widths = points.slice(1).map((point, index) => point.x - points[index].x);
|
|
203
|
+
const direction = Math.sign(widths.find(width => width !== 0) ?? 0);
|
|
204
|
+
if (widths.some(width => width !== 0 && Math.sign(width) !== direction)) {
|
|
205
|
+
throw new Error("Steffen curve requires monotonic x values.");
|
|
206
|
+
}
|
|
207
|
+
const slopes = widths.map((width, i) =>
|
|
208
|
+
(points[i + 1].y - points[i].y) / (width || (direction < 0 ? -0 : 0)));
|
|
209
|
+
const tangents = Array(points.length);
|
|
210
|
+
for (let i = 1; i < points.length - 1; i += 1) {
|
|
211
|
+
const a = slopes[i - 1], b = slopes[i];
|
|
212
|
+
const mean = (a * widths[i] + b * widths[i - 1]) / (widths[i - 1] + widths[i]);
|
|
213
|
+
// A zero-width turn has no finite secant estimate; its Hermite limit is flat.
|
|
214
|
+
const sign = value => value < 0 ? -1 : 1;
|
|
215
|
+
const slope = (sign(a) + sign(b)) * Math.min(Math.abs(a), Math.abs(b), Math.abs(mean) / 2);
|
|
216
|
+
tangents[i] = Number.isNaN(slope) && (widths[i - 1] === 0 || widths[i] === 0) ? 0 : slope;
|
|
217
|
+
}
|
|
218
|
+
tangents[0] = widths[0] === 0 ? tangents[1] : (3 * slopes[0] - tangents[1]) / 2;
|
|
219
|
+
tangents[points.length - 1] = widths.at(-1) === 0 ? tangents.at(-2) :
|
|
220
|
+
(3 * slopes.at(-1) - tangents.at(-2)) / 2;
|
|
221
|
+
return tangents;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function buildSteffenCommands(points) {
|
|
225
|
+
const distinct = points.filter((point, i) => i === 0 ||
|
|
226
|
+
point.x !== points[i - 1].x || point.y !== points[i - 1].y);
|
|
227
|
+
if (distinct.length === 1) return ownCommands([{ op: "M", ...distinct[0] }]);
|
|
228
|
+
if (distinct.length === 2) return buildLinearPathCommands(distinct);
|
|
229
|
+
return buildMonotoneCommands(distinct, steffenTangents(distinct));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildMonotoneCommands(points, tangents = monotoneTangents(points)) {
|
|
201
233
|
const commands = [{ op: "M", x: points[0].x, y: points[0].y }];
|
|
202
234
|
for (let index = 1; index < points.length; index += 1) {
|
|
203
235
|
const previous = points[index - 1];
|
|
@@ -274,6 +306,7 @@ function buildNaturalCommands(points) {
|
|
|
274
306
|
}
|
|
275
307
|
|
|
276
308
|
function buildValidatedCurvePathCommands(points, curve, tension) {
|
|
309
|
+
if (curve === "monotone-steffen") return buildSteffenCommands(points);
|
|
277
310
|
if (curve === "linear" ||
|
|
278
311
|
(SMOOTH_CURVES.has(curve) &&
|
|
279
312
|
curve !== "monotone" &&
|
|
@@ -404,7 +404,7 @@ export function deriveTransformSchema(inputSchema, transform, values, { sourceId
|
|
|
404
404
|
if (planned.lineage === undefined && !["complete", "impute"].includes(transform.type)) {
|
|
405
405
|
return planned;
|
|
406
406
|
}
|
|
407
|
-
return { ...planned, storageType: transform.type
|
|
407
|
+
return { ...planned, storageType: ["regressionStatistics", "timeUnit"].includes(transform.type) && planned.storageType === "number"
|
|
408
408
|
? "number" : field.storageType, nullable: field.nullable, optional: field.optional };
|
|
409
409
|
});
|
|
410
410
|
for (const field of inferred.fields) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{validateOptionObject as o,validateNonEmptyString as r,validatePositiveFinite as a,validateNonNegativeFinite as c}from"../core/validation.js";import{resolveRotation as s}from"./rotation.js";const f=Object.freeze(["text","fontSize","fontFamily","fontWeight","color","offset","align","rotation","alongOffset"]);function d(e){if(o(e,f,"Facet header title",{allowEmpty:!1}),r(e.text,"Facet header title text"),/[\r\n\u2028\u2029]/u.test(e.text))throw new TypeError("Facet header title text must be one line.");e.fontSize!==void 0&&a(e.fontSize,"Facet header title fontSize");for(const t of["fontFamily","color"])e[t]!==void 0&&r(e[t],`Facet header title ${t}`);if(e.fontWeight!==void 0&&!(typeof e.fontWeight=="string"&&e.fontWeight.length>0||Number.isFinite(e.fontWeight)))throw new TypeError("Facet header title fontWeight must be a non-empty string or finite number.");if(e.offset!==void 0&&c(e.offset,"Facet header title offset"),e.align!==void 0&&!["start","center","end"].includes(e.align))throw new TypeError("Facet header title align must be start, center, or end.");for(const t of["rotation","alongOffset"])if(Object.hasOwn(e,t)&&!Number.isFinite(e[t]))throw new TypeError(`Facet header title ${t} must contain finite normalized radians or coordinates.`)}function h(e,t){if(t===!1)return;o(t,f,"Facet header title",{allowEmpty:!1});for(const i of Object.keys(t))if(t[i]===void 0)throw new TypeError(`Facet header title ${i} must not be undefined.`);const n={...e,...t};return Object.hasOwn(t,"rotation")&&(n.rotation=s(t.rotation,"Facet header title rotation")),d(n),n}function w(e){for(const t of e.common===void 0?[e]:[e.common,e.row,e.column]){if(t!==void 0&&Object.hasOwn(t,"anchor")&&!["plot","content"].includes(t.anchor))throw new TypeError("Facet header anchor must be plot or
|
|
1
|
+
import{validateOptionObject as o,validateNonEmptyString as r,validatePositiveFinite as a,validateNonNegativeFinite as c}from"../core/validation.js";import{resolveRotation as s}from"./rotation.js";const f=Object.freeze(["text","fontSize","fontFamily","fontWeight","color","offset","align","rotation","alongOffset"]);function d(e){if(o(e,f,"Facet header title",{allowEmpty:!1}),r(e.text,"Facet header title text"),/[\r\n\u2028\u2029]/u.test(e.text))throw new TypeError("Facet header title text must be one line.");e.fontSize!==void 0&&a(e.fontSize,"Facet header title fontSize");for(const t of["fontFamily","color"])e[t]!==void 0&&r(e[t],`Facet header title ${t}`);if(e.fontWeight!==void 0&&!(typeof e.fontWeight=="string"&&e.fontWeight.length>0||Number.isFinite(e.fontWeight)))throw new TypeError("Facet header title fontWeight must be a non-empty string or finite number.");if(e.offset!==void 0&&c(e.offset,"Facet header title offset"),e.align!==void 0&&!["start","center","end"].includes(e.align))throw new TypeError("Facet header title align must be start, center, or end.");for(const t of["rotation","alongOffset"])if(Object.hasOwn(e,t)&&!Number.isFinite(e[t]))throw new TypeError(`Facet header title ${t} must contain finite normalized radians or coordinates.`)}function h(e,t){if(t===!1)return;o(t,f,"Facet header title",{allowEmpty:!1});for(const i of Object.keys(t))if(t[i]===void 0)throw new TypeError(`Facet header title ${i} must not be undefined.`);const n={...e,...t};return Object.hasOwn(t,"rotation")&&(n.rotation=s(t.rotation,"Facet header title rotation")),d(n),n}function w(e){for(const t of e.common===void 0?[e]:[e.common,e.row,e.column]){if(t!==void 0&&Object.hasOwn(t,"anchor")&&!["plot","content","view"].includes(t.anchor))throw new TypeError("Facet header anchor must be plot, content, or view.");if(t?.rotation!==void 0&&!Number.isFinite(t.rotation))throw new TypeError("Facet header rotation must contain finite normalized radians.");t?.title!==void 0&&d(t.title)}if(e.title!==void 0||e.common?.title!==void 0)throw new TypeError("Facet header title requires an explicit row or column role.");if(e.mode!=="roles"&&(e.row?.title!==void 0||e.column?.title!==void 0))throw new TypeError("Facet field titles require role header mode.")}export{h as normalizeFacetHeaderTitle,w as validateFacetHeaderRotations,d as validateFacetHeaderTitle};
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
|
|
2
2
|
|
|
3
|
+
|
|
4
|
+
import { normalizeFacetAxes, facetAxisMode, hasOuterFacetAxes } from "../../core/facetAxisPolicy.js";
|
|
5
|
+
|
|
3
6
|
const LEGEND_FAMILIES = Object.freeze({
|
|
4
7
|
series: "categorical",
|
|
5
8
|
color: "categorical",
|
|
@@ -243,9 +246,7 @@ export function planFacetGuideOwnership({
|
|
|
243
246
|
if (!Array.isArray(children) || children.length !== cells.length) {
|
|
244
247
|
throw new Error("Facet guide ownership requires one child descriptor per placement.");
|
|
245
248
|
}
|
|
246
|
-
|
|
247
|
-
throw new Error("Facet axes must be each or outer.");
|
|
248
|
-
}
|
|
249
|
+
axes = normalizeFacetAxes(axes, "each", { stored: true });
|
|
249
250
|
if (![false, "shared"].includes(legend) && !isPlainObject(legend)) {
|
|
250
251
|
throw new Error("Facet legend must be false or shared.");
|
|
251
252
|
}
|
|
@@ -264,11 +265,12 @@ export function planFacetGuideOwnership({
|
|
|
264
265
|
const presentAxes = descriptor.axes ?? [];
|
|
265
266
|
const records = descriptor.axisOwners;
|
|
266
267
|
const removed = {};
|
|
267
|
-
const keepAxes = axes
|
|
268
|
-
? presentAxes.filter(channel => outer.children[cell.id][channel])
|
|
268
|
+
const keepAxes = !hasOuterFacetAxes(axes) ? presentAxes : records === undefined
|
|
269
|
+
? presentAxes.filter(channel => facetAxisMode(axes, channel) === "each" || outer.children[cell.id][channel])
|
|
269
270
|
: presentAxes.filter(id => {
|
|
270
271
|
const owner = records.find(owner => owner.id === id);
|
|
271
272
|
if (owner === undefined) throw new Error("Facet axis descriptors must identify every retained owner.");
|
|
273
|
+
if (facetAxisMode(axes, owner.channel) === "each") return true;
|
|
272
274
|
const components = Object.entries(owner.components);
|
|
273
275
|
const remove = components.filter(([, side]) => !sides[side]?.includes(cell.id)).map(([component]) => component);
|
|
274
276
|
if (remove.length > 0 && remove.length < components.length) removed[id] = remove;
|
|
@@ -233,3 +233,10 @@ export function normalizeRegressionTransform(args = {}) {
|
|
|
233
233
|
validateRegressionTransform(transform);
|
|
234
234
|
return cloneAndFreeze(transform);
|
|
235
235
|
}
|
|
236
|
+
|
|
237
|
+
export function validateRegressionPlacement(value) {
|
|
238
|
+
if (value !== undefined && !["source", "plot-last"].includes(value)) {
|
|
239
|
+
throw new Error('Regression placement must be "source" or "plot-last".');
|
|
240
|
+
}
|
|
241
|
+
return value ?? "source";
|
|
242
|
+
}
|
package/src/grammar/timeUnit.js
CHANGED
|
@@ -41,7 +41,7 @@ export const TIME_UNITS = cloneAndFreeze([
|
|
|
41
41
|
|
|
42
42
|
const TRANSFORM_KEYS = Object.freeze([
|
|
43
43
|
"type", "field", "unit", "as", "temporalUnit", "timeZone",
|
|
44
|
-
"weekStartsOn", "weekRule"
|
|
44
|
+
"weekStartsOn", "weekRule", "missing"
|
|
45
45
|
]);
|
|
46
46
|
|
|
47
47
|
const CALENDAR_FORMATTERS = new Map();
|
|
@@ -93,6 +93,9 @@ export function validateTimeUnitTransform(transform) {
|
|
|
93
93
|
if (transform.type !== "timeUnit") {
|
|
94
94
|
throw new Error(`Unsupported time-unit transform "${transform.type}".`);
|
|
95
95
|
}
|
|
96
|
+
if (Object.hasOwn(transform, "missing") && !["error", "null"].includes(transform.missing)) {
|
|
97
|
+
throw new Error('Time-unit missing must be "error" or "null".');
|
|
98
|
+
}
|
|
96
99
|
validateTemporalUnit(transform.temporalUnit);
|
|
97
100
|
const field = requireField(transform.field, "Time-unit field");
|
|
98
101
|
const output = requireField(transform.as, "Time-unit output field");
|
|
@@ -127,7 +130,8 @@ export function normalizeTimeUnitTransform(args = {}) {
|
|
|
127
130
|
const as = args.as === undefined ? `${field}_${unit}` : args.as;
|
|
128
131
|
const transform = { type: "timeUnit", field, unit, as,
|
|
129
132
|
...(temporalUnit === undefined ? {} : { temporalUnit }),
|
|
130
|
-
...(timeZone === undefined ? {} : { timeZone })
|
|
133
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
134
|
+
...(Object.hasOwn(args, "missing") ? { missing: args.missing } : {}) };
|
|
131
135
|
if (unit === "week") {
|
|
132
136
|
transform.weekStartsOn = args.weekStartsOn ?? 1;
|
|
133
137
|
transform.weekRule = args.weekRule ?? "calendar";
|
|
@@ -319,7 +323,8 @@ export function deriveTimeUnitRows(rows, transform) {
|
|
|
319
323
|
throw new TypeError("Time-unit source must contain plain row objects.");
|
|
320
324
|
}
|
|
321
325
|
const values = rows.map((row, index) => {
|
|
322
|
-
|
|
326
|
+
const missing = !Object.hasOwn(row, transform.field) || row[transform.field] === null;
|
|
327
|
+
if (!Object.hasOwn(row, transform.field) && transform.missing !== "null") {
|
|
323
328
|
throw new Error(
|
|
324
329
|
`Time-unit source does not contain field "${transform.field}" at row ${index}.`
|
|
325
330
|
);
|
|
@@ -327,6 +332,7 @@ export function deriveTimeUnitRows(rows, transform) {
|
|
|
327
332
|
if (Object.hasOwn(row, transform.as)) {
|
|
328
333
|
throw new Error(`Time-unit output field "${transform.as}" already exists.`);
|
|
329
334
|
}
|
|
335
|
+
if (missing && transform.missing === "null") return { ...row, [transform.as]: null };
|
|
330
336
|
const timestamp = normalizeTemporalValue(row[transform.field], transform.field, index, transform.temporalUnit);
|
|
331
337
|
return {
|
|
332
338
|
...row,
|