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.
- package/CHANGELOG.md +56 -0
- package/README.md +6 -2
- package/knowledge/action-cards.json +1 -1
- package/knowledge/intent-taxonomy.json +1 -1
- package/knowledge/mcp-resources.json +1 -1
- package/package.json +1 -1
- package/src/actions/canvas/fitting.js +6 -2
- package/src/actions/charts/heatmap.js +4 -3
- package/src/actions/data/derived.js +11 -1
- package/src/actions/data/edit.js +2 -0
- package/src/actions/data/index.js +4 -0
- package/src/actions/data/regression.js +24 -16
- package/src/actions/data/regressionStatistics.js +73 -0
- package/src/actions/data/shared.js +5 -2
- package/src/actions/distributions/revision.js +24 -18
- package/src/actions/encodings/color/continuous.js +7 -1
- package/src/actions/encodings/remove.js +11 -6
- package/src/actions/encodings/shared.js +26 -24
- package/src/actions/facets/actions.js +18 -7
- package/src/actions/facets/guides.js +2 -1
- package/src/actions/guides/axes/axes.js +30 -11
- package/src/actions/guides/axes/axis.js +58 -8
- package/src/actions/guides/axes/components.js +13 -13
- package/src/actions/guides/axes/edit.js +36 -24
- package/src/actions/guides/axes/labels.js +37 -27
- package/src/actions/guides/axes/lines.js +22 -20
- package/src/actions/guides/axes/remove.js +10 -6
- package/src/actions/guides/axes/tickGroups.js +8 -7
- package/src/actions/guides/axes/ticks.js +31 -22
- package/src/actions/guides/axes/titles.js +53 -60
- package/src/actions/guides/facadeAxes.js +33 -24
- package/src/actions/guides/facadeLegend.js +3 -3
- package/src/actions/guides/grids/grid.js +1 -1
- package/src/actions/guides/grids/resolve.js +48 -51
- package/src/actions/guides/legends/blocks.js +43 -19
- package/src/actions/guides/legends/categorical/actions.js +32 -9
- package/src/actions/guides/legends/categorical/components.js +3 -10
- package/src/actions/guides/legends/categorical/index.js +2 -0
- package/src/actions/guides/legends/categorical/layout.js +9 -13
- package/src/actions/guides/legends/categorical/options.js +8 -2
- package/src/actions/guides/legends/categorical/recipes.js +7 -3
- package/src/actions/guides/legends/categorical/resolve.js +9 -2
- package/src/actions/guides/legends/categorical/symbols.js +22 -8
- package/src/actions/guides/legends/colorSize.js +110 -0
- package/src/actions/guides/legends/combine.js +37 -0
- package/src/actions/guides/legends/continuous/colorSymbols.js +1 -1
- package/src/actions/guides/legends/continuous/common.js +1 -1
- package/src/actions/guides/legends/continuous/gradient.js +27 -13
- package/src/actions/guides/legends/continuous/opacity.js +16 -11
- package/src/actions/guides/legends/creation.js +2 -0
- package/src/actions/guides/legends/edit.js +1 -1
- package/src/actions/guides/legends/focused.js +6 -5
- package/src/actions/guides/legends/lane.js +1 -1
- package/src/actions/guides/legends/lifecycle.js +2 -1
- package/src/actions/guides/legends/remove.js +6 -3
- package/src/actions/guides/legends/size.js +16 -6
- package/src/actions/guides/legends/strokeWidth.js +2 -0
- package/src/actions/guides/legends/target.js +2 -79
- package/src/actions/guides/legends/transition.js +24 -5
- package/src/actions/guides/tickValues.js +8 -5
- package/src/actions/marks/area/materialize.js +1 -190
- package/src/actions/marks/bar/edit.js +23 -12
- package/src/actions/marks/bar/materialize.js +6 -1
- package/src/actions/marks/point/materialize.js +5 -1
- package/src/actions/marks/remove.js +10 -10
- package/src/actions/primitives/createGraphics.js +3 -2
- package/src/actions/primitives/editGraphics.js +15 -11
- package/src/actions/primitives/graphicProperties.js +1 -6
- package/src/actions/primitives/semanticAction.js +27 -1
- package/src/actions/primitives/semanticValidation/guide.js +11 -1
- package/src/actions/primitives/semanticValidation/layer.js +4 -0
- package/src/actions/regression/edit.js +15 -3
- package/src/actions/scales/channels.js +2 -2
- package/src/actions/scales/consumers/common.js +7 -1
- package/src/actions/scales/create.js +3 -3
- package/src/actions/scales/definitions.js +1 -1
- package/src/actions/scales/edit.js +1 -1
- package/src/actions/scales/editPolicy.js +3 -0
- package/src/actions/scales/patch.js +1 -1
- package/src/actions/scales/quantitativeColor.js +8 -3
- package/src/actions/theme/reconcile.js +50 -23
- package/src/core/compositionState.js +2 -2
- package/src/core/graphicProperties.js +1 -0
- package/src/core/programState.js +4 -0
- package/src/core/resourceReferences.js +2 -2
- package/src/core/validation.js +9 -0
- package/src/core/vocabulary.js +3 -0
- package/src/grammar/aggregate.js +3 -2
- package/src/grammar/areaSeries.js +1 -415
- package/src/grammar/axisTextPlacement.js +1 -0
- package/src/grammar/colorSizeLegend.js +39 -0
- package/src/grammar/computed.js +23 -1
- package/src/grammar/datasetSchema.js +10 -2
- package/src/grammar/displayLabels.js +8 -0
- package/src/grammar/facets/guides.js +54 -31
- package/src/grammar/facets/legendIntent.js +65 -0
- package/src/grammar/guideDash.js +1 -0
- package/src/grammar/interval.js +22 -6
- package/src/grammar/legendBlocks.js +1 -0
- package/src/grammar/legendBorder.js +9 -0
- package/src/grammar/legendClip.js +19 -0
- package/src/grammar/legendCombinations.js +45 -0
- package/src/grammar/legendGradient.js +9 -0
- package/src/grammar/legendGroups.js +1 -1
- package/src/grammar/legendOpacity.js +6 -0
- package/src/grammar/pathSeries.js +2 -2
- package/src/grammar/pointShapes.js +16 -2
- package/src/grammar/regression/derive.js +9 -1
- package/src/grammar/regression/models.js +60 -0
- package/src/grammar/regression/parameters.js +9 -1
- package/src/grammar/regression/statistics.js +64 -0
- package/src/grammar/scales/color.js +1 -1
- package/src/grammar/scales/colorConsumers.js +6 -3
- package/src/grammar/scales/definition.js +1 -1
- package/src/grammar/scales/types.js +1 -1
- package/src/grammar/schemas/concreteGraphic.js +3 -0
- package/src/grammar/schemas/graphic.js +3 -0
- package/src/grammar/schemas/graphicBounds.js +31 -7
- package/src/grammar/schemas/semanticPath.js +17 -2
- package/src/grammar/statistics/confidenceInterval.js +1 -101
- package/src/grammar/summary.js +1 -320
- package/src/grammar/timeUnit.js +6 -4
- package/src/grammar/transformTopology.js +1 -0
- package/src/grammar/transforms.js +11 -1
- package/src/inspectionDescriptors.js +1 -1
- package/src/layout/guideCollisions.js +9 -1
- package/src/layout/legendGroup.js +1 -1
- package/src/materialization/compositionSnapshot.js +2 -7
- package/src/materialization/coordinateBounds.js +17 -4
- package/src/materialization/facetGuides/edit.js +30 -0
- package/src/materialization/facetGuides/layout.js +1 -1
- package/src/materialization/facetGuides/owner.js +125 -0
- package/src/materialization/facetGuides/ownerLayout.js +71 -0
- package/src/materialization/facetGuides/placement.js +52 -6
- package/src/materialization/facetGuides/preparation.js +7 -0
- package/src/materialization/facets.js +1 -1
- package/src/materialization/graphicHierarchy.js +5 -1
- package/src/materialization/graphicTranslation.js +73 -0
- package/src/materialization/guides/layout.js +1 -1
- package/src/materialization/guides/resources.js +1 -1
- package/src/materialization/legendBorder.js +28 -0
- package/src/materialization/legendClip.js +76 -0
- package/src/materialization/legendGroups.js +1 -1
- package/src/materialization/legends.js +1 -1
- package/src/materialization/marks/grouping.js +1 -1
- package/src/materialization/scaleGuideDependencies.js +13 -14
- package/src/materialization/scales/map.js +3 -0
- package/src/materialization/scales/resolve.js +2 -0
- package/src/materialization/typography.js +10 -2
- package/src/persistence/validation.js +174 -1
- package/src/persistence.js +1 -1
- package/src/renderers/canvas/index.js +20 -10
- package/src/renderers/canvas/isolated.js +166 -0
- package/src/renderers/canvas/limits.js +3 -0
- package/src/renderers/canvas/rect.js +1 -1
- package/src/renderers/pdf.js +2 -2
- package/src/renderers/png.js +2 -2
- package/src/renderers/svg.js +10 -3
- package/src/selectors/axes.js +1 -0
- package/src/selectors/graphicCapacity.js +38 -0
- package/src/version.js +1 -1
- package/types/basic.d.ts +2 -0
- package/types/index.d.ts +7 -0
- 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
|
|
81
|
-
const
|
|
82
|
-
|
|
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
|
|
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 =
|
|
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 ${
|
|
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 (!
|
|
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
|
-
!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
280
|
+
...(records === undefined ? {} : { removeAxisComponents: removed }),
|
|
281
|
+
removeLegends: legend !== false ? descriptor.legendKinds ?? [] : []
|
|
259
282
|
}];
|
|
260
283
|
})),
|
|
261
|
-
parent: legend
|
|
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};
|
package/src/grammar/interval.js
CHANGED
|
@@ -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
|
|
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};
|
|
@@ -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.");
|