ggaction 0.0.4 → 0.0.6
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 +55 -0
- package/README.md +35 -13
- package/package.json +18 -7
- package/src/actions/boxPlots/create.js +13 -7
- package/src/actions/boxPlots/edit.js +484 -36
- package/src/actions/boxPlots/materialize.js +3 -0
- package/src/actions/boxPlots/options.js +11 -4
- package/src/actions/boxPlots/resolve.js +10 -2
- package/src/actions/charts/bar.js +50 -0
- package/src/actions/charts/heatmap.js +205 -0
- package/src/actions/charts/histogram.js +58 -0
- package/src/actions/charts/index.js +15 -0
- package/src/actions/charts/line.js +69 -0
- package/src/actions/charts/parallel.js +76 -0
- package/src/actions/charts/scatter.js +56 -0
- package/src/actions/charts/shared.js +121 -0
- package/src/actions/composition/actions.js +13 -3
- package/src/actions/coordinates/parallel.js +50 -0
- package/src/actions/data/bin2d.js +260 -0
- package/src/actions/data/density.js +54 -13
- package/src/actions/data/derived.js +22 -0
- package/src/actions/data/filter.js +5 -0
- package/src/actions/data/gradientProfile.js +65 -0
- package/src/actions/data/horizon.js +71 -0
- package/src/actions/data/index.js +34 -2
- package/src/actions/data/intervalEdit.js +76 -0
- package/src/actions/data/window.js +50 -0
- package/src/actions/distributions/revision.js +326 -0
- package/src/actions/encodings/appearance.js +35 -1
- package/src/actions/encodings/color/index.js +17 -1
- package/src/actions/encodings/color/layout.js +8 -0
- package/src/actions/encodings/density/resolve.js +109 -0
- package/src/actions/encodings/density.js +330 -90
- package/src/actions/encodings/horizon/resolve.js +283 -0
- package/src/actions/encodings/horizon.js +364 -0
- package/src/actions/encodings/index.js +8 -0
- package/src/actions/encodings/parallel.js +115 -0
- package/src/actions/encodings/pathOrder.js +131 -0
- package/src/actions/encodings/position/apply.js +73 -4
- package/src/actions/encodings/position/index.js +15 -2
- package/src/actions/encodings/position/policies/arc.js +27 -4
- package/src/actions/encodings/position/policies/area.js +33 -2
- package/src/actions/encodings/position/policies/index.js +7 -2
- package/src/actions/encodings/position/policies/line.js +18 -2
- package/src/actions/encodings/position/resolve.js +24 -2
- package/src/actions/encodings/ranged.js +3 -1
- package/src/actions/encodings/remove.js +268 -0
- package/src/actions/encodings/ruleAppearance.js +88 -10
- package/src/actions/errorBands/edit.js +121 -13
- package/src/actions/errorBars/edit.js +45 -8
- package/src/actions/errorBars/options.js +7 -1
- package/src/actions/facets/actions.js +137 -3
- package/src/actions/facets/derive.js +33 -3
- package/src/actions/facets/replay.js +7 -36
- package/src/actions/gradientPlots/components.js +177 -0
- package/src/actions/gradientPlots/create.js +96 -0
- package/src/actions/gradientPlots/edit.js +501 -0
- package/src/actions/gradientPlots/index.js +24 -0
- package/src/actions/gradientPlots/materialize.js +287 -0
- package/src/actions/gradientPlots/options.js +120 -0
- package/src/actions/gradientPlots/paint.js +107 -0
- package/src/actions/gradientPlots/rebind.js +51 -0
- package/src/actions/gradientPlots/resolve.js +81 -0
- package/src/actions/guides/applicability.js +44 -17
- package/src/actions/guides/axes/axes.js +37 -3
- package/src/actions/guides/axes/edit.js +129 -47
- package/src/actions/guides/axes/index.js +2 -0
- package/src/actions/guides/axes/labels.js +11 -6
- package/src/actions/guides/axes/parallel/resolve.js +86 -0
- package/src/actions/guides/axes/parallel.js +228 -0
- package/src/actions/guides/axes/titles.js +11 -8
- package/src/actions/guides/guides.js +60 -5
- package/src/actions/guides/legends/categorical/actions.js +67 -19
- package/src/actions/guides/legends/continuous/common.js +3 -3
- package/src/actions/guides/legends/edit.js +63 -0
- package/src/actions/guides/legends/index.js +2 -0
- package/src/actions/guides/legends/remove.js +124 -30
- package/src/actions/guides/legends/strokeWidth.js +198 -0
- package/src/actions/index.js +6 -0
- package/src/actions/marks/arc/actions.js +46 -14
- package/src/actions/marks/area/actions.js +60 -136
- package/src/actions/marks/area/materialize.js +163 -0
- package/src/actions/marks/index.js +5 -1
- package/src/actions/marks/line/actions.js +99 -80
- package/src/actions/marks/line/materialize.js +146 -0
- package/src/actions/marks/point/create.js +76 -0
- package/src/actions/marks/point/edit.js +97 -0
- package/src/actions/marks/point/index.js +11 -1
- package/src/actions/marks/point/jitter.js +72 -0
- package/src/actions/marks/point/{actions.js → materialize.js} +183 -172
- package/src/actions/marks/rect/actions.js +3 -0
- package/src/actions/marks/remove.js +13 -2
- package/src/actions/marks/rule/actions.js +29 -4
- package/src/actions/marks/text/actions.js +19 -4
- package/src/actions/marks/text/index.js +7 -1
- package/src/actions/marks/text/layout.js +321 -0
- package/src/actions/primitives/semanticValidation/dataset.js +22 -0
- package/src/actions/primitives/semanticValidation/guide.js +43 -0
- package/src/actions/primitives/semanticValidation/index.js +23 -0
- package/src/actions/primitives/semanticValidation/layer.js +74 -0
- package/src/actions/primitives/semanticValidation/scale.js +74 -0
- package/src/actions/primitives/semanticValidation/shared.js +5 -0
- package/src/actions/primitives/semanticValue.js +1 -204
- package/src/actions/regression/edit.js +247 -88
- package/src/actions/scales/consumers/common.js +18 -0
- package/src/actions/scales/consumers/families.js +14 -3
- package/src/actions/scales/definitions.js +30 -0
- package/src/actions/scales/edit.js +6 -204
- package/src/actions/scales/editPolicy.js +214 -0
- package/src/actions/scales/materialize.js +1 -1
- package/src/actions/scales/patch.js +26 -0
- package/src/actions/selection/actions.js +150 -5
- package/src/actions/selection/index.js +6 -0
- package/src/actions/violinPlots/create.js +198 -0
- package/src/actions/violinPlots/index.js +5 -0
- package/src/core/textMetrics.js +47 -0
- package/src/core/vocabulary.js +14 -2
- package/src/grammar/arcs.js +49 -12
- package/src/grammar/areaSeries.js +41 -5
- package/src/grammar/bin2d.js +333 -0
- package/src/grammar/categoricalDensity.js +192 -0
- package/src/grammar/coordinates.js +1 -1
- package/src/grammar/curveCommands.js +3 -1
- package/src/grammar/density.js +252 -26
- package/src/grammar/facets/dependencies.js +3 -11
- package/src/grammar/facets/index.js +3 -1
- package/src/grammar/gradientProfile.js +213 -0
- package/src/grammar/horizon.js +455 -0
- package/src/grammar/jitter.js +197 -0
- package/src/grammar/lineSeries.js +69 -0
- package/src/grammar/paint.js +88 -0
- package/src/grammar/parallelCoordinates.js +213 -0
- package/src/grammar/pathOrder.js +35 -0
- package/src/grammar/pointShapes.js +24 -0
- package/src/grammar/rules.js +1 -0
- package/src/grammar/scales/appearance.js +21 -0
- package/src/grammar/schemas/concreteGraphic.js +11 -1
- package/src/grammar/schemas/graphicBounds.js +19 -37
- package/src/grammar/schemas/semanticPath.js +17 -2
- package/src/grammar/transforms.js +100 -11
- package/src/grammar/window.js +339 -0
- package/src/layout/labels.js +270 -0
- package/src/layout/text.js +4 -1
- package/src/layout/title.js +10 -20
- package/src/materialization/dataProvenance.js +56 -2
- package/src/materialization/dependencies.js +9 -14
- package/src/materialization/guides/resources.js +74 -24
- package/src/materialization/horizon.js +29 -0
- package/src/materialization/layout.js +15 -0
- package/src/materialization/marks/capabilities.js +21 -1
- package/src/materialization/marks/index.js +3 -0
- package/src/materialization/marks/pathOrder.js +71 -0
- package/src/materialization/marks/policies.js +6 -2
- package/src/materialization/scaleGuideDependencies.js +8 -0
- package/src/materialization/scales/resolve.js +2 -0
- package/src/materialization/selection/items/path.js +27 -1
- package/src/materialization/selection/items/point.js +1 -5
- package/src/materialization/selection/items/rect.js +21 -0
- package/src/materialization/selection/styles.js +20 -9
- package/src/materialization/text.js +12 -4
- package/src/renderers/canvas/fill.js +33 -0
- package/src/renderers/canvas/index.js +7 -0
- package/src/renderers/canvas/path.js +7 -4
- package/src/renderers/canvas/rect.js +9 -3
- package/src/selectors/datasets.js +4 -0
- package/src/theme/defaults.js +4 -0
- package/types/extension.d.ts +6 -0
- package/types/index.d.ts +77 -0
- package/types/program.d.ts +703 -17
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
|
|
2
|
+
import { normalizePalette } from "./palettes.js";
|
|
3
|
+
import {
|
|
4
|
+
isNominalValue,
|
|
5
|
+
normalizeTemporalValue
|
|
6
|
+
} from "./scales/index.js";
|
|
7
|
+
|
|
8
|
+
export const HORIZON_RESOLUTIONS = Object.freeze(["shared", "independent"]);
|
|
9
|
+
export const HORIZON_MISSING_POLICIES = Object.freeze(["break", "error"]);
|
|
10
|
+
export const HORIZON_OVERFLOW_POLICIES = Object.freeze(["clip", "error"]);
|
|
11
|
+
|
|
12
|
+
const ENCODING_PROPERTIES = Object.freeze(["field", "fieldType"]);
|
|
13
|
+
const OUTPUT_PROPERTIES = Object.freeze([
|
|
14
|
+
"x", "lower", "upper", "group", "color", "sign", "band", "segment"
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function requireField(value, label) {
|
|
18
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
19
|
+
throw new TypeError(`${label} must be a non-empty string.`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateEncoding(value, role) {
|
|
25
|
+
if (!isPlainObject(value)) {
|
|
26
|
+
throw new TypeError(`Horizon ${role} encoding must be a plain object.`);
|
|
27
|
+
}
|
|
28
|
+
const unknown = Object.keys(value).find(
|
|
29
|
+
property => !ENCODING_PROPERTIES.includes(property)
|
|
30
|
+
);
|
|
31
|
+
if (unknown !== undefined) {
|
|
32
|
+
throw new Error(`Unknown Horizon ${role} encoding property "${unknown}".`);
|
|
33
|
+
}
|
|
34
|
+
const field = requireField(value.field, `Horizon ${role} field`);
|
|
35
|
+
const fieldType = value.fieldType ?? (role === "x" ? "quantitative" : "quantitative");
|
|
36
|
+
const supported = role === "x"
|
|
37
|
+
? ["quantitative", "temporal"]
|
|
38
|
+
: ["quantitative"];
|
|
39
|
+
if (!supported.includes(fieldType)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Horizon ${role} fieldType must be ${supported.join(" or ")}.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return { field, fieldType };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateOutputFields(value) {
|
|
48
|
+
if (!isPlainObject(value)) {
|
|
49
|
+
throw new TypeError("Horizon as must be a plain object.");
|
|
50
|
+
}
|
|
51
|
+
const unknown = Object.keys(value).find(
|
|
52
|
+
property => !OUTPUT_PROPERTIES.includes(property)
|
|
53
|
+
);
|
|
54
|
+
if (unknown !== undefined) {
|
|
55
|
+
throw new Error(`Unknown Horizon output field "${unknown}".`);
|
|
56
|
+
}
|
|
57
|
+
const output = Object.fromEntries(OUTPUT_PROPERTIES.map(property => [
|
|
58
|
+
property,
|
|
59
|
+
requireField(value[property], `Horizon ${property} output field`)
|
|
60
|
+
]));
|
|
61
|
+
if (new Set(Object.values(output)).size !== OUTPUT_PROPERTIES.length) {
|
|
62
|
+
throw new Error("Horizon output fields must be distinct.");
|
|
63
|
+
}
|
|
64
|
+
return output;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validatePalette(value = {}) {
|
|
68
|
+
if (!isPlainObject(value)) {
|
|
69
|
+
throw new TypeError("Horizon palette must be a plain object.");
|
|
70
|
+
}
|
|
71
|
+
const unknown = Object.keys(value).find(
|
|
72
|
+
property => !["positive", "negative"].includes(property)
|
|
73
|
+
);
|
|
74
|
+
if (unknown !== undefined) {
|
|
75
|
+
throw new Error(`Unknown Horizon palette property "${unknown}".`);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
positive: normalizePalette(value.positive ?? "blues"),
|
|
79
|
+
negative: normalizePalette(value.negative ?? "reds")
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateResolved(value) {
|
|
84
|
+
if (!isPlainObject(value) || Object.keys(value).some(key => key !== "extents")) {
|
|
85
|
+
throw new TypeError("Horizon resolved provenance must contain only extents.");
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(value.extents) || value.extents.length === 0) {
|
|
88
|
+
throw new TypeError("Horizon resolved extents must be a non-empty array.");
|
|
89
|
+
}
|
|
90
|
+
for (const [index, entry] of value.extents.entries()) {
|
|
91
|
+
if (
|
|
92
|
+
!isPlainObject(entry) ||
|
|
93
|
+
Object.keys(entry).some(key => !["group", "extent", "bandHeight"].includes(key)) ||
|
|
94
|
+
!Number.isFinite(entry.extent) ||
|
|
95
|
+
entry.extent < 0 ||
|
|
96
|
+
!Number.isFinite(entry.bandHeight) ||
|
|
97
|
+
entry.bandHeight < 0
|
|
98
|
+
) {
|
|
99
|
+
throw new TypeError(`Horizon resolved extent ${index} is invalid.`);
|
|
100
|
+
}
|
|
101
|
+
if (Object.hasOwn(entry, "group") && !isNominalValue(entry.group)) {
|
|
102
|
+
throw new TypeError(`Horizon resolved group ${index} must be nominal.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function requestedHorizonTransform(transform) {
|
|
109
|
+
const { resolved: _resolved, ...requested } = transform;
|
|
110
|
+
void _resolved;
|
|
111
|
+
return requested;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function validateHorizonTransform(transform) {
|
|
115
|
+
if (!isPlainObject(transform)) {
|
|
116
|
+
throw new TypeError("Horizon transform must be a plain object.");
|
|
117
|
+
}
|
|
118
|
+
const supported = [
|
|
119
|
+
"type", "x", "y", "groupBy", "bands", "baseline", "extent",
|
|
120
|
+
"resolve", "missing", "overflow", "palette", "as", "resolved"
|
|
121
|
+
];
|
|
122
|
+
const unknown = Object.keys(transform).find(key => !supported.includes(key));
|
|
123
|
+
if (unknown !== undefined) {
|
|
124
|
+
throw new Error(`Unknown Horizon transform property "${unknown}".`);
|
|
125
|
+
}
|
|
126
|
+
if (transform.type !== "horizon") {
|
|
127
|
+
throw new Error(`Unsupported Horizon transform "${transform.type}".`);
|
|
128
|
+
}
|
|
129
|
+
const x = validateEncoding(transform.x, "x");
|
|
130
|
+
const y = validateEncoding(transform.y, "y");
|
|
131
|
+
if (x.field === y.field) {
|
|
132
|
+
throw new Error("Horizon x and y fields must differ.");
|
|
133
|
+
}
|
|
134
|
+
if (transform.groupBy !== undefined) {
|
|
135
|
+
requireField(transform.groupBy, "Horizon groupBy");
|
|
136
|
+
if ([x.field, y.field].includes(transform.groupBy)) {
|
|
137
|
+
throw new Error("Horizon groupBy must differ from x and y fields.");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!Number.isInteger(transform.bands) || transform.bands <= 0) {
|
|
141
|
+
throw new RangeError("Horizon bands must be a positive integer.");
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isFinite(transform.baseline)) {
|
|
144
|
+
throw new TypeError("Horizon baseline must be finite.");
|
|
145
|
+
}
|
|
146
|
+
if (
|
|
147
|
+
transform.extent !== "auto" &&
|
|
148
|
+
(!Number.isFinite(transform.extent) || transform.extent <= 0)
|
|
149
|
+
) {
|
|
150
|
+
throw new RangeError('Horizon extent must be "auto" or a positive number.');
|
|
151
|
+
}
|
|
152
|
+
if (!HORIZON_RESOLUTIONS.includes(transform.resolve)) {
|
|
153
|
+
throw new Error(`Unknown Horizon resolve "${transform.resolve}".`);
|
|
154
|
+
}
|
|
155
|
+
if (!HORIZON_MISSING_POLICIES.includes(transform.missing)) {
|
|
156
|
+
throw new Error(`Unknown Horizon missing policy "${transform.missing}".`);
|
|
157
|
+
}
|
|
158
|
+
if (!HORIZON_OVERFLOW_POLICIES.includes(transform.overflow)) {
|
|
159
|
+
throw new Error(`Unknown Horizon overflow policy "${transform.overflow}".`);
|
|
160
|
+
}
|
|
161
|
+
const palette = validatePalette(transform.palette);
|
|
162
|
+
for (const [sign, value] of Object.entries(palette)) {
|
|
163
|
+
if (value.count !== undefined && value.count !== transform.bands) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Horizon ${sign} palette count must equal bands (${transform.bands}).`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const as = validateOutputFields(transform.as);
|
|
170
|
+
if (transform.groupBy !== undefined && Object.values(as).includes(transform.groupBy)) {
|
|
171
|
+
throw new Error("Horizon output fields must not collide with groupBy.");
|
|
172
|
+
}
|
|
173
|
+
if (transform.resolved !== undefined) validateResolved(transform.resolved);
|
|
174
|
+
return cloneAndFreeze({
|
|
175
|
+
...transform,
|
|
176
|
+
x,
|
|
177
|
+
y,
|
|
178
|
+
palette,
|
|
179
|
+
as
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function orderedX(value, encoding, rowIndex) {
|
|
184
|
+
if (encoding.fieldType === "temporal") {
|
|
185
|
+
return normalizeTemporalValue(value, encoding.field, rowIndex);
|
|
186
|
+
}
|
|
187
|
+
if (!Number.isFinite(value)) {
|
|
188
|
+
throw new TypeError(
|
|
189
|
+
`Horizon x field "${encoding.field}" must contain finite numbers at row ${rowIndex}.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function groupRows(rows, transform) {
|
|
196
|
+
const groups = [];
|
|
197
|
+
const indexed = new Map();
|
|
198
|
+
for (const [sourceRowIndex, row] of rows.entries()) {
|
|
199
|
+
if (!isPlainObject(row)) {
|
|
200
|
+
throw new TypeError(`Horizon row ${sourceRowIndex} must be a plain object.`);
|
|
201
|
+
}
|
|
202
|
+
const group = transform.groupBy === undefined ? undefined : row[transform.groupBy];
|
|
203
|
+
if (transform.groupBy !== undefined && !isNominalValue(group)) {
|
|
204
|
+
throw new TypeError(
|
|
205
|
+
`Horizon groupBy field "${transform.groupBy}" must contain nominal values at row ${sourceRowIndex}.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const key = transform.groupBy === undefined
|
|
209
|
+
? "__horizon_ungrouped__"
|
|
210
|
+
: `${typeof group}:${String(group)}`;
|
|
211
|
+
if (!indexed.has(key)) {
|
|
212
|
+
const entry = { group, groupIndex: groups.length, rows: [] };
|
|
213
|
+
indexed.set(key, entry);
|
|
214
|
+
groups.push(entry);
|
|
215
|
+
}
|
|
216
|
+
const y = row[transform.y.field];
|
|
217
|
+
const missing = !Number.isFinite(y);
|
|
218
|
+
if (missing && transform.missing === "error") {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Horizon y field "${transform.y.field}" is missing at source row ${sourceRowIndex}.`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
indexed.get(key).rows.push({
|
|
224
|
+
sourceRowIndex,
|
|
225
|
+
x: orderedX(row[transform.x.field], transform.x, sourceRowIndex),
|
|
226
|
+
y,
|
|
227
|
+
missing
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
for (const entry of groups) {
|
|
231
|
+
entry.rows.sort((left, right) =>
|
|
232
|
+
left.x - right.x || left.sourceRowIndex - right.sourceRowIndex
|
|
233
|
+
);
|
|
234
|
+
for (let index = 1; index < entry.rows.length; index += 1) {
|
|
235
|
+
if (entry.rows[index - 1].x === entry.rows[index].x) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Horizon group ${JSON.stringify(entry.group ?? null)} has duplicate x value ${entry.rows[index].x}.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return groups;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function splitSegments(rows, baseline) {
|
|
246
|
+
const segments = [];
|
|
247
|
+
let current = [];
|
|
248
|
+
const flush = () => {
|
|
249
|
+
if (current.length > 0) segments.push(current);
|
|
250
|
+
current = [];
|
|
251
|
+
};
|
|
252
|
+
for (const row of rows) {
|
|
253
|
+
if (row.missing) {
|
|
254
|
+
flush();
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
current.push({ ...row, signed: row.y - baseline, interpolated: false });
|
|
258
|
+
}
|
|
259
|
+
flush();
|
|
260
|
+
return segments;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function insertCrossings(points, baseline) {
|
|
264
|
+
if (points.length < 2) return points.map(point => ({ ...point }));
|
|
265
|
+
const output = [{ ...points[0] }];
|
|
266
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
267
|
+
const left = points[index - 1];
|
|
268
|
+
const right = points[index];
|
|
269
|
+
if (left.signed * right.signed < 0) {
|
|
270
|
+
const fraction = (baseline - left.y) / (right.y - left.y);
|
|
271
|
+
output.push({
|
|
272
|
+
sourceRowIndex: undefined,
|
|
273
|
+
x: left.x + (right.x - left.x) * fraction,
|
|
274
|
+
y: baseline,
|
|
275
|
+
missing: false,
|
|
276
|
+
signed: 0,
|
|
277
|
+
interpolated: true,
|
|
278
|
+
leftSourceRowIndex: left.sourceRowIndex,
|
|
279
|
+
rightSourceRowIndex: right.sourceRowIndex,
|
|
280
|
+
fraction
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
output.push({ ...right });
|
|
284
|
+
}
|
|
285
|
+
return output;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function pointSign(value) {
|
|
289
|
+
return value > 0 ? "positive" : value < 0 ? "negative" : "zero";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function runsForSign(points, desired) {
|
|
293
|
+
const runs = [];
|
|
294
|
+
let current = [];
|
|
295
|
+
let pendingZeros = [];
|
|
296
|
+
const flush = () => {
|
|
297
|
+
if (current.some(point => pointSign(point.signed) === desired)) {
|
|
298
|
+
runs.push(current);
|
|
299
|
+
}
|
|
300
|
+
current = [];
|
|
301
|
+
};
|
|
302
|
+
for (const point of points) {
|
|
303
|
+
const sign = pointSign(point.signed);
|
|
304
|
+
if (sign === desired) {
|
|
305
|
+
if (current.length === 0 && pendingZeros.length > 0) {
|
|
306
|
+
current.push(...pendingZeros);
|
|
307
|
+
}
|
|
308
|
+
current.push(point);
|
|
309
|
+
pendingZeros = [];
|
|
310
|
+
} else if (sign === "zero") {
|
|
311
|
+
if (current.length > 0) {
|
|
312
|
+
current.push(point);
|
|
313
|
+
flush();
|
|
314
|
+
}
|
|
315
|
+
pendingZeros = [point];
|
|
316
|
+
} else {
|
|
317
|
+
flush();
|
|
318
|
+
pendingZeros = [];
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
flush();
|
|
322
|
+
return runs;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function maximumMagnitude(group) {
|
|
326
|
+
return Math.max(
|
|
327
|
+
0,
|
|
328
|
+
...group.segments.flatMap(segment =>
|
|
329
|
+
segment.map(point => Math.abs(point.signed))
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function resolvedExtents(groups, transform) {
|
|
335
|
+
if (transform.extent !== "auto") return groups.map(() => transform.extent);
|
|
336
|
+
if (transform.resolve === "shared") {
|
|
337
|
+
const shared = Math.max(0, ...groups.map(maximumMagnitude));
|
|
338
|
+
return groups.map(() => shared);
|
|
339
|
+
}
|
|
340
|
+
return groups.map(maximumMagnitude);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function seriesForGroup(group, extent, transform) {
|
|
344
|
+
if (extent === 0) return [];
|
|
345
|
+
const bandHeight = extent / transform.bands;
|
|
346
|
+
const output = [];
|
|
347
|
+
for (const sign of ["negative", "positive"]) {
|
|
348
|
+
const runs = [];
|
|
349
|
+
let segmentIndex = 0;
|
|
350
|
+
for (const source of group.segments) {
|
|
351
|
+
for (const points of runsForSign(source, sign)) {
|
|
352
|
+
runs.push({ points, segmentIndex });
|
|
353
|
+
segmentIndex += 1;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (let bandIndex = 0; bandIndex < transform.bands; bandIndex += 1) {
|
|
357
|
+
for (const run of runs) {
|
|
358
|
+
const points = run.points.map(point => {
|
|
359
|
+
const rawMagnitude = Math.abs(point.signed);
|
|
360
|
+
if (rawMagnitude > extent && transform.overflow === "error") {
|
|
361
|
+
throw new RangeError(
|
|
362
|
+
`Horizon magnitude ${rawMagnitude} exceeds extent ${extent}.`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
const magnitude = Math.min(rawMagnitude, extent);
|
|
366
|
+
const amplitude = Math.min(
|
|
367
|
+
bandHeight,
|
|
368
|
+
Math.max(0, magnitude - bandIndex * bandHeight)
|
|
369
|
+
);
|
|
370
|
+
return {
|
|
371
|
+
...point,
|
|
372
|
+
magnitude,
|
|
373
|
+
rawMagnitude,
|
|
374
|
+
amplitude,
|
|
375
|
+
fraction: amplitude / bandHeight,
|
|
376
|
+
overflowed: rawMagnitude > extent
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
if (points.some(point => point.amplitude > 0)) {
|
|
380
|
+
const groupIdentity = transform.groupBy === undefined
|
|
381
|
+
? null
|
|
382
|
+
: group.group;
|
|
383
|
+
output.push({
|
|
384
|
+
group: group.group,
|
|
385
|
+
groupIndex: group.groupIndex,
|
|
386
|
+
sign,
|
|
387
|
+
bandIndex,
|
|
388
|
+
segmentIndex: run.segmentIndex,
|
|
389
|
+
seriesKey: JSON.stringify([
|
|
390
|
+
groupIdentity,
|
|
391
|
+
sign,
|
|
392
|
+
bandIndex,
|
|
393
|
+
run.segmentIndex
|
|
394
|
+
]),
|
|
395
|
+
colorKey: `${sign}:${bandIndex}`,
|
|
396
|
+
bandHeight,
|
|
397
|
+
points
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return output;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function generatedRows(series, transform) {
|
|
407
|
+
return series.flatMap(item => item.points.map(point => ({
|
|
408
|
+
...(transform.groupBy === undefined
|
|
409
|
+
? {}
|
|
410
|
+
: { [transform.groupBy]: item.group }),
|
|
411
|
+
[transform.as.x]: point.x,
|
|
412
|
+
[transform.as.lower]: 0,
|
|
413
|
+
[transform.as.upper]: point.fraction,
|
|
414
|
+
[transform.as.group]: item.seriesKey,
|
|
415
|
+
[transform.as.color]: item.colorKey,
|
|
416
|
+
[transform.as.sign]: item.sign,
|
|
417
|
+
[transform.as.band]: item.bandIndex,
|
|
418
|
+
[transform.as.segment]: item.segmentIndex
|
|
419
|
+
})));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function deriveHorizon(rows, requested) {
|
|
423
|
+
if (!Array.isArray(rows)) {
|
|
424
|
+
throw new TypeError("Horizon values must be an array.");
|
|
425
|
+
}
|
|
426
|
+
if (rows.length === 0) {
|
|
427
|
+
throw new Error("Horizon requires at least one source row.");
|
|
428
|
+
}
|
|
429
|
+
const transform = validateHorizonTransform(requested);
|
|
430
|
+
const grouped = groupRows(rows, transform).map(group => ({
|
|
431
|
+
...group,
|
|
432
|
+
segments: splitSegments(group.rows, transform.baseline).map(segment =>
|
|
433
|
+
insertCrossings(segment, transform.baseline)
|
|
434
|
+
)
|
|
435
|
+
}));
|
|
436
|
+
if (!grouped.some(group => group.rows.some(row => !row.missing))) {
|
|
437
|
+
throw new Error("Horizon requires at least one finite y value.");
|
|
438
|
+
}
|
|
439
|
+
const extents = resolvedExtents(grouped, transform);
|
|
440
|
+
const groups = grouped.map((group, index) => ({
|
|
441
|
+
...(transform.groupBy === undefined ? {} : { group: group.group }),
|
|
442
|
+
extent: extents[index],
|
|
443
|
+
bandHeight: extents[index] / transform.bands
|
|
444
|
+
}));
|
|
445
|
+
const series = grouped.flatMap((group, index) =>
|
|
446
|
+
seriesForGroup(group, extents[index], transform)
|
|
447
|
+
);
|
|
448
|
+
return cloneAndFreeze({
|
|
449
|
+
transform: requestedHorizonTransform(transform),
|
|
450
|
+
resolved: { extents: groups },
|
|
451
|
+
groups,
|
|
452
|
+
series,
|
|
453
|
+
values: generatedRows(series, transform)
|
|
454
|
+
});
|
|
455
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
|
|
2
|
+
import { validateNonEmptyString } from "../core/validation.js";
|
|
3
|
+
|
|
4
|
+
const UINT32_RANGE = 0x1_0000_0000;
|
|
5
|
+
const CATEGORICAL_SCALE_TYPES = new Set(["band", "point", "ordinal"]);
|
|
6
|
+
|
|
7
|
+
export const JITTER_ALGORITHM = "jitter-uniform-v1";
|
|
8
|
+
|
|
9
|
+
export function canonicalJitterScalar(value, label = "Jitter identity") {
|
|
10
|
+
if (typeof value === "string") {
|
|
11
|
+
return `string:${new TextEncoder().encode(value).length}:${value}`;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
14
|
+
return `number:${Object.is(value, -0) ? "0" : String(value)}`;
|
|
15
|
+
}
|
|
16
|
+
if (typeof value === "boolean") return `boolean:${value}`;
|
|
17
|
+
throw new TypeError(`${label} must be a string, finite number, or boolean.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function stableUint32HashV1(value) {
|
|
21
|
+
const bytes = new TextEncoder().encode(value);
|
|
22
|
+
let hash = 0x811c9dc5;
|
|
23
|
+
for (const byte of bytes) {
|
|
24
|
+
hash ^= byte;
|
|
25
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
26
|
+
}
|
|
27
|
+
return hash;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function jitterIdentity({ target, channel, seed, identity }) {
|
|
31
|
+
return [
|
|
32
|
+
canonicalJitterScalar(target, "Jitter target"),
|
|
33
|
+
canonicalJitterScalar(channel, "Jitter channel"),
|
|
34
|
+
canonicalJitterScalar(seed, "Jitter seed"),
|
|
35
|
+
canonicalJitterScalar(identity, "Jitter key")
|
|
36
|
+
].join("\0");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requestedOffset(options, maximum) {
|
|
40
|
+
const hash = stableUint32HashV1(jitterIdentity(options));
|
|
41
|
+
return (2 * (hash / UINT32_RANGE) - 1) * maximum;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizePointJitterPolicy(options = {}) {
|
|
45
|
+
if (!isPlainObject(options)) {
|
|
46
|
+
throw new TypeError("Point jitter options must be a plain object.");
|
|
47
|
+
}
|
|
48
|
+
if (!["x", "y"].includes(options.channel)) {
|
|
49
|
+
throw new Error('Point jitter channel must be "x" or "y".');
|
|
50
|
+
}
|
|
51
|
+
const maxOffset = options.maxOffset;
|
|
52
|
+
if (!isPlainObject(maxOffset)) {
|
|
53
|
+
throw new TypeError("Point jitter maxOffset must be a plain object.");
|
|
54
|
+
}
|
|
55
|
+
const keys = Object.keys(maxOffset);
|
|
56
|
+
if (
|
|
57
|
+
keys.length !== 1 ||
|
|
58
|
+
!["pixels", "band"].includes(keys[0])
|
|
59
|
+
) {
|
|
60
|
+
throw new Error("Point jitter maxOffset requires exactly one of pixels or band.");
|
|
61
|
+
}
|
|
62
|
+
const kind = keys[0];
|
|
63
|
+
const value = maxOffset[kind];
|
|
64
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
65
|
+
throw new RangeError(`Point jitter ${kind} must be positive and finite.`);
|
|
66
|
+
}
|
|
67
|
+
if (kind === "band" && value > 0.5) {
|
|
68
|
+
throw new RangeError("Point jitter band must be at most 0.5.");
|
|
69
|
+
}
|
|
70
|
+
const seed = options.seed ?? 0;
|
|
71
|
+
if (!(
|
|
72
|
+
typeof seed === "string" ||
|
|
73
|
+
(typeof seed === "number" && Number.isFinite(seed))
|
|
74
|
+
)) {
|
|
75
|
+
throw new TypeError("Point jitter seed must be a string or finite number.");
|
|
76
|
+
}
|
|
77
|
+
const key = options.key === undefined
|
|
78
|
+
? undefined
|
|
79
|
+
: validateNonEmptyString(options.key, "Point jitter key");
|
|
80
|
+
return cloneAndFreeze({
|
|
81
|
+
algorithm: JITTER_ALGORITHM,
|
|
82
|
+
channel: options.channel,
|
|
83
|
+
maxOffset: { [kind]: value },
|
|
84
|
+
seed,
|
|
85
|
+
...(key === undefined ? {} : { key })
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveJitterSlotWidth(scale) {
|
|
90
|
+
if (!CATEGORICAL_SCALE_TYPES.has(scale?.type)) return undefined;
|
|
91
|
+
const width = scale.bandwidth > 0
|
|
92
|
+
? scale.bandwidth
|
|
93
|
+
: Math.abs(scale.step);
|
|
94
|
+
return Number.isFinite(width) && width > 0 ? width : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveJitterMaximum(policy, scale) {
|
|
98
|
+
if (policy.maxOffset.pixels !== undefined) {
|
|
99
|
+
return policy.maxOffset.pixels;
|
|
100
|
+
}
|
|
101
|
+
const slotWidth = resolveJitterSlotWidth(scale);
|
|
102
|
+
if (slotWidth === undefined) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"Band point jitter requires a categorical position scale with a positive slot."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return slotWidth * policy.maxOffset.band;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function containOffset({
|
|
111
|
+
base,
|
|
112
|
+
requested,
|
|
113
|
+
plotMinimum,
|
|
114
|
+
plotMaximum,
|
|
115
|
+
halfExtent,
|
|
116
|
+
slotWidth
|
|
117
|
+
}) {
|
|
118
|
+
let minimum = plotMinimum + halfExtent;
|
|
119
|
+
let maximum = plotMaximum - halfExtent;
|
|
120
|
+
if (slotWidth !== undefined) {
|
|
121
|
+
minimum = Math.max(minimum, base - slotWidth / 2 + halfExtent);
|
|
122
|
+
maximum = Math.min(maximum, base + slotWidth / 2 - halfExtent);
|
|
123
|
+
}
|
|
124
|
+
if (minimum > maximum) {
|
|
125
|
+
return { offset: 0, clamped: true, available: false };
|
|
126
|
+
}
|
|
127
|
+
const final = Math.max(minimum, Math.min(maximum, base + requested));
|
|
128
|
+
return {
|
|
129
|
+
offset: final - base,
|
|
130
|
+
clamped: final !== base + requested,
|
|
131
|
+
available: true
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolvePointJitter({
|
|
136
|
+
target,
|
|
137
|
+
policy,
|
|
138
|
+
scale,
|
|
139
|
+
entries,
|
|
140
|
+
plotMinimum,
|
|
141
|
+
plotMaximum
|
|
142
|
+
}) {
|
|
143
|
+
validateNonEmptyString(target, "Point jitter target");
|
|
144
|
+
if (!Array.isArray(entries)) {
|
|
145
|
+
throw new TypeError("Point jitter entries must be an array.");
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
!Number.isFinite(plotMinimum) ||
|
|
149
|
+
!Number.isFinite(plotMaximum) ||
|
|
150
|
+
plotMaximum < plotMinimum
|
|
151
|
+
) {
|
|
152
|
+
throw new RangeError("Point jitter plot bounds are invalid.");
|
|
153
|
+
}
|
|
154
|
+
const maximum = resolveJitterMaximum(policy, scale);
|
|
155
|
+
const slotWidth = resolveJitterSlotWidth(scale);
|
|
156
|
+
const items = entries.map((entry, itemIndex) => {
|
|
157
|
+
if (!Number.isInteger(entry.index) || entry.index < 0) {
|
|
158
|
+
throw new TypeError(`Point jitter entry ${itemIndex} index must be non-negative.`);
|
|
159
|
+
}
|
|
160
|
+
if (!Number.isFinite(entry.base) || !Number.isFinite(entry.halfExtent)) {
|
|
161
|
+
throw new TypeError(`Point jitter entry ${itemIndex} geometry must be finite.`);
|
|
162
|
+
}
|
|
163
|
+
if (entry.halfExtent < 0) {
|
|
164
|
+
throw new RangeError(`Point jitter entry ${itemIndex} extent must not be negative.`);
|
|
165
|
+
}
|
|
166
|
+
const requested = requestedOffset({
|
|
167
|
+
target,
|
|
168
|
+
channel: policy.channel,
|
|
169
|
+
seed: policy.seed,
|
|
170
|
+
identity: entry.identity
|
|
171
|
+
}, maximum);
|
|
172
|
+
const contained = containOffset({
|
|
173
|
+
base: entry.base,
|
|
174
|
+
requested,
|
|
175
|
+
plotMinimum,
|
|
176
|
+
plotMaximum,
|
|
177
|
+
halfExtent: entry.halfExtent,
|
|
178
|
+
slotWidth
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
index: entry.index,
|
|
182
|
+
identity: entry.identity,
|
|
183
|
+
requestedOffset: requested,
|
|
184
|
+
finalOffset: contained.offset,
|
|
185
|
+
clamped: contained.clamped,
|
|
186
|
+
available: contained.available
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
return cloneAndFreeze({
|
|
190
|
+
algorithm: JITTER_ALGORITHM,
|
|
191
|
+
maximum,
|
|
192
|
+
itemCount: items.length,
|
|
193
|
+
clampedItemCount: items.filter(item => item.clamped).length,
|
|
194
|
+
unavailableItemCount: items.filter(item => !item.available).length,
|
|
195
|
+
items
|
|
196
|
+
});
|
|
197
|
+
}
|