ggaction 0.0.18 → 0.0.20

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 (164) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +6 -2
  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 +1 -1
  7. package/src/actions/canvas/fitting.js +6 -2
  8. package/src/actions/charts/heatmap.js +4 -3
  9. package/src/actions/data/derived.js +11 -1
  10. package/src/actions/data/edit.js +2 -0
  11. package/src/actions/data/index.js +4 -0
  12. package/src/actions/data/regression.js +24 -16
  13. package/src/actions/data/regressionStatistics.js +73 -0
  14. package/src/actions/data/shared.js +5 -2
  15. package/src/actions/distributions/revision.js +24 -18
  16. package/src/actions/encodings/color/continuous.js +7 -1
  17. package/src/actions/encodings/remove.js +11 -6
  18. package/src/actions/encodings/shared.js +26 -24
  19. package/src/actions/facets/actions.js +18 -7
  20. package/src/actions/facets/guides.js +2 -1
  21. package/src/actions/guides/axes/axes.js +30 -11
  22. package/src/actions/guides/axes/axis.js +58 -8
  23. package/src/actions/guides/axes/components.js +13 -13
  24. package/src/actions/guides/axes/edit.js +36 -24
  25. package/src/actions/guides/axes/labels.js +37 -27
  26. package/src/actions/guides/axes/lines.js +22 -20
  27. package/src/actions/guides/axes/remove.js +10 -6
  28. package/src/actions/guides/axes/tickGroups.js +8 -7
  29. package/src/actions/guides/axes/ticks.js +31 -22
  30. package/src/actions/guides/axes/titles.js +53 -60
  31. package/src/actions/guides/facadeAxes.js +33 -24
  32. package/src/actions/guides/facadeLegend.js +3 -3
  33. package/src/actions/guides/grids/grid.js +1 -1
  34. package/src/actions/guides/grids/resolve.js +48 -51
  35. package/src/actions/guides/legends/blocks.js +43 -19
  36. package/src/actions/guides/legends/categorical/actions.js +32 -9
  37. package/src/actions/guides/legends/categorical/components.js +3 -10
  38. package/src/actions/guides/legends/categorical/index.js +2 -0
  39. package/src/actions/guides/legends/categorical/layout.js +9 -13
  40. package/src/actions/guides/legends/categorical/options.js +8 -2
  41. package/src/actions/guides/legends/categorical/recipes.js +7 -3
  42. package/src/actions/guides/legends/categorical/resolve.js +9 -2
  43. package/src/actions/guides/legends/categorical/symbols.js +22 -8
  44. package/src/actions/guides/legends/colorSize.js +110 -0
  45. package/src/actions/guides/legends/combine.js +37 -0
  46. package/src/actions/guides/legends/continuous/colorSymbols.js +1 -1
  47. package/src/actions/guides/legends/continuous/common.js +1 -1
  48. package/src/actions/guides/legends/continuous/gradient.js +27 -13
  49. package/src/actions/guides/legends/continuous/opacity.js +16 -11
  50. package/src/actions/guides/legends/creation.js +2 -0
  51. package/src/actions/guides/legends/edit.js +1 -1
  52. package/src/actions/guides/legends/focused.js +6 -5
  53. package/src/actions/guides/legends/lane.js +1 -1
  54. package/src/actions/guides/legends/lifecycle.js +2 -1
  55. package/src/actions/guides/legends/remove.js +6 -3
  56. package/src/actions/guides/legends/size.js +16 -6
  57. package/src/actions/guides/legends/strokeWidth.js +2 -0
  58. package/src/actions/guides/legends/target.js +2 -79
  59. package/src/actions/guides/legends/transition.js +24 -5
  60. package/src/actions/guides/tickValues.js +8 -5
  61. package/src/actions/marks/area/materialize.js +1 -190
  62. package/src/actions/marks/bar/edit.js +23 -12
  63. package/src/actions/marks/bar/materialize.js +6 -1
  64. package/src/actions/marks/point/materialize.js +5 -1
  65. package/src/actions/marks/remove.js +10 -10
  66. package/src/actions/primitives/createGraphics.js +3 -2
  67. package/src/actions/primitives/editGraphics.js +15 -11
  68. package/src/actions/primitives/graphicProperties.js +1 -6
  69. package/src/actions/primitives/semanticAction.js +27 -1
  70. package/src/actions/primitives/semanticValidation/guide.js +11 -1
  71. package/src/actions/primitives/semanticValidation/layer.js +4 -0
  72. package/src/actions/regression/edit.js +15 -3
  73. package/src/actions/scales/channels.js +2 -2
  74. package/src/actions/scales/consumers/common.js +7 -1
  75. package/src/actions/scales/create.js +3 -3
  76. package/src/actions/scales/definitions.js +1 -1
  77. package/src/actions/scales/edit.js +1 -1
  78. package/src/actions/scales/editPolicy.js +3 -0
  79. package/src/actions/scales/patch.js +1 -1
  80. package/src/actions/scales/quantitativeColor.js +8 -3
  81. package/src/actions/theme/reconcile.js +50 -23
  82. package/src/core/compositionState.js +2 -2
  83. package/src/core/graphicProperties.js +1 -0
  84. package/src/core/programState.js +4 -0
  85. package/src/core/resourceReferences.js +2 -2
  86. package/src/core/validation.js +9 -0
  87. package/src/core/vocabulary.js +3 -0
  88. package/src/grammar/aggregate.js +3 -2
  89. package/src/grammar/areaSeries.js +1 -415
  90. package/src/grammar/axisTextPlacement.js +1 -0
  91. package/src/grammar/colorSizeLegend.js +39 -0
  92. package/src/grammar/computed.js +23 -1
  93. package/src/grammar/datasetSchema.js +10 -2
  94. package/src/grammar/displayLabels.js +8 -0
  95. package/src/grammar/facets/guides.js +54 -31
  96. package/src/grammar/facets/legendIntent.js +65 -0
  97. package/src/grammar/guideDash.js +1 -0
  98. package/src/grammar/interval.js +22 -6
  99. package/src/grammar/legendBlocks.js +1 -0
  100. package/src/grammar/legendBorder.js +9 -0
  101. package/src/grammar/legendClip.js +19 -0
  102. package/src/grammar/legendCombinations.js +45 -0
  103. package/src/grammar/legendGradient.js +9 -0
  104. package/src/grammar/legendGroups.js +1 -1
  105. package/src/grammar/legendOpacity.js +6 -0
  106. package/src/grammar/pathSeries.js +2 -2
  107. package/src/grammar/pointShapes.js +16 -2
  108. package/src/grammar/regression/derive.js +9 -1
  109. package/src/grammar/regression/models.js +60 -0
  110. package/src/grammar/regression/parameters.js +9 -1
  111. package/src/grammar/regression/statistics.js +64 -0
  112. package/src/grammar/scales/color.js +1 -1
  113. package/src/grammar/scales/colorConsumers.js +6 -3
  114. package/src/grammar/scales/definition.js +1 -1
  115. package/src/grammar/scales/types.js +1 -1
  116. package/src/grammar/schemas/concreteGraphic.js +3 -0
  117. package/src/grammar/schemas/graphic.js +3 -0
  118. package/src/grammar/schemas/graphicBounds.js +31 -7
  119. package/src/grammar/schemas/semanticPath.js +17 -2
  120. package/src/grammar/statistics/confidenceInterval.js +1 -101
  121. package/src/grammar/summary.js +1 -320
  122. package/src/grammar/timeUnit.js +6 -4
  123. package/src/grammar/transformTopology.js +1 -0
  124. package/src/grammar/transforms.js +11 -1
  125. package/src/inspectionDescriptors.js +1 -1
  126. package/src/layout/guideCollisions.js +9 -1
  127. package/src/layout/legendGroup.js +1 -1
  128. package/src/materialization/compositionSnapshot.js +2 -7
  129. package/src/materialization/coordinateBounds.js +17 -4
  130. package/src/materialization/facetGuides/edit.js +30 -0
  131. package/src/materialization/facetGuides/layout.js +1 -1
  132. package/src/materialization/facetGuides/owner.js +125 -0
  133. package/src/materialization/facetGuides/ownerLayout.js +71 -0
  134. package/src/materialization/facetGuides/placement.js +52 -6
  135. package/src/materialization/facetGuides/preparation.js +7 -0
  136. package/src/materialization/facets.js +1 -1
  137. package/src/materialization/graphicHierarchy.js +5 -1
  138. package/src/materialization/graphicTranslation.js +73 -0
  139. package/src/materialization/guides/layout.js +1 -1
  140. package/src/materialization/guides/resources.js +1 -1
  141. package/src/materialization/legendBorder.js +28 -0
  142. package/src/materialization/legendClip.js +76 -0
  143. package/src/materialization/legendGroups.js +1 -1
  144. package/src/materialization/legends.js +1 -1
  145. package/src/materialization/marks/grouping.js +1 -1
  146. package/src/materialization/scaleGuideDependencies.js +13 -14
  147. package/src/materialization/scales/map.js +3 -0
  148. package/src/materialization/scales/resolve.js +2 -0
  149. package/src/materialization/typography.js +10 -2
  150. package/src/persistence/validation.js +174 -1
  151. package/src/persistence.js +1 -1
  152. package/src/renderers/canvas/index.js +20 -10
  153. package/src/renderers/canvas/isolated.js +166 -0
  154. package/src/renderers/canvas/limits.js +3 -0
  155. package/src/renderers/canvas/rect.js +1 -1
  156. package/src/renderers/pdf.js +2 -2
  157. package/src/renderers/png.js +2 -2
  158. package/src/renderers/svg.js +10 -3
  159. package/src/selectors/axes.js +1 -0
  160. package/src/selectors/graphicCapacity.js +38 -0
  161. package/src/version.js +1 -1
  162. package/types/basic.d.ts +2 -0
  163. package/types/index.d.ts +7 -0
  164. package/types/program.d.ts +220 -64
