ggaction 0.0.22 → 0.0.24

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 (56) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +82 -90
  3. package/knowledge/action-card.schema.json +1 -1
  4. package/knowledge/action-cards.json +1 -1
  5. package/knowledge/intent-taxonomy.json +1 -1
  6. package/knowledge/mcp-resources.json +1 -1
  7. package/package.json +6 -3
  8. package/src/actions/charts/heatmap.js +1 -1
  9. package/src/actions/charts/interval-regression.js +2 -2
  10. package/src/actions/data/basicBin2d.js +1 -1
  11. package/src/actions/data/bin2d.js +2 -2
  12. package/src/actions/data/edit.js +1 -1
  13. package/src/actions/data/timeUnit.js +1 -1
  14. package/src/actions/encodings/ranged.js +2 -2
  15. package/src/actions/facets/actions.js +1 -1
  16. package/src/actions/guides/axes/components.js +5 -0
  17. package/src/actions/guides/axes/edit.js +1 -188
  18. package/src/actions/guides/axes/labels.js +1 -537
  19. package/src/actions/guides/axes/tickGroups.js +16 -1
  20. package/src/actions/guides/axes/ticks.js +33 -24
  21. package/src/actions/guides/axes/titles.js +1 -389
  22. package/src/actions/guides/legends/categorical/components.js +1 -4
  23. package/src/actions/regression/create.js +6 -1
  24. package/src/actions/regression/edit.js +10 -1
  25. package/src/actions/titles/resolve.js +11 -1
  26. package/src/core/compositionState.js +9 -4
  27. package/src/core/facetAxisPolicy.js +30 -0
  28. package/src/core/facetGap.js +44 -0
  29. package/src/grammar/axisTextPlacement.js +1 -1
  30. package/src/grammar/bin2d.js +34 -4
  31. package/src/grammar/curveCommands.js +35 -2
  32. package/src/grammar/datasetSchema.js +1 -1
  33. package/src/grammar/facetHeaders.js +1 -1
  34. package/src/grammar/facets/guides.js +7 -5
  35. package/src/grammar/regression/parameters.js +7 -0
  36. package/src/grammar/timeUnit.js +9 -3
  37. package/src/inspectionDescriptors.js +1 -1
  38. package/src/layout/composition.js +2 -6
  39. package/src/layout/facetContentFlow.js +15 -7
  40. package/src/layout/facets.js +1 -1
  41. package/src/layout/guideCollisions.js +9 -3
  42. package/src/layout/title.js +1 -1
  43. package/src/materialization/facetContentBounds.js +96 -0
  44. package/src/materialization/facetGuides/layout.js +1 -1
  45. package/src/materialization/facetGuides/placement.js +16 -0
  46. package/src/materialization/facetHeaders.js +1 -1
  47. package/src/materialization/facets.js +1 -1
  48. package/src/materialization/graphicHierarchy.js +25 -1
  49. package/src/materialization/guides/layout.js +1 -1
  50. package/src/materialization/guides/resources.js +1 -1
  51. package/src/materialization/planner.js +19 -4
  52. package/src/persistence/validation.js +25 -1
  53. package/src/version.js +1 -1
  54. package/types/basic.d.ts +2 -0
  55. package/types/index.d.ts +3 -0
  56. package/types/program.d.ts +36 -18
@@ -1,3 +1,5 @@
1
+ import { normalizeFacetGap } from "./facetGap.js";
2
+ import { normalizeFacetAxes } from "./facetAxisPolicy.js";
1
3
  import { isOwned, isPlainObject } from "./immutable.js";
2
4
  import { ownProgramState } from "./programState.js";
3
5
  import {
@@ -43,6 +45,7 @@ function validateFacetSpec(compositionSpec) {
43
45
  }
44
46
  const grid = compositionSpec.facet.grid;
45
47
  const repeat = compositionSpec.facet.repeat;
48
+ if (grid !== undefined && compositionSpec.spacing === "content") throw new Error("Content flow requires a wrapped facet.");
46
49
  if (grid !== undefined && repeat !== undefined) {
47
50
  throw new Error("Facet compositionSpec cannot combine grid and repeat recipes.");
48
51
  }
@@ -204,10 +207,10 @@ function validateFacetSpec(compositionSpec) {
204
207
  );
205
208
  }
