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.
Files changed (34) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +81 -89
  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/interval-regression.js +2 -2
  9. package/src/actions/data/edit.js +1 -1
  10. package/src/actions/data/timeUnit.js +1 -1
  11. package/src/actions/facets/actions.js +1 -1
  12. package/src/actions/guides/legends/categorical/components.js +1 -4
  13. package/src/actions/regression/create.js +6 -1
  14. package/src/actions/regression/edit.js +10 -1
  15. package/src/core/compositionState.js +5 -3
  16. package/src/core/facetAxisPolicy.js +30 -0
  17. package/src/grammar/curveCommands.js +35 -2
  18. package/src/grammar/datasetSchema.js +1 -1
  19. package/src/grammar/facetHeaders.js +1 -1
  20. package/src/grammar/facets/guides.js +7 -5
  21. package/src/grammar/regression/parameters.js +7 -0
  22. package/src/grammar/timeUnit.js +9 -3
  23. package/src/inspectionDescriptors.js +1 -1
  24. package/src/layout/facets.js +1 -1
  25. package/src/layout/guideCollisions.js +9 -3
  26. package/src/materialization/facetContentBounds.js +96 -0
  27. package/src/materialization/facetGuides/layout.js +1 -1
  28. package/src/materialization/facetGuides/placement.js +16 -0
  29. package/src/materialization/facetHeaders.js +1 -1
  30. package/src/materialization/facets.js +1 -1
  31. package/src/materialization/graphicHierarchy.js +25 -1
  32. package/src/persistence/validation.js +6 -0
  33. package/src/version.js +1 -1
  34. 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 plot spacing.");
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 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,