@@ -3,13 +3,15 @@ import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
3
3
  const LEGEND_FAMILIES = Object.freeze({
4
4
  series: "categorical",
5
5
  color: "categorical",
6
+ colorSize: "color-size",
6
7
  stroke: "categorical",
7
8
  gradient: "gradient",
8
9
  interval: "discretized",
9
10
  strokeGradient: "gradient",
10
11
  strokeInterval: "discretized",
11
12
  size: "size",
12
- opacity: "opacity"
13
+ opacity: "opacity",
14
+ strokeWidth: "stroke-width"
13
15
  });
14
16
 
15
17
  function requirePlacements(placements) {
@@ -72,25 +74,34 @@ function translatedBounds(placement, bounds, channel) {
72
74
  };
73
75
  }
74
76
 
77
+ export function resolveFacetOuterSides({ placements } = {}) {
78
+ const cells = requirePlacements(placements);
79
+ const edges = { top: new Map(), bottom: new Map(), left: new Map(), right: new Map() };
80
+ for (const cell of cells) {
81
+ for (const [side, key, coordinate, better] of [
82
+ ["top", cell.column, "row", (a, b) => a < b],
83
+ ["bottom", cell.column, "row", (a, b) => a > b],
84
+ ["left", cell.row, "column", (a, b) => a < b],
85
+ ["right", cell.row, "column", (a, b) => a > b]
86
+ ]) {
87
+ const previous = edges[side].get(key);
88
+ if (previous === undefined || better(cell[coordinate], previous[coordinate])) edges[side].set(key, cell);
89
+ }
90
+ }
91
+ return cloneAndFreeze(Object.fromEntries(Object.entries(edges).map(([side, owners]) => {
92
+ const ids = new Set([...owners.values()].map(cell => cell.id));
93
+ return [side, cells.filter(cell => ids.has(cell.id)).map(cell => cell.id)];
94
+ })));
95
+ }
96
+
75
97
  export function resolveFacetOuterAxes({ placements, axisBounds = {} } = {}) {
76
98
  const cells = requirePlacements(placements);
77
99
  if (!isPlainObject(axisBounds)) {
78
100
  throw new TypeError("Facet axisBounds must be a plain object.");
79
101
  }
80
- const bottomByColumn = new Map();
81
- const leftByRow = new Map();
82
- for (const cell of cells) {
83
- const bottom = bottomByColumn.get(cell.column);
84
- if (bottom === undefined || cell.row > bottom.row) {
85
- bottomByColumn.set(cell.column, cell);
86
- }
87
- const left = leftByRow.get(cell.row);
88
- if (left === undefined || cell.column < left.column) {
89
- leftByRow.set(cell.row, cell);
90
- }
91
- }
92
- const xOwners = new Set([...bottomByColumn.values()].map(cell => cell.id));
93
- const yOwners = new Set([...leftByRow.values()].map(cell => cell.id));
102
+ const sides = resolveFacetOuterSides({ placements: cells });
103
+ const xOwners = new Set(sides.bottom);
104
+ const yOwners = new Set(sides.left);
94
105
  return cloneAndFreeze({
95
106
  x: cells.filter(cell => xOwners.has(cell.id)).map(cell => cell.id),
96
107
  y: cells.filter(cell => yOwners.has(cell.id)).map(cell => cell.id),
@@ -129,7 +140,7 @@ function comparableConfig(config) {
129
140
  return stableValue(rest);
130
141
  }
131
142
 
132
- function sameValue(left, right) {
143
+ export function sameFacetGuideValue(left, right) {
133
144
  return JSON.stringify(stableValue(left)) === JSON.stringify(stableValue(right));
134
145
  }
135
146
 
@@ -152,12 +163,13 @@ function requireLegendChildren(children) {
152
163
  });
153
164
  }
154
165
 
155
- function scaleIds(config) {
156
- const ids = config.scales ?? (config.scale === undefined ? [] : [config.scale]);
166
+ function scaleIds(config, kind) {
167
+ const ids = kind === "colorSize" ? [config.colorScale, config.sizeScale]
168
+ : config.scales ?? (config.scale === undefined ? [] : [config.scale]);
157
169
  if (!Array.isArray(ids) || ids.length === 0 || !ids.every(id =>
158
170
  typeof id === "string" && id.length > 0
159
171
  )) {
160
- throw new Error(`Shared ${config.kind} legend requires resolved scale ids.`);
172
+ throw new Error(`Shared ${kind} legend requires resolved scale ids.`);
161
173
  }
162
174
  return ids;
163
175
  }
@@ -166,7 +178,7 @@ export function resolveSharedFacetLegends(children) {
166
178
  const normalized = requireLegendChildren(children);
167
179
  const first = normalized[0];
168
180
  for (const child of normalized.slice(1)) {
169
- if (!sameValue(first.guideConfigs.legendGroups, child.guideConfigs.legendGroups)) {
181
+ if (!sameFacetGuideValue(first.guideConfigs.legendGroups, child.guideConfigs.legendGroups)) {
170
182
  throw new Error(`Facet child "${child.id}" has incompatible legend group placement.`);
171
183
  }
172
184
  }
@@ -183,7 +195,7 @@ export function resolveSharedFacetLegends(children) {
183
195
  ) {
184
196
  throw new Error(`Facet child "${first.id}" has invalid ${kind} legend config.`);
185
197
  }
186
- const scales = scaleIds(config);
198
+ const scales = scaleIds(config, kind);
187
199
  const resolvedScales = Object.fromEntries(scales.map(id => {
188
200
  const scale = first.resolvedScales[id];
189
201
  if (!isPlainObject(scale)) {
@@ -195,14 +207,14 @@ export function resolveSharedFacetLegends(children) {
195
207
  const candidate = child.legends[kind];
196
208
  if (
197
209
  !isPlainObject(candidate) ||
198
- !sameValue(comparableConfig(config), comparableConfig(candidate))
210
+ !sameFacetGuideValue(comparableConfig(config), comparableConfig(candidate))
199
211
  ) {
200
212
  throw new Error(
201
213
  `Facet child "${child.id}" has an incompatible ${kind} legend config.`
202
214
  );
203
215
  }
204
216
  for (const id of scales) {
205
- if (!sameValue(resolvedScales[id], child.resolvedScales[id])) {
217
+ if (!sameFacetGuideValue(resolvedScales[id], child.resolvedScales[id])) {
206
218
  throw new Error(
207
219
  `Facet child "${child.id}" has an incompatible resolved legend scale "${id}".`
208
220
  );
@@ -213,7 +225,7 @@ export function resolveSharedFacetLegends(children) {
213
225
  });
214
226
  for (const child of normalized.slice(1)) {
215
227
  const childKinds = Object.keys(child.legends);
216
- if (!sameValue([...kinds].sort(), [...childKinds].sort())) {
228
+ if (!sameFacetGuideValue([...kinds].sort(), [...childKinds].sort())) {
217
229
  throw new Error(`Facet child "${child.id}" has incompatible legend kinds.`);
218
230
  }
219
231
  }
@@ -234,7 +246,7 @@ export function planFacetGuideOwnership({
234
246
  if (!["each", "outer"].includes(axes)) {
235
247
  throw new Error("Facet axes must be each or outer.");
236
248
  }
237
- if (![false, "shared"].includes(legend)) {
249
+ if (![false, "shared"].includes(legend) && !isPlainObject(legend)) {
238
250
  throw new Error("Facet legend must be false or shared.");
239
251
  }
240
252
  const byId = new Map(children.map(child => [child.id, child]));
@@ -242,23 +254,34 @@ export function planFacetGuideOwnership({
242
254
  throw new Error("Facet guide child ids must match occupied placements exactly.");
243
255
  }
244
256
  const outer = resolveFacetOuterAxes({ placements: cells });
245
- if (legend === "shared" && !isPlainObject(sharedLegends)) {
257
+ const sides = resolveFacetOuterSides({ placements: cells });
258
+ if (legend !== false && !isPlainObject(sharedLegends)) {
246
259
  throw new Error("Shared facet legend ownership requires a compatibility plan.");
247
260
  }
248
261
  return cloneAndFreeze({
249
262
  children: Object.fromEntries(cells.map(cell => {
250
263
  const descriptor = byId.get(cell.id);
251
264
  const presentAxes = descriptor.axes ?? [];
252
- const keepAxes = axes === "each"
253
- ? presentAxes
254
- : presentAxes.filter(channel => outer.children[cell.id][channel]);
265
+ const records = descriptor.axisOwners;
266
+ const removed = {};
267
+ const keepAxes = axes === "each" ? presentAxes : records === undefined
268
+ ? presentAxes.filter(channel => outer.children[cell.id][channel])
269
+ : presentAxes.filter(id => {
270
+ const owner = records.find(owner => owner.id === id);
271
+ if (owner === undefined) throw new Error("Facet axis descriptors must identify every retained owner.");
272
+ const components = Object.entries(owner.components);
273
+ const remove = components.filter(([, side]) => !sides[side]?.includes(cell.id)).map(([component]) => component);
274
+ if (remove.length > 0 && remove.length < components.length) removed[id] = remove;
275
+ return remove.length < components.length;
276
+ });
255
277
  return [cell.id, {
256
278
  keepAxes,
257
279
  removeAxes: presentAxes.filter(channel => !keepAxes.includes(channel)),
258
- removeLegends: legend === "shared" ? descriptor.legendKinds ?? [] : []
280
+ ...(records === undefined ? {} : { removeAxisComponents: removed }),
281
+ removeLegends: legend !== false ? descriptor.legendKinds ?? [] : []
259
282
  }];
260
283
  })),
261
- parent: legend === "shared"
284
+ parent: legend !== false
262
285
  ? { legends: sharedLegends.entries, promoteFrom: sharedLegends.source }
263
286
  : { legends: [] }
264
287
  });
@@ -0,0 +1,65 @@
1
+ import { isPlainObject } from "../../core/immutable.js";
2
+ import { validateOptionObject, validateNonEmptyString } from "../../core/validation.js";
3
+ import { validateUserId } from "../../core/identifiers.js";
4
+ import { LEGEND_CONFIG_KINDS, LEGEND_POSITIONS } from "../../core/vocabulary.js";
5
+ import { describeLegendBlock } from "../legendBlocks.js";
6
+ import { validateLegendGroupState } from "../legendGroups.js";
7
+ import { resolveSharedFacetLegends, sameFacetGuideValue as same } from "./guides.js";
8
+
9
+ const CONFIG_KEYS = Object.freeze([
10
+ "kind", "target", "channels", "scales", "scale", "field", "fieldType", "domain", "title", "titleVisible",
11
+ "inferredTitle", "inferredSymbol", "inferredStrokeWidth", "inferredOffset", "position", "align", "offset", "direction", "columns",
12
+ "titlePosition", "symbol", "labels", "titleStyle", "itemGap", "layout", "overflow", "border", "gradient",
13
+ "count", "sampling", "representation", "gradientDirection", "inheritAppearance", "inheritPosition", "blockOverrides",
14
+ "combine", "sizeAreas", "clipHeight"
15
+ ]);
16
+
17
+ export function isAuthoredFacetLegend(value) {
18
+ return isPlainObject(value) && value.type === "authored-legend";
19
+ }
20
+
21
+ export function validateAuthoredFacetLegend(program, intent) {
22
+ validateOptionObject(intent, ["type", "blocks", "groups"], "Stored parent legend");
23
+ if (!isAuthoredFacetLegend(intent) || Object.keys(intent).length !== 3 ||
24
+ !isPlainObject(intent.blocks) || Object.keys(intent.blocks).length === 0 || !isPlainObject(intent.groups)) {
25
+ throw new Error("Stored parent legend requires its closed authored block intent.");
26
+ }
27
+ validateLegendGroupState(intent.groups);
28
+ const configs = {};
29
+ for (const [kind, block] of Object.entries(intent.blocks)) {
30
+ if (!LEGEND_CONFIG_KINDS.includes(kind)) throw new Error("Stored parent legend has an unknown block kind.");
31
+ validateOptionObject(block, ["config", "guide"], "Stored parent legend block");
32
+ validateOptionObject(block.config, kind === "colorSize" ? [...CONFIG_KEYS, "colorScale", "sizeScale"] : CONFIG_KEYS, "Stored parent legend config");
33
+ const config = block.config;
34
+ validateUserId(config.target, "Parent legend target");
35
+ if (!LEGEND_POSITIONS.includes(config.position)) throw new Error("Stored parent legend requires a native position.");
36
+ for (const flag of ["inferredTitle", "inferredSymbol", "inferredStrokeWidth", "inferredOffset", "titleVisible", "inheritAppearance", "inheritPosition"]) {
37
+ if (config[flag] !== undefined && typeof config[flag] !== "boolean") throw new Error(`Stored parent legend ${flag} must be boolean.`);
38
+ }
39
+ const descriptor = describeLegendBlock(program, kind, config);
40
+ if (block.guide !== undefined) {
41
+ validateOptionObject(block.guide, kind === "colorSize" ? ["colorScale", "sizeScale", "title"]
42
+ : ["scale", "scales", "channels", "title", "order"], "Stored parent legend meaning");
43
+ if (kind === "colorSize" && (block.guide.colorScale !== config.colorScale || block.guide.sizeScale !== config.sizeScale)) {
44
+ throw new Error("Stored parent color-size meaning must match both block scale bindings.");
45
+ }
46
+ if (block.guide.title !== undefined) validateNonEmptyString(block.guide.title, "Parent legend title");
47
+ if (block.guide.channels !== undefined && !same([...block.guide.channels].sort(), descriptor.channels)) {
48
+ throw new Error("Stored parent legend meaning must match its block channels.");
49
+ }
50
+ if (block.guide.scale !== undefined && block.guide.scale !== descriptor.scaleIds[0] ||
51
+ block.guide.scales !== undefined && !same(block.guide.scales, descriptor.scaleIds)) {
52
+ throw new Error("Stored parent legend meaning must match its block scale binding.");
53
+ }
54
+ }
55
+ if (!same(config, program.guideConfigs.legend?.[kind])) throw new Error("Parent legend materialization must match its authored owner.");
56
+ configs[kind] = config;
57
+ }
58
+ if (!same(Object.keys(configs).sort(), Object.keys(program.guideConfigs.legend ?? {}).sort()) ||
59
+ !same(intent.groups, program.guideConfigs.legendGroups ?? {})) {
60
+ throw new Error("Parent legend materialization must contain exactly its authored blocks and groups.");
61
+ }
62
+ resolveSharedFacetLegends(program.compositionSpec.children.map(id => ({ id,
63
+ guideConfigs: { legend: configs, legendGroups: intent.groups }, resolvedScales: program.children[id].resolvedScales })));
64
+ return intent;
65
+ }
@@ -0,0 +1 @@
1
+ import{isPlainObject as i}from"../core/immutable.js";import{validateGeneratedItemLimit as o}from"../core/validation.js";function n(e,s="Guide strokeDash"){if(!Array.isArray(e)||e.length%2!==0||!e.every(r=>Number.isFinite(r)&&r>=0))throw new TypeError(`${s} must be an even-length array of non-negative finite numbers.`);return o(e.length,s),e}function u(e,{reset:s=!0}={}){if(e==="auto"&&s)return;if(!Array.isArray(e))throw new TypeError("Guide strokeDashMap must be an array.");o(e.length,"Guide strokeDashMap count");const r=new Set,a=e.map(t=>{if(!i(t)||Reflect.ownKeys(t).length!==2||!Object.hasOwn(t,"value")||!Object.hasOwn(t,"strokeDash"))throw new TypeError("Guide strokeDashMap entries must contain exactly value and strokeDash.");if(!["string","boolean"].includes(typeof t.value)&&!Number.isFinite(t.value))throw new TypeError("Guide strokeDashMap values must be strings, booleans, or finite numbers.");if(r.has(t.value))throw new Error("Guide strokeDashMap contains a duplicate typed value.");return r.add(t.value),n(t.strokeDash),Object.freeze({value:t.value,strokeDash:Object.freeze([...t.strokeDash])})});return a.length===0?void 0:Object.freeze(a)}function d(e,s){const r={...e};if(Object.hasOwn(s,"strokeDash")&&(n(s.strokeDash),r.strokeDash=[...s.strokeDash]),Object.hasOwn(s,"strokeDashMap")){const a=u(s.strokeDashMap);a===void 0?delete r.strokeDashMap:r.strokeDashMap=a}return r}function D(e,s){const r=new Map(s.strokeDashMap?.map(a=>[a.value,a.strokeDash]));return e.map(a=>r.get(a)??s.strokeDash??[])}export{u as normalizeGuideDashMap,d as patchGuideDash,D as resolveGuideDashes,n as validateGuideDash};
@@ -4,6 +4,9 @@ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
4
4
  import { aggregateScalarValues } from "./aggregate.js";
5
5
  import { requireFiniteResult, stableDecimal } from "./numeric.js";
6
6
  import {
7
+ createBootstrapMeanSampler,
8
+ bootstrapMeanBounds,
9
+ validateBootstrapDrawCount,
7
10
  confidenceCriticalValue,
8
11
  normalizeConfidenceInterval
9
12
  } from "./statistics/confidenceInterval.js";
@@ -91,7 +94,8 @@ export function validateIntervalTransform(transform) {
91
94
  if (transform.extent === "ci") {
92
95
  normalizeConfidenceInterval(transform, {
93
96
  defaultMethod: "student-t",
94
- label: "Interval CI"
97
+ label: "Interval CI",
98
+ allowBootstrap: true
95
99
  });
96
100
  } else if (transform.method !== undefined) {
97
101
  throw new Error("Interval method is supported only for ci extent.");
@@ -117,11 +121,12 @@ export function normalizeIntervalParameters({
117
121
  extent = "ci",
118
122
  method,
119
123
  level
120
- } = {}) {
124
+ } = {}, { allowBootstrap = false } = {}) {
121
125
  const confidence = extent === "ci"
122
126
  ? normalizeConfidenceInterval({ method, level }, {
123
127
  defaultMethod: "student-t",
124
- label: "Interval CI"
128
+ label: "Interval CI",
129
+ allowBootstrap
125
130
  })
126
131
  : undefined;
127
132
  const candidate = {
@@ -164,7 +169,11 @@ function isMissing(value) {
164
169
  }
165
170
 
166
171
 
167
- function deriveGroup(values, transform) {
172
+ function deriveGroup(values, transform, bootstrapSampler) {
173
+ if (bootstrapSampler !== undefined) {
174
+ const bounds = bootstrapMeanBounds(bootstrapSampler(values), transform.level);
175
+ return bounds === undefined ? undefined : { center: aggregateScalarValues(values, "mean"), ...bounds };
176
+ }
168
177
  if (transform.center === "median") {
169
178
  if (values.length === 0) return undefined;
170
179
  return {
@@ -235,9 +244,16 @@ export function deriveInterval(rows, transform) {
235
244
  groups.get(key).values.push(value);
236
245
  }
237
246
 
247
+ const bootstrapMethod = transform.method?.type === "bootstrap"
248
+ ? normalizeConfidenceInterval(transform, { defaultMethod: "student-t", allowBootstrap: true }).method : undefined;
249
+ if (bootstrapMethod !== undefined) {
250
+ validateBootstrapDrawCount(bootstrapMethod.samples * [...groups.values()].reduce((count, group) => count + group.values.length, 0));
251
+ }
252
+ const bootstrapSampler = bootstrapMethod === undefined ? undefined : createBootstrapMeanSampler(bootstrapMethod);
253
+
238
254
  const output = [];
239
255
  for (const group of groups.values()) {
240
- const result = deriveGroup(group.values, transform);
256
+ const result = deriveGroup(group.values, transform, bootstrapSampler);
241
257
  if (result === undefined) continue;
242
258
  output.push({
243
259
  ...group.fields,
@@ -277,7 +293,7 @@ export function normalizeIntervalTransform({
277
293
  }) {
278
294
  nonEmptyString(field, "Interval field");
279
295
  const grouping = normalizeGrouping(groupBy);
280
- const parameters = normalizeIntervalParameters({ center, extent, method, level });
296
+ const parameters = normalizeIntervalParameters({ center, extent, method, level }, { allowBootstrap: true });
281
297
  const transform = {
282
298
  type: "interval",
283
299
  field,
@@ -0,0 +1 @@
1
+ import{LEGEND_CHANNELS as d,LEGEND_CONFIG_KINDS as h}from"../core/vocabulary.js";import{isEnumeratedSizeScaleType as u}from"./scales/size.js";function y(e,r){if(!Array.isArray(e))throw new TypeError(`${r} channels must be an array.`);if(e.length===0)throw new Error(`${r} channels must select at least one channel.`);const s=new Set;for(const t of e){if(!d.includes(t))throw new Error(`Unsupported legend channel "${t}".`);if(s.has(t))throw new Error(`${r} channels contains duplicate "${t}".`);s.add(t)}}const o=Object.freeze(["series","color","stroke"]),a=Object.freeze({size:"size",opacity:"opacity",strokeWidth:"strokeWidth",gradient:"color",interval:"color",strokeGradient:"stroke",strokeInterval:"stroke"}),c=(e,r)=>e<r?-1:e>r?1:0;function w(e,r){const s=e==="colorSize"?["color","size"]:o.includes(e)?r?.channels:a[e]===void 0?void 0:[a[e]];if(!Array.isArray(s)||s.length===0)throw new Error(`Legend block ${e} requires at least one channel.`);return y(s,`Legend block ${e}`),Object.freeze([...s].sort(c))}function p(e){if(!Array.isArray(e)||e.length===0)throw new TypeError("Legend block key requires a non-empty channel array.");return JSON.stringify([...e].sort(c))}function b(e,r,s){if(!h.includes(r))throw new Error(`Unknown legend block kind "${r}".`);const t=w(r,s),n=r==="colorSize"?[s.colorScale,s.sizeScale]:o.includes(r)?s?.scales:[s?.scale];if(!Array.isArray(n)||n.length===0||n.some(l=>typeof l!="string"||e.resolvedScales[l]===void 0))throw new Error(`Legend block ${r} requires resolved scales.`);const i=o.includes(r)?"categorical":r==="colorSize"?"sampled":r==="size"?u(e.resolvedScales[n[0]].type)?"discrete-size":"sampled":["opacity","strokeWidth"].includes(r)||r==="gradient"&&s.representation==="symbols"?"sampled":["gradient","strokeGradient"].includes(r)?"gradient":"interval";return Object.freeze({target:s.target,kind:r,family:i,key:p(t),channels:t,scaleIds:Object.freeze([...n]),config:s})}function g(e,r){return e.symbol.layers.length===1&&(e.kind==="series"&&r==="line"||["color","stroke"].includes(e.kind)&&r==="swatch")?`${e.kind}LegendSymbols`:`${e.kind}LegendSymbol${{line:"Lines",point:"Points",swatch:"Swatches"}[r]}`}export{g as categoricalLegendSymbolId,b as describeLegendBlock,w as legendBlockChannels,p as legendBlockKey,y as validateLegendChannels};
@@ -0,0 +1,9 @@
1
+ // Legend borders use one closed rectangular path, so odd dash duplication is not implicit.
2
+ export function normalizeLegendBorderDash(value, label = "Legend border strokeDash") {
3
+ if (value === undefined) return undefined;
4
+ if (!Array.isArray(value) || value.length % 2 !== 0 ||
5
+ ![...value].every(item => Number.isFinite(item) && item >= 0)) {
6
+ throw new TypeError(`${label} must be an even-length array of non-negative finite numbers.`);
7
+ }
8
+ return Object.freeze([...value]);
9
+ }
@@ -0,0 +1,19 @@
1
+ export function normalizeLegendClipHeight(value,{edit=false}={}){
2
+ if(value===undefined||edit&&value==='auto')return undefined;
3
+ if(!Number.isFinite(value)||value<=0)throw new RangeError('Legend clipHeight must be a positive finite number.');
4
+ return value;
5
+ }
6
+ export function validateLegendClip(kind,config){
7
+ if(config.clipHeight===undefined)return;
8
+ normalizeLegendClipHeight(config.clipHeight);
9
+ const supported=['size','opacity','colorSize'].includes(kind)||kind==='gradient'&&config.representation==='symbols'||
10
+ ['series','color','stroke'].includes(kind)&&config.symbol?.layers?.some(layer=>layer.type==='point');
11
+ if(!supported)throw new Error('Legend clipHeight requires a point-bearing sample block.');
12
+ }
13
+ export function patchLegendClipHeight(config,value){
14
+ const {clipHeight:_old,...rest}=config,clipHeight=normalizeLegendClipHeight(value,{edit:true});
15
+ return clipHeight===undefined?rest:{...rest,clipHeight};
16
+ }
17
+ export function clippedSampleBounds(bounds,height){
18
+ return height===undefined?bounds:{...bounds,top:Math.max(bounds.top,-height/2),bottom:Math.min(bounds.bottom,height/2)};
19
+ }
@@ -0,0 +1,45 @@
1
+ import { sameOrderedValues, validateGeneratedItemLimit } from "../core/validation.js";
2
+ import { mapOrdinalValues } from "./scales/index.js";
3
+ import { readNominalField } from "./scales/fields.js";
4
+ import { resolveLegendOrderDomain } from "./categoryOrder.js";
5
+
6
+ export function isSizeShapeChannels(channels) {
7
+ return Array.isArray(channels) && channels.length === 2 &&
8
+ channels.includes('shape') && channels.includes('size');
9
+ }
10
+ export function validateLegendCombine(value) {
11
+ if(value===undefined)return;
12
+ if (!['separate','size-shape','color-size'].includes(value)) throw new Error('Legend combine must be separate, size-shape, or color-size.');
13
+ }
14
+ export function validateMappedSizeShapeSymbol(symbol) {
15
+ if(symbol===undefined||symbol==='auto')return;
16
+ const layers=symbol.layers??[];
17
+ if(layers.some(layer=>layer.type==='point'&&(Object.hasOwn(layer,'shape')||Object.hasOwn(layer,'size')))) {
18
+ throw new Error('Combined size-shape symbols cannot override mapped shape or size.');
19
+ }
20
+ }
21
+
22
+ export function resolveSizeShapeMapping({layer,scales,order,rows}) {
23
+ const shape=layer?.encoding?.shape,size=layer?.encoding?.size;
24
+ const shapeScale=scales[shape?.scale],sizeScale=scales[size?.scale];
25
+ if(layer?.mark?.type!=="point"||shape?.field!==size?.field||
26
+ !["nominal","ordinal"].includes(size?.fieldType)||shapeScale?.type!=="ordinal"||
27
+ sizeScale?.type!=="ordinal"||!sameOrderedValues(shapeScale.domain,sizeScale.domain)) {
28
+ throw new Error("Combined size-shape requires one categorical point field and identical ordered domains.");
29
+ }
30
+ validateGeneratedItemLimit(shapeScale.domain.length,"Categorical legend item count");
31
+ if(shapeScale.domain.length===0||sizeScale.range.some(area=>!Number.isFinite(area)||area<0)) {
32
+ throw new Error("Combined size-shape requires a nonempty domain and finite nonnegative areas.");
33
+ }
34
+ let linked;
35
+ if(order?.channel!==undefined){
36
+ const position=layer.encoding[order.channel];
37
+ if(!["nominal","ordinal"].includes(position?.fieldType)||position.field!==shape.field) {
38
+ throw new Error("Linked legend order requires the same categorical field on its target.");
39
+ }
40
+ linked=scales[position.scale]?.domain;
41
+ }
42
+ const domain=resolveLegendOrderDomain(shapeScale.domain,order,linked,
43
+ order?.values===undefined?undefined:readNominalField(rows,shape.field));
44
+ return {domain,sizeAreas:mapOrdinalValues(domain,sizeScale.domain,sizeScale.range)};
45
+ }
@@ -16,3 +16,12 @@ export function normalizeLegendGradient(patch, previous = DEFAULT_LEGEND_GRADIEN
16
16
  validateLegendGradient(value);
17
17
  return value;
18
18
  }
19
+
20
+
21
+ export function normalizeGradientDirection(value, label = "Gradient legend direction") {
22
+ if (value === undefined) return undefined;
23
+ if (value !== "horizontal" && value !== "vertical") {
24
+ throw new Error(`${label} must be horizontal or vertical.`);
25
+ }
26
+ return value;
27
+ }
@@ -1 +1 @@
1
- import{validateOptionObject as r,validateNonNegativeFinite as i}from"../core/validation.js";import{validateUserId as a}from"../core/identifiers.js";import{PLOT_LEGEND_POSITIONS as g,LEGEND_POSITIONS as d,LEGEND_BLOCK_LAYOUTS as f}from"../core/vocabulary.js";function s(n){r(n,void 0,"Legend groups");for(const[o,t]of Object.entries(n)){if(a(o,"Legend group target"),r(t,["placement","blockLayout","inferredOffset"],"Legend group"),!f.includes(t.blockLayout))throw new Error("Legend group blockLayout must be row, column or wrap.");if(t.inferredOffset!==void 0&&typeof t.inferredOffset!="boolean")throw new TypeError("Legend group inferredOffset must be boolean.");const e=t.placement;if(r(e,["at","position","align","offset"],"Legend group placement"),Object.hasOwn(e,"at")){if(Object.keys(e).length!==1)throw new Error("Legend group at cannot coexist with an edge anchor.");if(r(e.at,["x","y"],"Legend group at"),!Number.isFinite(e.at.x)||!Number.isFinite(e.at.y))throw new Error("Legend group at requires finite x and y.")}else{if(Object.keys(e).length!==3||!d.includes(e.position))throw new Error("Legend group requires a complete edge placement.");if(!["left","center","right"].includes(e.align))throw new Error("Unsupported legend group alignment.");if((g.includes(e.position)||["left","right"].includes(e.position))&&e.align!=="center")throw new Error("Side and in-plot legend groups require center alignment.");i(e.offset,"Legend group offset")}}return n}export{s as validateLegendGroupState};
1
+ import{validateOptionObject as o,validateNonNegativeFinite as d}from"../core/validation.js";import{validateUserId as l}from"../core/identifiers.js";import{PLOT_LEGEND_POSITIONS as i,LEGEND_POSITIONS as a,LEGEND_BLOCK_LAYOUTS as f}from"../core/vocabulary.js";function c(n){o(n,void 0,"Legend groups");for(const[t,r]of Object.entries(n)){if(l(t,"Legend group target"),o(r,["placement","blockLayout","inferredOffset"],"Legend group"),!f.includes(r.blockLayout))throw new Error("Legend group blockLayout must be row, column or wrap.");if(r.inferredOffset!==void 0&&typeof r.inferredOffset!="boolean")throw new TypeError("Legend group inferredOffset must be boolean.");const e=r.placement;if(o(e,["at","position","align","offset"],"Legend group placement"),Object.hasOwn(e,"at")){if(Object.keys(e).length!==1)throw new Error("Legend group at cannot coexist with an edge anchor.");if(o(e.at,["x","y"],"Legend group at"),!Number.isFinite(e.at.x)||!Number.isFinite(e.at.y))throw new Error("Legend group at requires finite x and y.")}else{if(Object.keys(e).length!==3||!a.includes(e.position))throw new Error("Legend group requires a complete edge placement.");if(!["left","center","right"].includes(e.align))throw new Error("Unsupported legend group alignment.");if((i.includes(e.position)||["left","right"].includes(e.position))&&e.align!=="center")throw new Error("Side and in-plot legend groups require center alignment.");d(e.offset,"Legend group offset")}}return n}function s(n){const t=Object.values(n?.blockOverrides??{}).map(r=>r.position).filter(r=>r!==void 0);if(t.length>1)throw new Error("Legend family has ambiguous local positions.");if(t.length&&!a.includes(t[0]))throw new Error("Invalid local legend position.");return t[0]}function m(n){const t=s(n);if(t!==void 0)return{position:t,align:["top","bottom"].includes(t)?n.align??"center":"center",offset:i.includes(t)?8:n.offset??(n.channels===void 0?30:8),inferredOffset:n.inferredOffset??!0}}export{m as localLegendPlacement,s as localLegendPosition,c as validateLegendGroupState};
@@ -0,0 +1,6 @@
1
+ export function validateLegendLayerOpacity(value) {
2
+ if(value!==undefined&&(!Number.isFinite(value)||value<0||value>1)) {
3
+ throw new RangeError("Legend symbol layer opacity must be finite and between 0 and 1.");
4
+ }
5
+ return value;
6
+ }
@@ -42,7 +42,7 @@ export function readPathSeriesFields(rows, layer) {
42
42
 
43
43
  function seriesValueIndex(rows, fields, field, channel, encoding = {}) {
44
44
  const quantitative = channel === "strokeWidth" || channel === "opacity";
45
- const values = channel === "stroke"
45
+ const values = channel === "stroke" || (channel === "color" && encoding.fieldType === "quantitative")
46
46
  ? readScaleField(rows, field, encoding.fieldType ?? "nominal", {
47
47
  temporalUnit: encoding.temporalUnit
48
48
  })
@@ -88,7 +88,7 @@ export function validatePathSeriesAppearance(rows, layer) {
88
88
  if (encoding?.field === undefined) continue;
89
89
  const allowed = ["color", "stroke"].includes(channel)
90
90
  ? channel === "color"
91
- ? ["nominal", "ordinal"]
91
+ ? layer.mark.type === "area" ? ["nominal", "ordinal", "quantitative"] : ["nominal", "ordinal"]
92
92
  : ["nominal", "ordinal", "quantitative", "temporal"]
93
93
  : channel === "strokeDash" ? ["nominal"] : ["quantitative"];
94
94
  if (!allowed.includes(encoding.fieldType)) {
@@ -14,7 +14,9 @@ export const POINT_SHAPES = cloneAndFreeze([
14
14
  "cross",
15
15
  "star",
16
16
  "hexagon",
17
- "wye"
17
+ "wye",
18
+ "wedge",
19
+ "arrow"
18
20
  ]);
19
21
 
20
22
  const SHAPE_SET = new Set(POINT_SHAPES);
@@ -98,7 +100,19 @@ function shapePolygon(shape) {
98
100
  cross: rotate(plus, Math.PI / 4),
99
101
  star: alternatingPolygon(5, 0.45),
100
102
  hexagon: regularPolygon(6),
101
- wye: alternatingPolygon(3, 0.28)
103
+ wye: alternatingPolygon(3, 0.28),
104
+ wedge: [
105
+ { x: 0, y: -2 / Math.sqrt(3) },
106
+ { x: -1 / 4, y: 1 / Math.sqrt(3) },
107
+ { x: 1 / 4, y: 1 / Math.sqrt(3) }
108
+ ],
109
+ // The directional arrow is anchored at its bounding-box center, not its centroid.
110
+ arrow: [
111
+ { x: -1 / 7, y: 1 }, { x: 1 / 7, y: 1 },
112
+ { x: 1 / 7, y: -1 / 8 }, { x: 2 / 5, y: -1 / 8 },
113
+ { x: 0, y: -1 }, { x: -2 / 5, y: -1 / 8 },
114
+ { x: -1 / 7, y: -1 / 8 }
115
+ ]
102
116
  }[shape];
103
117
  }
104
118
 
@@ -3,7 +3,7 @@ import {
3
3
  readNominalField,
4
4
  readQuantitativeField
5
5
  } from "../scales/index.js";
6
- import { fitRegressionGroup, predictRegressionAt } from "./models.js";
6
+ import { fitRegressionGroup, predictRegressionAt, linearRegressionStatistics } from "./models.js";
7
7
  import { requireFiniteResult } from "../numeric.js";
8
8
  import {
9
9
  validateGeneratedItemLimit,
@@ -81,6 +81,7 @@ export function deriveRegression(values, {
81
81
  }
82
82
  const groups = [...groupedRows.keys()];
83
83
  const models = [];
84
+ const statistics = [];
84
85
  const rows = [];
85
86
  let work = 0;
86
87
 
@@ -119,6 +120,10 @@ export function deriveRegression(values, {
119
120
  parameters
120
121
  });
121
122
  models.push({ ...(groupBy === undefined ? {} : { group }), ...model, xValues });
123
+ if (parameters.method === "linear") statistics.push({
124
+ ...(groupBy === undefined ? {} : { group }),
125
+ ...linearRegressionStatistics(groupRows, model, { x, y })
126
+ });
122
127
 
123
128
  for (const xValue of xValues) {
124
129
  const prediction = predictRegressionAt(model, xValue, parameters);
@@ -163,6 +168,9 @@ export function deriveRegression(values, {
163
168
  parameters,
164
169
  groups,
165
170
  models,
171
+ ...(parameters.method === "linear" ? {
172
+ fitStatistics: { version: 1, method: "linear", groups: statistics }
173
+ } : {}),
166
174
  values: rows,
167
175
  ...(missing === undefined ? {} : {
168
176
  report: {
@@ -477,7 +477,62 @@ function predictLoessAt(model, xValue) {
477
477
  }, "LOESS regression prediction").prediction;
478
478
  }
479
479
 
480
+ function fitExponentialGroup(rows, { x, y, group }) {
481
+ const label = `Regression group "${group === undefined ? "all" : String(group)}"`;
482
+ if (rows.length < 2) throw new Error(`${label} requires at least two rows.`);
483
+ if (rows.some(row => !(row[y] > 0))) {
484
+ throw new RangeError("Exponential regression y values must be strictly positive.");
485
+ }
486
+ const xValues = rows.map(row => row[x]);
487
+ if (new Set(xValues).size < 2) throw new Error(`${label} requires varying x values.`);
488
+ const responseScale = maximumMagnitude(rows.map(row => row[y]));
489
+ const xMagnitude = maximumMagnitude(xValues) || 1;
490
+ const weights = rows.map(row => row[y] / responseScale);
491
+ const weightSum = weights.reduce((sum, value) => sum + value, 0);
492
+ const logs = rows.map(row => Math.log(row[y]));
493
+ const meanX = restoreFiniteScale(rows.reduce((sum, row, index) =>
494
+ sum + weights[index] * (row[x] / xMagnitude), 0) / weightSum,
495
+ xMagnitude, `${label} weighted x mean`);
496
+ const meanLogY = logs.reduce((sum, value, index) => sum + weights[index] * value, 0) / weightSum;
497
+ const [centeredX, xScale] = normalizedCoordinates(xValues, meanX);
498
+ const variance = centeredX.reduce((sum, value, index) => sum + weights[index] * value ** 2, 0);
499
+ if (variance === 0) throw new Error(`${label} requires varying x values.`);
500
+ const covariance = centeredX.reduce((sum, value, index) =>
501
+ sum + weights[index] * value * (logs[index] - meanLogY), 0);
502
+ const rate = restoreFiniteRatio(covariance / variance, 1, xScale, `${label} exponential rate`);
503
+ const logAmplitude = requireFiniteResult(meanLogY - rate * meanX, `${label} exponential log amplitude`);
504
+ const amplitude = requireFiniteResult(Math.exp(logAmplitude), `${label} exponential amplitude`);
505
+ if (amplitude === 0) throw new RangeError("Exponential regression amplitude must be positive and representable.");
506
+ return { count: rows.length, amplitude, rate, logAmplitude };
507
+ }
508
+
509
+ export function linearRegressionStatistics(rows, model, { x, y }) {
510
+ const responseScale = maximumMagnitude(rows.map(row => row[y])) || 1;
511
+ const [coordinates, xScale] = normalizedCoordinates(rows.map(row => row[x]), model.meanX);
512
+ const normalizedSlope = model.normalizedResponseSlope ?? (
513
+ Number.isFinite(model.slope * xScale) && (model.slope * xScale !== 0 || model.slope === 0)
514
+ ? model.slope * xScale / responseScale
515
+ : restoreFiniteRatio(model.slope, xScale, responseScale, "Linear regression normalized slope")
516
+ );
517
+ let residualSquares = 0;
518
+ let totalSquares = 0;
519
+ rows.forEach((row, index) => {
520
+ const difference = row[y] - model.meanY;
521
+ const centered = Number.isFinite(difference)
522
+ ? difference / responseScale
523
+ : row[y] / responseScale - model.meanY / responseScale;
524
+ const residual = centered - normalizedSlope * coordinates[index];
525
+ totalSquares += centered ** 2;
526
+ residualSquares += residual ** 2;
527
+ });
528
+ return {
529
+ count: rows.length, intercept: model.intercept, slope: model.slope,
530
+ rSquared: totalSquares === 0 ? null : 1 - residualSquares / totalSquares
531
+ };
532
+ }
533
+
480
534
  export function fitRegressionGroup(rows, { x, y, group, parameters }) {
535
+ if (parameters.method === "exp") return fitExponentialGroup(rows, { x, y, group });
481
536
  if (parameters.method === "linear") {
482
537
  return fitLinearGroup(rows, {
483
538
  x,
@@ -509,9 +564,14 @@ export function predictRegressionAt(model, xValue, parameters) {
509
564
  : undefined;
510
565
  const prediction = parameters.method === "linear"
511
566
  ? linearPrediction(model, xValue)
567
+ : parameters.method === "exp"
568
+ ? requireFiniteResult(Math.exp(model.logAmplitude + model.rate * xValue), "Exponential regression prediction")
512
569
  : parameters.method === "polynomial"
513
570
  ? polynomial.prediction
514
571
  : predictLoessAt(model, xValue);
572
+ if (parameters.method === "exp" && prediction === 0) {
573
+ throw new RangeError("Exponential regression prediction must be positive and representable.");
574
+ }
515
575
  if (parameters.method === "loess" || parameters.interval === false) return { prediction };
516
576
  const leverage = parameters.method === "linear"
517
577
  ? model.normalizedSxx !== undefined
@@ -51,9 +51,17 @@ export function normalizeRegressionParameters({
51
51
  confidence,
52
52
  interval
53
53
  } = {}) {
54
- if (!["linear", "polynomial", "loess"].includes(method)) {
54
+ if (!["linear", "polynomial", "loess", "exp"].includes(method)) {
55
55
  throw new Error(`Unsupported regression method "${method}".`);
56
56
  }
57
+ if (method === "exp") {
58
+ if (degree !== undefined || span !== undefined || robustIterations !== undefined ||
59
+ confidenceMethod !== undefined || level !== undefined || confidence !== undefined ||
60
+ (interval !== undefined && interval !== false)) {
61
+ throw new Error("Exponential regression only supports interval false and no confidence or method-specific options.");
62
+ }
63
+ return cloneAndFreeze({ method, interval: false });
64
+ }
57
65
  if (method === "loess") {
58
66
  if (degree !== undefined) {
59
67
  throw new Error("Regression degree requires the polynomial method.");