206
209
  const guides = compositionSpec.facet.guides;
210
+ normalizeFacetAxes(guides?.axes, "each", { stored: true });
207
211
  if (
208
212
  !isPlainObject(guides) ||
209
213
  Object.keys(guides).some(key => !["axes", "legend"].includes(key)) ||
210
- !["each", "outer"].includes(guides.axes) ||
211
214
  (![false, "shared"].includes(guides.legend) && !isPlainObject(guides.legend))
212
215
  ) {
213
216
  throw new Error(
@@ -240,7 +243,9 @@ function validateCompositionChildren(compositionSpec, childIds, facet) {
240
243
  }
241
244
 
242
245
  function validateLayout(compositionSpec) {
243
- if (!Number.isFinite(compositionSpec.gap) || compositionSpec.gap < 0) {
246
+ if (compositionSpec.type === "facet") {
247
+ normalizeFacetGap(compositionSpec.gap, "compositionSpec.gap");
248
+ } else if (!Number.isFinite(compositionSpec.gap) || compositionSpec.gap < 0) {
244
249
  throw new RangeError("compositionSpec.gap must be a non-negative finite number.");
245
250
  }
246
251
  if (!["start", "center", "end"].includes(compositionSpec.align)) {
@@ -289,8 +294,8 @@ export function ownCompositionSpec(compositionSpec, children) {
289
294
  throw new TypeError("compositionSpec.id must be a non-empty string.");
290
295
  }
291
296
  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 plot spacing.");
297
+ if (compositionSpec.spacing !== undefined && (!facet || !["plot", "canvas", "content"].includes(compositionSpec.spacing))) {
298
+ throw new Error("compositionSpec.spacing requires a facet and canvas, plot, or content spacing.");
294
299
  }
295
300
  if (compositionSpec.topLane !== undefined && (!facet ||
296
301
  !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
+ }
@@ -0,0 +1,44 @@
1
+ import { cloneAndFreeze, isPlainObject } from "./immutable.js";
2
+ import { annotateError } from "./diagnostics.js";
3
+ export function validateCompositionSpacing(value, label) {
4
+ if (!Number.isFinite(value) || value < 0) {
5
+ throw new RangeError(`${label} must be a non-negative finite number.`);
6
+ }
7
+ return value;
8
+ }
9
+
10
+ /** Keep scalar intent intact; a facet pair always declares both axes. */
11
+ export function normalizeFacetGap(value, label = "Facet gap") {
12
+ if (!isPlainObject(value)) return validateCompositionSpacing(value, label);
13
+ const keys = Reflect.ownKeys(value);
14
+ if (keys.length !== 2 || !keys.includes("x") || !keys.includes("y")) {
15
+ throw annotateError(new TypeError(`${label} must contain exactly x and y.`), {
16
+ code: "invalid-value", optionPath: "gap", actual: value
17
+ });
18
+ }
19
+ const result = {};
20
+ for (const axis of ["x", "y"]) {
21
+ try {
22
+ result[axis] = validateCompositionSpacing(value[axis], `${label}.${axis}`);
23
+ } catch (error) {
24
+ throw annotateError(error, {
25
+ code: "invalid-value", optionPath: `gap.${axis}`, actual: value[axis], limit: 0
26
+ });
27
+ }
28
+ }
29
+ return cloneAndFreeze(result);
30
+ }
31
+
32
+ /** Accept only a previously normalized scalar or pair. */
33
+ export function facetGapAxis(gap, axis) {
34
+ return typeof gap === "number" ? gap : gap[axis];
35
+ }
36
+
37
+ /** Overflow remains axis-specific for a pair; scalar callers keep their old error. */
38
+ export function assertFacetGapFinite(gap, axis, values, message) {
39
+ if (values.every(Number.isFinite)) return;
40
+ const error = new RangeError(message);
41
+ throw typeof gap === "number" ? error : annotateError(error, {
42
+ code: "invalid-value", optionPath: `gap.${axis}`, actual: gap[axis]
43
+ });
44
+ }
@@ -1 +1 @@
1
- import{resolveTextLines as l}from"../core/textMetrics.js";function a(e,{title:i=!1}={}){if(Object.hasOwn(e,"align")&&!["left","center","right"].includes(e.align))throw new TypeError("Axis text align must be left, center, or right.");if(i){if(Object.hasOwn(e,"overlap")&&!["error","allow"].includes(e.overlap))throw new TypeError("Axis title overlap must be error or allow.");if(Object.hasOwn(e,"baseline")&&!["top","hanging","middle","alphabetic","ideographic","bottom"].includes(e.baseline))throw new TypeError("Axis title baseline must be a supported text baseline.");for(const t of["offset","alongOffset"])if(Object.hasOwn(e,t)&&!Number.isFinite(e[t]))throw new TypeError(`Axis title ${t} must be a finite number.`)}}function f(e,i){if(!/[\r\n]/u.test(e))return;const t=i.baseline??"middle",n=l(e,{fontSize:i.fontSize,align:i.align??"center",baseline:t,...t==="middle"?{blockAlign:"middle"}:{}});if(["bottom","ideographic"].includes(t)){const o=(n.length-1)*i.fontSize*1.2;return n.map(r=>({...r,y:r.y-o}))}return n.length>1?n:void 0}function b(e){return e?.overlap==="allow"}export{b as axisTitleAllowsLabelOverlap,f as axisTitleTextLines,a as validateAxisTextPlacement};
1
+ import{validateKeys as a,validateNonNegativeFinite as o}from"../core/validation.js";import{isPlainObject as s}from"../core/immutable.js";import{resolveTextLines as b}from"../core/textMetrics.js";function c(e,{title:t=!1}={}){if(Object.hasOwn(e,"align")&&!["left","center","right"].includes(e.align))throw new TypeError("Axis text align must be left, center, or right.");if(t){if(Object.hasOwn(e,"overlap")&&!["error","allow"].includes(e.overlap))throw new TypeError("Axis title overlap must be error or allow.");if(Object.hasOwn(e,"baseline")&&!["top","hanging","middle","alphabetic","ideographic","bottom"].includes(e.baseline))throw new TypeError("Axis title baseline must be a supported text baseline.");if(Object.hasOwn(e,"offset")&&s(e.offset)){if(a(e.offset,["from","gap"],"Axis title offset"),e.offset.from!=="labels"||!Object.hasOwn(e.offset,"gap"))throw new TypeError("Axis title offset requires from: labels and gap.");o(e.offset.gap,"Axis title label gap")}for(const i of["offset","alongOffset"])if(!(i==="offset"&&f(e.offset))&&Object.hasOwn(e,i)&&!Number.isFinite(e[i]))throw new TypeError(`Axis title ${i} must be a finite number.`)}}function f(e){return s(e)&&e.from==="labels"}function h(e){return e?.inferredOffset===!0||f(e?.offset)}function m(e){return(e?.lines??[{text:e?.text}]).some(t=>typeof t.text=="string"&&t.text.length>0)}function w(e,t){if(!/[\r\n]/u.test(e))return;const i=t.baseline??"middle",r=b(e,{fontSize:t.fontSize,align:t.align??"center",baseline:i,...i==="middle"?{blockAlign:"middle"}:{}});if(["bottom","ideographic"].includes(i)){const l=(r.length-1)*t.fontSize*1.2;return r.map(n=>({...n,y:n.y-l}))}return r.length>1?r:void 0}function u(e){return e?.overlap==="allow"}function g(e,{allowReset:t=!1}={}){e===void 0||t&&e==="auto"||o(e,"Label flushThreshold")}export{m as axisLabelHasText,u as axisTitleAllowsLabelOverlap,h as axisTitleFollowsLabels,w as axisTitleTextLines,f as isLabelRelativeTitleOffset,g as validateAxisLabelFlushThreshold,c as validateAxisTextPlacement};
@@ -11,7 +11,7 @@ const MAX_CELLS = 1_000_000;
11
11
 
12
12
  const TRANSFORM_KEYS = Object.freeze([
13
13
  "type", "x", "y", "bins", "extent", "includeEmpty", "members", "as",
14
- "resolved"
14
+ "resolved", "assignment"
15
15
  ]);
16
16
  const AXIS_KEYS = Object.freeze(["x", "y"]);
17
17
  const FIELD_KEYS = Object.freeze(["x0", "x1", "y0", "y1", "count", "members"]);
@@ -180,6 +180,11 @@ export function validateBin2DTransform(transform) {
180
180
  }
181
181
  requireField(transform.x, "2D bin x field");
182
182
  requireField(transform.y, "2D bin y field");
183
+ if (transform.assignment !== undefined && transform.assignment !== "step") {
184
+ throw new TypeError(
185
+ 'Stored 2D bin assignment must be "step" or omitted.'
186
+ );
187
+ }
183
188
  validateBins(transform.bins);
184
189
  validateRequestedExtent(transform.extent);
185
190
  if (typeof transform.includeEmpty !== "boolean") {
@@ -201,10 +206,16 @@ export function normalizeBin2DTransform({
201
206
  y,
202
207
  bins,
203
208
  extent,
209
+ assignment = "edges",
204
210
  includeEmpty = false,
205
211
  members = false,
206
212
  as
207
213
  } = {}) {
214
+ if (assignment !== "edges" && assignment !== "step") {
215
+ throw new TypeError(
216
+ '2D bin assignment must be "edges" or "step".'
217
+ );
218
+ }
208
219
  const normalizedBins = normalizeBins(bins);
209
220
  const transform = {
210
221
  type: "bin2d",
@@ -214,7 +225,8 @@ export function normalizeBin2DTransform({
214
225
  extent: normalizeRequestedExtent(extent),
215
226
  includeEmpty,
216
227
  members,
217
- as: normalizeOutputFields(id, as, members)
228
+ as: normalizeOutputFields(id, as, members),
229
+ ...(assignment === "step" ? { assignment } : {})
218
230
  };
219
231
  validateBin2DTransform(transform);
220
232
  return cloneAndFreeze(transform);
@@ -266,6 +278,20 @@ function findBinIndex(value, edges) {
266
278
  return low;
267
279
  }
268
280
 
281
+ function findStepBinIndex(value, extent, count) {
282
+ if (count === 1 || value === extent[1]) return count - 1;
283
+ const [minimum, maximum] = extent;
284
+ const span = maximum - minimum;
285
+ const step = Number.isFinite(span)
286
+ ? span / count
287
+ : maximum / count - minimum / count;
288
+ const distance = value - minimum;
289
+ const position = Number.isFinite(distance)
290
+ ? distance / step
291
+ : value / step - minimum / step;
292
+ return Math.max(0, Math.min(count - 1, Math.floor(1e-14 + position)));
293
+ }
294
+
269
295
  export function deriveBin2DRows(rows, transform) {
270
296
  if (!Array.isArray(rows)) {
271
297
  throw new TypeError("2D bin source rows must be an array.");
@@ -304,8 +330,12 @@ export function deriveBin2DRows(rows, transform) {
304
330
  () => []
305
331
  );
306
332
  for (const item of eligible) {
307
- const xIndex = findBinIndex(item.row[transform.x], edges.x);
308
- const yIndex = findBinIndex(item.row[transform.y], edges.y);
333
+ const xIndex = transform.assignment === "step"
334
+ ? findStepBinIndex(item.row[transform.x], extent.x, transform.bins.x)
335
+ : findBinIndex(item.row[transform.x], edges.x);
336
+ const yIndex = transform.assignment === "step"
337
+ ? findStepBinIndex(item.row[transform.y], extent.y, transform.bins.y)
338
+ : findBinIndex(item.row[transform.y], edges.y);
309
339
  buckets[yIndex * transform.bins.x + xIndex].push(item.index);
310
340
  }
311
341
  const values = [];
@@ -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 buildMonotoneCommands(points) {
200
- const tangents = monotoneTangents(points);
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 === "regressionStatistics" && planned.storageType === "number"
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 content.");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
+ 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
- if (!["each", "outer"].includes(axes)) {
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 === "each" ? presentAxes : records === undefined
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
+ }
@@ -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
- if (!Object.hasOwn(row, transform.field)) {
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,