ggaction 0.0.1
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 +27 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/package.json +84 -0
- package/src/AGENTS.md +119 -0
- package/src/ChartProgram.js +10 -0
- package/src/actions/boxPlots/components.js +88 -0
- package/src/actions/boxPlots/create.js +84 -0
- package/src/actions/boxPlots/index.js +10 -0
- package/src/actions/boxPlots/materialize.js +183 -0
- package/src/actions/boxPlots/options.js +147 -0
- package/src/actions/boxPlots/resolve.js +38 -0
- package/src/actions/canvas/actions.js +144 -0
- package/src/actions/canvas/index.js +6 -0
- package/src/actions/coordinates/actions.js +85 -0
- package/src/actions/coordinates/index.js +5 -0
- package/src/actions/data/box.js +40 -0
- package/src/actions/data/create.js +33 -0
- package/src/actions/data/density.js +62 -0
- package/src/actions/data/derived.js +52 -0
- package/src/actions/data/filter.js +168 -0
- package/src/actions/data/index.js +32 -0
- package/src/actions/data/interval.js +69 -0
- package/src/actions/data/regression.js +71 -0
- package/src/actions/data/shared.js +25 -0
- package/src/actions/encodings/appearance.js +243 -0
- package/src/actions/encodings/barWidth.js +60 -0
- package/src/actions/encodings/color.js +329 -0
- package/src/actions/encodings/density.js +225 -0
- package/src/actions/encodings/histogram.js +50 -0
- package/src/actions/encodings/index.js +23 -0
- package/src/actions/encodings/offset.js +104 -0
- package/src/actions/encodings/position/policies/area.js +24 -0
- package/src/actions/encodings/position/policies/bar.js +110 -0
- package/src/actions/encodings/position/policies/common.js +17 -0
- package/src/actions/encodings/position/policies/index.js +25 -0
- package/src/actions/encodings/position/policies/line.js +74 -0
- package/src/actions/encodings/position/policies/point.js +19 -0
- package/src/actions/encodings/position/policies/rule.js +17 -0
- package/src/actions/encodings/position/resolve.js +170 -0
- package/src/actions/encodings/position.js +176 -0
- package/src/actions/encodings/ranged.js +276 -0
- package/src/actions/encodings/ruleAppearance.js +47 -0
- package/src/actions/encodings/shared.js +164 -0
- package/src/actions/encodings/strokeDash.js +145 -0
- package/src/actions/errorBands/components.js +81 -0
- package/src/actions/errorBands/create.js +159 -0
- package/src/actions/errorBands/index.js +7 -0
- package/src/actions/errorBands/resolve.js +28 -0
- package/src/actions/errorBars/components.js +62 -0
- package/src/actions/errorBars/create.js +144 -0
- package/src/actions/errorBars/index.js +7 -0
- package/src/actions/errorBars/resolve.js +25 -0
- package/src/actions/guides/axes/axes.js +204 -0
- package/src/actions/guides/axes/axis.js +131 -0
- package/src/actions/guides/axes/index.js +17 -0
- package/src/actions/guides/axes/labels.js +231 -0
- package/src/actions/guides/axes/lines.js +149 -0
- package/src/actions/guides/axes/policy.js +155 -0
- package/src/actions/guides/axes/tickGroups.js +149 -0
- package/src/actions/guides/axes/ticks.js +179 -0
- package/src/actions/guides/axes/titles.js +264 -0
- package/src/actions/guides/grids/grid.js +228 -0
- package/src/actions/guides/grids/index.js +1 -0
- package/src/actions/guides/grids/resolve.js +258 -0
- package/src/actions/guides/guides.js +138 -0
- package/src/actions/guides/index.js +11 -0
- package/src/actions/guides/legends/categorical/actions.js +329 -0
- package/src/actions/guides/legends/categorical/components.js +140 -0
- package/src/actions/guides/legends/categorical/index.js +49 -0
- package/src/actions/guides/legends/categorical/layout.js +445 -0
- package/src/actions/guides/legends/categorical/options.js +188 -0
- package/src/actions/guides/legends/categorical/recipes.js +120 -0
- package/src/actions/guides/legends/categorical/resolve.js +131 -0
- package/src/actions/guides/legends/categorical/symbols.js +280 -0
- package/src/actions/guides/legends/categorical/validation.js +27 -0
- package/src/actions/guides/legends/continuous/common.js +273 -0
- package/src/actions/guides/legends/continuous/gradient.js +363 -0
- package/src/actions/guides/legends/continuous/index.js +25 -0
- package/src/actions/guides/legends/continuous/interval.js +234 -0
- package/src/actions/guides/legends/continuous/opacity.js +382 -0
- package/src/actions/guides/legends/edit.js +314 -0
- package/src/actions/guides/legends/index.js +9 -0
- package/src/actions/guides/legends/size.js +220 -0
- package/src/actions/guides/tickValues.js +84 -0
- package/src/actions/index.js +31 -0
- package/src/actions/intervals/resolve.js +352 -0
- package/src/actions/marks/area.js +369 -0
- package/src/actions/marks/bar/create.js +46 -0
- package/src/actions/marks/bar/edit.js +90 -0
- package/src/actions/marks/bar/index.js +9 -0
- package/src/actions/marks/bar/materialize.js +130 -0
- package/src/actions/marks/index.js +13 -0
- package/src/actions/marks/line.js +248 -0
- package/src/actions/marks/point.js +420 -0
- package/src/actions/marks/rule.js +287 -0
- package/src/actions/marks/shared.js +112 -0
- package/src/actions/primitives/createGraphics.js +163 -0
- package/src/actions/primitives/editGraphics.js +208 -0
- package/src/actions/primitives/index.js +9 -0
- package/src/actions/primitives/semantic.js +168 -0
- package/src/actions/primitives/semanticValue.js +182 -0
- package/src/actions/regression/components.js +216 -0
- package/src/actions/regression/create.js +102 -0
- package/src/actions/regression/index.js +23 -0
- package/src/actions/regression/resolve.js +57 -0
- package/src/actions/scales/consumers.js +283 -0
- package/src/actions/scales/create.js +148 -0
- package/src/actions/scales/definitions.js +319 -0
- package/src/actions/scales/edit.js +290 -0
- package/src/actions/scales/index.js +11 -0
- package/src/actions/scales/materialize.js +466 -0
- package/src/actions/scales/quantitativeColor.js +45 -0
- package/src/actions/selection/actions.js +384 -0
- package/src/actions/selection/index.js +23 -0
- package/src/actions/titles/actions.js +235 -0
- package/src/actions/titles/index.js +19 -0
- package/src/actions/titles/resolve.js +321 -0
- package/src/core/ChartProgram.js +316 -0
- package/src/core/action.js +120 -0
- package/src/core/identifiers.js +28 -0
- package/src/core/immutable.js +94 -0
- package/src/core/specs.js +29 -0
- package/src/core/textMetrics.js +23 -0
- package/src/core/validation.js +54 -0
- package/src/core/vocabulary.js +58 -0
- package/src/extension.js +2 -0
- package/src/grammar/aggregate.js +259 -0
- package/src/grammar/areaSeries.js +217 -0
- package/src/grammar/bars/aggregate.js +98 -0
- package/src/grammar/bars/geometry.js +54 -0
- package/src/grammar/bars/policy.js +90 -0
- package/src/grammar/boxPlot.js +140 -0
- package/src/grammar/coordinates.js +25 -0
- package/src/grammar/curveCommands.js +356 -0
- package/src/grammar/density.js +296 -0
- package/src/grammar/filter.js +169 -0
- package/src/grammar/histogram.js +332 -0
- package/src/grammar/interval.js +343 -0
- package/src/grammar/lineSeries.js +197 -0
- package/src/grammar/markFilter.js +46 -0
- package/src/grammar/markSelection.js +326 -0
- package/src/grammar/palettes.js +208 -0
- package/src/grammar/pathCommands.js +86 -0
- package/src/grammar/pointShapes.js +164 -0
- package/src/grammar/positionCompatibility.js +32 -0
- package/src/grammar/regression.js +578 -0
- package/src/grammar/ruleAppearance.js +12 -0
- package/src/grammar/rules.js +90 -0
- package/src/grammar/scales/appearance.js +185 -0
- package/src/grammar/scales/color.js +349 -0
- package/src/grammar/scales/continuous.js +111 -0
- package/src/grammar/scales/definition.js +153 -0
- package/src/grammar/scales/discretized.js +159 -0
- package/src/grammar/scales/fields.js +133 -0
- package/src/grammar/scales/index.js +12 -0
- package/src/grammar/scales/mapping.js +27 -0
- package/src/grammar/scales/ordinal.js +221 -0
- package/src/grammar/scales/policies.js +50 -0
- package/src/grammar/scales/temporal.js +53 -0
- package/src/grammar/scales/transformed.js +236 -0
- package/src/grammar/scales/types.js +102 -0
- package/src/grammar/scales/validation.js +66 -0
- package/src/grammar/scales.js +1 -0
- package/src/grammar/schemas/concreteGraphic.js +73 -0
- package/src/grammar/schemas/graphic.js +131 -0
- package/src/grammar/schemas/graphicBounds.js +146 -0
- package/src/grammar/schemas/graphicTree.js +133 -0
- package/src/grammar/schemas/mark.js +4 -0
- package/src/grammar/schemas/semanticPath.js +174 -0
- package/src/grammar/seriesLayout.js +76 -0
- package/src/grammar/ticks.js +149 -0
- package/src/grammar/transforms.js +37 -0
- package/src/index.js +2 -0
- package/src/layout/canvas.js +114 -0
- package/src/layout/grid.js +17 -0
- package/src/layout/legend.js +51 -0
- package/src/layout/text.js +62 -0
- package/src/layout/title.js +107 -0
- package/src/materialization/bars/aggregate.js +184 -0
- package/src/materialization/bars/grouped.js +117 -0
- package/src/materialization/bars/histogram.js +163 -0
- package/src/materialization/bars/ranged.js +53 -0
- package/src/materialization/bars/resolve.js +56 -0
- package/src/materialization/dataProvenance.js +15 -0
- package/src/materialization/density.js +29 -0
- package/src/materialization/dependencies.js +134 -0
- package/src/materialization/encodings.js +50 -0
- package/src/materialization/graphicHierarchy.js +104 -0
- package/src/materialization/legends.js +20 -0
- package/src/materialization/marks.js +110 -0
- package/src/materialization/planner.js +38 -0
- package/src/materialization/selection/filter.js +40 -0
- package/src/materialization/selection/items.js +503 -0
- package/src/materialization/selection/path.js +37 -0
- package/src/materialization/selection/point.js +118 -0
- package/src/materialization/selection/policies/area.js +10 -0
- package/src/materialization/selection/policies/bar.js +10 -0
- package/src/materialization/selection/policies/index.js +40 -0
- package/src/materialization/selection/policies/line.js +10 -0
- package/src/materialization/selection/policies/point.js +10 -0
- package/src/materialization/selection/policies/rule.js +10 -0
- package/src/materialization/selection/state.js +67 -0
- package/src/materialization/selection/styles.js +183 -0
- package/src/renderers/canvas/circle.js +62 -0
- package/src/renderers/canvas/index.js +132 -0
- package/src/renderers/canvas/line.js +54 -0
- package/src/renderers/canvas/path.js +98 -0
- package/src/renderers/canvas/rect.js +57 -0
- package/src/renderers/canvas/text.js +96 -0
- package/src/renderers/canvas/validation.js +23 -0
- package/src/renderers/png.js +29 -0
- package/src/selectors/coordinates.js +13 -0
- package/src/selectors/datasets.js +13 -0
- package/src/selectors/index.js +4 -0
- package/src/selectors/layers.js +32 -0
- package/src/selectors/scales.js +21 -0
- package/src/theme/defaults.js +13 -0
- package/types/extension.d.ts +14 -0
- package/types/index.d.ts +81 -0
- package/types/png.d.ts +14 -0
- package/types/program.d.ts +1096 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { USER_ID_SOURCE } from "../../core/identifiers.js";
|
|
2
|
+
import {
|
|
3
|
+
ENCODING_CHANNELS,
|
|
4
|
+
POSITION_CHANNELS,
|
|
5
|
+
SCALED_ENCODING_CHANNELS
|
|
6
|
+
} from "../../core/vocabulary.js";
|
|
7
|
+
|
|
8
|
+
const ENCODING_PATHS = Object.freeze([
|
|
9
|
+
...ENCODING_CHANNELS.flatMap(channel => [
|
|
10
|
+
`encoding.${channel}.field`,
|
|
11
|
+
`encoding.${channel}.datum`,
|
|
12
|
+
`encoding.${channel}.fieldType`,
|
|
13
|
+
`encoding.${channel}.title`
|
|
14
|
+
]),
|
|
15
|
+
...SCALED_ENCODING_CHANNELS.map(channel =>
|
|
16
|
+
`encoding.${channel}.scale`
|
|
17
|
+
),
|
|
18
|
+
"encoding.x.bin.maxBins",
|
|
19
|
+
"encoding.x.bin.step",
|
|
20
|
+
"encoding.x.bin.boundaries",
|
|
21
|
+
"encoding.color.layout",
|
|
22
|
+
"encoding.color.aggregate",
|
|
23
|
+
...POSITION_CHANNELS.flatMap(channel => [
|
|
24
|
+
`encoding.${channel}.aggregate`,
|
|
25
|
+
`encoding.${channel}.stack`
|
|
26
|
+
])
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const ENTITY_PATHS = Object.freeze({
|
|
30
|
+
dataset: {
|
|
31
|
+
collection: "datasets",
|
|
32
|
+
properties: new Set(["source", "transform", "values"])
|
|
33
|
+
},
|
|
34
|
+
layer: {
|
|
35
|
+
collection: "layers",
|
|
36
|
+
removableContainers: new Set(
|
|
37
|
+
ENCODING_CHANNELS.map(channel => `encoding.${channel}`)
|
|
38
|
+
),
|
|
39
|
+
properties: new Set([
|
|
40
|
+
"data",
|
|
41
|
+
"coordinate",
|
|
42
|
+
"transform",
|
|
43
|
+
"mark.type",
|
|
44
|
+
...ENCODING_PATHS
|
|
45
|
+
])
|
|
46
|
+
},
|
|
47
|
+
scale: {
|
|
48
|
+
collection: "scales",
|
|
49
|
+
properties: new Set([
|
|
50
|
+
"type", "domain", "range", "nice", "zero", "clamp", "reverse",
|
|
51
|
+
"unknown", "base", "exponent", "constant", "interpolate",
|
|
52
|
+
"paddingInner", "paddingOuter", "padding", "align"
|
|
53
|
+
])
|
|
54
|
+
},
|
|
55
|
+
coordinate: {
|
|
56
|
+
collection: "coordinates",
|
|
57
|
+
properties: new Set(["type"])
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const GUIDE_PATHS = new Set([
|
|
62
|
+
"axis.x.scale",
|
|
63
|
+
"axis.x.coordinate",
|
|
64
|
+
"axis.x.title",
|
|
65
|
+
"axis.y.scale",
|
|
66
|
+
"axis.y.coordinate",
|
|
67
|
+
"axis.y.title",
|
|
68
|
+
"legend.color.scale",
|
|
69
|
+
"legend.color.title",
|
|
70
|
+
"legend.size.scale",
|
|
71
|
+
"legend.size.title",
|
|
72
|
+
"legend.opacity.scale",
|
|
73
|
+
"legend.opacity.title",
|
|
74
|
+
"legend.series.channels",
|
|
75
|
+
"legend.series.scales",
|
|
76
|
+
"legend.series.title",
|
|
77
|
+
"grid.horizontal.scale",
|
|
78
|
+
"grid.horizontal.coordinate",
|
|
79
|
+
"grid.vertical.scale",
|
|
80
|
+
"grid.vertical.coordinate"
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const GUIDE_REMOVABLE_CONTAINERS = new Set([
|
|
84
|
+
"legend.color",
|
|
85
|
+
"legend.size",
|
|
86
|
+
"legend.opacity",
|
|
87
|
+
"legend.series"
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const TITLE_PATHS = new Set(["text", "subtitle"]);
|
|
91
|
+
|
|
92
|
+
function splitPropertyPath(property) {
|
|
93
|
+
return property.split(".");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseSemanticPath(property, { allowContainer = false } = {}) {
|
|
97
|
+
if (typeof property !== "string" || property.length === 0) {
|
|
98
|
+
throw new TypeError("editSemantic requires a non-empty property string.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (allowContainer) {
|
|
102
|
+
const datasetMatch = property.match(
|
|
103
|
+
new RegExp(`^dataset\\[(${USER_ID_SOURCE})\\]$`)
|
|
104
|
+
);
|
|
105
|
+
if (datasetMatch) {
|
|
106
|
+
return Object.freeze({
|
|
107
|
+
kind: "dataset",
|
|
108
|
+
id: datasetMatch[1],
|
|
109
|
+
collection: "datasets",
|
|
110
|
+
path: Object.freeze([])
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const entityMatch = property.match(
|
|
116
|
+
new RegExp(
|
|
117
|
+
`^(${Object.keys(ENTITY_PATHS).join("|")})\\[(${USER_ID_SOURCE})\\]\\.(.+)$`
|
|
118
|
+
)
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (entityMatch) {
|
|
122
|
+
const [, kind, id, propertyPath] = entityMatch;
|
|
123
|
+
const definition = ENTITY_PATHS[kind];
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
!definition.properties.has(propertyPath) &&
|
|
127
|
+
!(allowContainer && definition.removableContainers?.has(propertyPath))
|
|
128
|
+
) {
|
|
129
|
+
throw new Error(`Unknown semantic property "${property}".`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
kind,
|
|
134
|
+
id,
|
|
135
|
+
collection: definition.collection,
|
|
136
|
+
path: Object.freeze(splitPropertyPath(propertyPath))
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (property.startsWith("guide.")) {
|
|
141
|
+
const propertyPath = property.slice("guide.".length);
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
!GUIDE_PATHS.has(propertyPath) &&
|
|
145
|
+
!(allowContainer && GUIDE_REMOVABLE_CONTAINERS.has(propertyPath))
|
|
146
|
+
) {
|
|
147
|
+
throw new Error(`Unknown semantic property "${property}".`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
kind: "guide",
|
|
152
|
+
id: propertyPath.split(".").slice(0, 2).join("."),
|
|
153
|
+
collection: "guides",
|
|
154
|
+
path: Object.freeze(splitPropertyPath(propertyPath))
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (property.startsWith("title.")) {
|
|
159
|
+
const propertyPath = property.slice("title.".length);
|
|
160
|
+
|
|
161
|
+
if (!TITLE_PATHS.has(propertyPath)) {
|
|
162
|
+
throw new Error(`Unknown semantic property "${property}".`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
kind: "title",
|
|
167
|
+
id: "title",
|
|
168
|
+
collection: "title",
|
|
169
|
+
path: Object.freeze(splitPropertyPath(propertyPath))
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
throw new Error(`Unknown semantic property "${property}".`);
|
|
174
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { cloneAndFreeze } from "../core/immutable.js";
|
|
2
|
+
import { COLOR_LAYOUTS } from "../core/vocabulary.js";
|
|
3
|
+
|
|
4
|
+
export function validateColorLayout(layout) {
|
|
5
|
+
if (!COLOR_LAYOUTS.includes(layout)) {
|
|
6
|
+
throw new Error(`Unsupported color layout "${layout}".`);
|
|
7
|
+
}
|
|
8
|
+
return layout;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validateValues(values) {
|
|
12
|
+
if (!Array.isArray(values) || !values.every(Number.isFinite)) {
|
|
13
|
+
throw new TypeError("Series layout values must be finite numbers.");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function layoutSeriesPartition(values, layout, { baseline = 0 } = {}) {
|
|
18
|
+
validateValues(values);
|
|
19
|
+
validateColorLayout(layout);
|
|
20
|
+
if (!Number.isFinite(baseline)) {
|
|
21
|
+
throw new TypeError("Series layout baseline must be finite.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (layout === "group" || layout === "overlay") {
|
|
25
|
+
return cloneAndFreeze(values.flatMap((value, index) =>
|
|
26
|
+
value === baseline
|
|
27
|
+
? []
|
|
28
|
+
: [{ index, value, start: baseline, end: value }]
|
|
29
|
+
));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (layout === "stack" || layout === "fill") {
|
|
33
|
+
if (values.some(value => value < 0)) {
|
|
34
|
+
throw new RangeError(`${layout} layout requires non-negative values.`);
|
|
35
|
+
}
|
|
36
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
37
|
+
if (layout === "fill" && total === 0) return cloneAndFreeze([]);
|
|
38
|
+
let offset = 0;
|
|
39
|
+
return cloneAndFreeze(values.flatMap((value, index) => {
|
|
40
|
+
if (value === 0) return [];
|
|
41
|
+
const resolvedValue = layout === "fill" ? value / total : value;
|
|
42
|
+
const start = offset;
|
|
43
|
+
const end = start + resolvedValue;
|
|
44
|
+
offset = end;
|
|
45
|
+
return [{ index, value, start, end }];
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let positive = 0;
|
|
50
|
+
let negative = 0;
|
|
51
|
+
return cloneAndFreeze(values.flatMap((value, index) => {
|
|
52
|
+
if (value === 0) return [];
|
|
53
|
+
const start = value > 0 ? positive : negative;
|
|
54
|
+
const end = start + value;
|
|
55
|
+
if (value > 0) positive = end;
|
|
56
|
+
else negative = end;
|
|
57
|
+
return [{ index, value, start, end }];
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveSeriesLayoutDomainValues(partitions, layout) {
|
|
62
|
+
if (!Array.isArray(partitions)) {
|
|
63
|
+
throw new TypeError("Series layout partitions must be an array.");
|
|
64
|
+
}
|
|
65
|
+
validateColorLayout(layout);
|
|
66
|
+
for (const partition of partitions) validateValues(partition);
|
|
67
|
+
|
|
68
|
+
if (layout === "fill") return [0, 1];
|
|
69
|
+
if (layout === "group" || layout === "overlay") return partitions.flat();
|
|
70
|
+
return partitions.flatMap(partition =>
|
|
71
|
+
layoutSeriesPartition(partition, layout).flatMap(segment => [
|
|
72
|
+
segment.start,
|
|
73
|
+
segment.end
|
|
74
|
+
])
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { cloneAndFreeze } from "../core/immutable.js";
|
|
2
|
+
|
|
3
|
+
const LINEAR_FACTORS = Object.freeze([1, 2, 3, 5]);
|
|
4
|
+
|
|
5
|
+
export function niceTicks(domain, count) {
|
|
6
|
+
if (!Number.isInteger(count) || count <= 0) throw new RangeError("Tick count must be a positive integer.");
|
|
7
|
+
const low = Math.min(...domain);
|
|
8
|
+
const high = Math.max(...domain);
|
|
9
|
+
if (low === high) return cloneAndFreeze([low]);
|
|
10
|
+
const rough = (high - low) / count;
|
|
11
|
+
const power = 10 ** Math.floor(Math.log10(rough));
|
|
12
|
+
const error = rough / power;
|
|
13
|
+
const factor = LINEAR_FACTORS.find(candidate => candidate >= error) ?? 10;
|
|
14
|
+
const step = factor * power;
|
|
15
|
+
const tolerance = step * 1e-10;
|
|
16
|
+
const start = Math.ceil((low - tolerance) / step) * step;
|
|
17
|
+
const end = Math.floor((high + tolerance) / step) * step;
|
|
18
|
+
const values = [];
|
|
19
|
+
for (let value = start; value <= end + step * 1e-10; value += step) values.push(+value.toPrecision(12));
|
|
20
|
+
return cloneAndFreeze(values);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SECOND = 1000;
|
|
24
|
+
const MINUTE = 60 * SECOND;
|
|
25
|
+
const HOUR = 60 * MINUTE;
|
|
26
|
+
const DAY = 24 * HOUR;
|
|
27
|
+
const YEAR = 365.2425 * DAY;
|
|
28
|
+
|
|
29
|
+
const TIME_INTERVALS = Object.freeze([
|
|
30
|
+
...[1, 2, 5, 10].map(step => ({ unit: "year", step, duration: step * YEAR })),
|
|
31
|
+
...[1, 2, 3, 6].map(step => ({ unit: "month", step, duration: step * YEAR / 12 })),
|
|
32
|
+
...[1, 2, 7, 14].map(step => ({ unit: "day", step, duration: step * DAY })),
|
|
33
|
+
...[1, 3, 6, 12].map(step => ({ unit: "hour", step, duration: step * HOUR })),
|
|
34
|
+
...[1, 5, 15, 30].map(step => ({ unit: "minute", step, duration: step * MINUTE })),
|
|
35
|
+
...[1, 5, 15, 30].map(step => ({ unit: "second", step, duration: step * SECOND }))
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function validateTimeDomain(domain) {
|
|
39
|
+
if (
|
|
40
|
+
!Array.isArray(domain) ||
|
|
41
|
+
domain.length !== 2 ||
|
|
42
|
+
!domain.every(Number.isFinite)
|
|
43
|
+
) {
|
|
44
|
+
throw new TypeError("Time tick domain must contain two finite timestamps.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return [Math.min(...domain), Math.max(...domain)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function selectTimeInterval(span, count) {
|
|
51
|
+
return TIME_INTERVALS.reduce((best, candidate) => {
|
|
52
|
+
const error = Math.abs(span / candidate.duration - count);
|
|
53
|
+
return error < best.error ? { ...candidate, error } : best;
|
|
54
|
+
}, { ...TIME_INTERVALS[0], error: Infinity });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function floorCalendar(timestamp, { unit, step }) {
|
|
58
|
+
const date = new Date(timestamp);
|
|
59
|
+
|
|
60
|
+
if (unit === "year") {
|
|
61
|
+
const year = Math.floor(date.getUTCFullYear() / step) * step;
|
|
62
|
+
return Date.UTC(year, 0, 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (unit === "month") {
|
|
66
|
+
const month = date.getUTCFullYear() * 12 + date.getUTCMonth();
|
|
67
|
+
const aligned = Math.floor(month / step) * step;
|
|
68
|
+
return Date.UTC(Math.floor(aligned / 12), aligned % 12, 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const duration = unit === "day"
|
|
72
|
+
? step * DAY
|
|
73
|
+
: unit === "hour"
|
|
74
|
+
? step * HOUR
|
|
75
|
+
: unit === "minute"
|
|
76
|
+
? step * MINUTE
|
|
77
|
+
: step * SECOND;
|
|
78
|
+
|
|
79
|
+
return Math.floor(timestamp / duration) * duration;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addCalendar(timestamp, { unit, step }) {
|
|
83
|
+
const date = new Date(timestamp);
|
|
84
|
+
|
|
85
|
+
if (unit === "year") {
|
|
86
|
+
return Date.UTC(date.getUTCFullYear() + step, 0, 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (unit === "month") {
|
|
90
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + step, 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const duration = unit === "day"
|
|
94
|
+
? step * DAY
|
|
95
|
+
: unit === "hour"
|
|
96
|
+
? step * HOUR
|
|
97
|
+
: unit === "minute"
|
|
98
|
+
? step * MINUTE
|
|
99
|
+
: step * SECOND;
|
|
100
|
+
|
|
101
|
+
return timestamp + duration;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function timeTicks(domain, count) {
|
|
105
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
106
|
+
throw new RangeError("Time tick count must be a positive integer.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const [low, high] = validateTimeDomain(domain);
|
|
110
|
+
if (low === high) return cloneAndFreeze([low]);
|
|
111
|
+
const interval = selectTimeInterval(high - low, count);
|
|
112
|
+
let value = floorCalendar(low, interval);
|
|
113
|
+
if (value < low) value = addCalendar(value, interval);
|
|
114
|
+
const values = [];
|
|
115
|
+
|
|
116
|
+
while (value <= high) {
|
|
117
|
+
values.push(value);
|
|
118
|
+
value = addCalendar(value, interval);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return cloneAndFreeze(values.length === 0 ? [low, high] : values);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function pad(value) {
|
|
125
|
+
return String(value).padStart(2, "0");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function formatTimeTick(value, domain) {
|
|
129
|
+
if (!Number.isFinite(value)) {
|
|
130
|
+
throw new TypeError("Time tick value must be a finite timestamp.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const [low, high] = validateTimeDomain(domain);
|
|
134
|
+
const span = high - low;
|
|
135
|
+
const date = new Date(value);
|
|
136
|
+
const year = String(date.getUTCFullYear()).padStart(4, "0");
|
|
137
|
+
const month = pad(date.getUTCMonth() + 1);
|
|
138
|
+
const day = pad(date.getUTCDate());
|
|
139
|
+
const hour = pad(date.getUTCHours());
|
|
140
|
+
const minute = pad(date.getUTCMinutes());
|
|
141
|
+
const second = pad(date.getUTCSeconds());
|
|
142
|
+
|
|
143
|
+
if (span >= 2 * YEAR) return year;
|
|
144
|
+
if (span >= 60 * DAY) return `${year}-${month}`;
|
|
145
|
+
if (span >= 2 * DAY) return `${year}-${month}-${day}`;
|
|
146
|
+
if (span >= 2 * HOUR) return `${year}-${month}-${day} ${hour}:00`;
|
|
147
|
+
if (span >= 2 * MINUTE) return `${hour}:${minute}`;
|
|
148
|
+
return `${hour}:${minute}:${second}`;
|
|
149
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isPlainObject } from "../core/immutable.js";
|
|
2
|
+
import { validateBoxTransform } from "./boxPlot.js";
|
|
3
|
+
import { validateDensityTransform } from "./density.js";
|
|
4
|
+
import { validateFilterTransform } from "./filter.js";
|
|
5
|
+
import { validateIntervalTransform } from "./interval.js";
|
|
6
|
+
import { validateMarkFilterTransform } from "./markFilter.js";
|
|
7
|
+
import { validateRegressionTransform } from "./regression.js";
|
|
8
|
+
|
|
9
|
+
const TRANSFORM_VALIDATORS = Object.freeze({
|
|
10
|
+
boxOutlier: validateBoxTransform,
|
|
11
|
+
boxSummary: validateBoxTransform,
|
|
12
|
+
density: validateDensityTransform,
|
|
13
|
+
filter: validateFilterTransform,
|
|
14
|
+
interval: validateIntervalTransform,
|
|
15
|
+
markFilter: validateMarkFilterTransform,
|
|
16
|
+
regression: validateRegressionTransform
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function findTransformValidator(type) {
|
|
20
|
+
return TRANSFORM_VALIDATORS[type];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateDatasetTransforms(value) {
|
|
24
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(isPlainObject)) {
|
|
25
|
+
throw new TypeError(
|
|
26
|
+
"Dataset transform must be a non-empty array of plain objects."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
for (const transform of value) {
|
|
30
|
+
const validate = findTransformValidator(transform.type);
|
|
31
|
+
if (validate === undefined) {
|
|
32
|
+
throw new Error(`Unsupported dataset transform "${transform.type}".`);
|
|
33
|
+
}
|
|
34
|
+
validate(transform);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
|
|
2
|
+
import {
|
|
3
|
+
validateNonEmptyString,
|
|
4
|
+
validateNonNegativeFinite
|
|
5
|
+
} from "../core/validation.js";
|
|
6
|
+
|
|
7
|
+
const MARGIN_KEYS = Object.freeze(["top", "right", "bottom", "left"]);
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_MARGIN = cloneAndFreeze({
|
|
10
|
+
top: 30,
|
|
11
|
+
right: 30,
|
|
12
|
+
bottom: 60,
|
|
13
|
+
left: 70
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CANVAS = cloneAndFreeze({
|
|
17
|
+
width: 640,
|
|
18
|
+
height: 400,
|
|
19
|
+
background: "white",
|
|
20
|
+
margin: DEFAULT_MARGIN
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function validateMarginValue(value, key) {
|
|
24
|
+
validateNonNegativeFinite(value, `Canvas margin.${key}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normalizeMargin(margin, base = DEFAULT_MARGIN) {
|
|
28
|
+
if (Number.isFinite(margin)) {
|
|
29
|
+
if (margin < 0) {
|
|
30
|
+
throw new RangeError("Canvas margin must not be negative.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return cloneAndFreeze({
|
|
34
|
+
top: margin,
|
|
35
|
+
right: margin,
|
|
36
|
+
bottom: margin,
|
|
37
|
+
left: margin
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!isPlainObject(margin)) {
|
|
42
|
+
throw new TypeError("Canvas margin must be a number or a plain object.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const keys = Object.keys(margin);
|
|
46
|
+
|
|
47
|
+
if (keys.length === 0) {
|
|
48
|
+
throw new TypeError("Canvas margin object must contain at least one side.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
if (!MARGIN_KEYS.includes(key)) {
|
|
53
|
+
throw new Error(`Unknown canvas margin option "${key}".`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
validateMarginValue(margin[key], key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return cloneAndFreeze({
|
|
60
|
+
...base,
|
|
61
|
+
...margin
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateCanvasState({ width, height, background, margin }) {
|
|
66
|
+
if (!Number.isInteger(width) || width <= 0) {
|
|
67
|
+
throw new RangeError("Canvas width must be a positive integer.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!Number.isInteger(height) || height <= 0) {
|
|
71
|
+
throw new RangeError("Canvas height must be a positive integer.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
validateNonEmptyString(background, "Canvas background");
|
|
75
|
+
|
|
76
|
+
for (const key of MARGIN_KEYS) {
|
|
77
|
+
validateMarginValue(margin[key], key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (margin.left + margin.right >= width) {
|
|
81
|
+
throw new RangeError("Canvas horizontal margins must be smaller than width.");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (margin.top + margin.bottom >= height) {
|
|
85
|
+
throw new RangeError("Canvas vertical margins must be smaller than height.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createGraphicBounds({ width, height, margin }) {
|
|
90
|
+
return cloneAndFreeze({
|
|
91
|
+
x: margin.left,
|
|
92
|
+
y: margin.top,
|
|
93
|
+
width: width - margin.left - margin.right,
|
|
94
|
+
height: height - margin.top - margin.bottom
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveGraphicBounds(program) {
|
|
99
|
+
const canvas = program.graphicSpec.objects.canvas;
|
|
100
|
+
const margin = program.materializationConfigs.canvas?.margin;
|
|
101
|
+
if (
|
|
102
|
+
canvas?.type !== "canvas" ||
|
|
103
|
+
!Number.isFinite(canvas.properties.width) ||
|
|
104
|
+
!Number.isFinite(canvas.properties.height) ||
|
|
105
|
+
margin === undefined
|
|
106
|
+
) {
|
|
107
|
+
throw new Error("Graphical layout requires Canvas dimensions and margin.");
|
|
108
|
+
}
|
|
109
|
+
return createGraphicBounds({
|
|
110
|
+
width: canvas.properties.width,
|
|
111
|
+
height: canvas.properties.height,
|
|
112
|
+
margin
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function resolveGridLineGeometry({ direction, values, positions, bounds }) {
|
|
2
|
+
return direction === "horizontal"
|
|
3
|
+
? {
|
|
4
|
+
values,
|
|
5
|
+
x1: bounds.x,
|
|
6
|
+
y1: positions,
|
|
7
|
+
x2: bounds.x + bounds.width,
|
|
8
|
+
y2: positions
|
|
9
|
+
}
|
|
10
|
+
: {
|
|
11
|
+
values,
|
|
12
|
+
x1: positions,
|
|
13
|
+
y1: bounds.y,
|
|
14
|
+
x2: positions,
|
|
15
|
+
y2: bounds.y + bounds.height
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function measureLegendSymbolHeight(config) {
|
|
2
|
+
return Math.max(...config.symbol.layers.map(layer => {
|
|
3
|
+
if (layer.type === "swatch") return layer.height;
|
|
4
|
+
if (layer.type === "point") return layer.size * 2;
|
|
5
|
+
return layer.lineWidth;
|
|
6
|
+
}));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function measureLegendTextWidth(value) {
|
|
10
|
+
return String(value).length * 7;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveLegendGrid(config, width, count) {
|
|
14
|
+
const labels = config.domain.map(String);
|
|
15
|
+
const itemWidths = labels.map(
|
|
16
|
+
label => width + config.labels.offset + label.length * 7
|
|
17
|
+
);
|
|
18
|
+
const columnCount = Math.min(config.columns ?? count, count);
|
|
19
|
+
const rowCount = Math.ceil(count / columnCount);
|
|
20
|
+
const cells = Array.from({ length: count }, (_, index) => {
|
|
21
|
+
if (config.direction === "horizontal") {
|
|
22
|
+
return { column: index % columnCount, row: Math.floor(index / columnCount) };
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
column: Math.floor(index / rowCount),
|
|
26
|
+
row: index % rowCount
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
const actualColumns = Math.max(...cells.map(cell => cell.column)) + 1;
|
|
30
|
+
const actualRows = Math.max(...cells.map(cell => cell.row)) + 1;
|
|
31
|
+
const columnWidths = Array.from({ length: actualColumns }, (_, column) =>
|
|
32
|
+
Math.max(...cells.map((cell, index) =>
|
|
33
|
+
cell.column === column ? itemWidths[index] : 0
|
|
34
|
+
))
|
|
35
|
+
);
|
|
36
|
+
const gridWidth = columnWidths.reduce((sum, value) => sum + value, 0) +
|
|
37
|
+
config.itemGap * Math.max(0, actualColumns - 1);
|
|
38
|
+
const rowHeight = Math.max(
|
|
39
|
+
config.labels.fontSize,
|
|
40
|
+
measureLegendSymbolHeight(config)
|
|
41
|
+
);
|
|
42
|
+
const gridHeight = rowHeight * actualRows +
|
|
43
|
+
config.itemGap * Math.max(0, actualRows - 1);
|
|
44
|
+
return { cells, columnWidths, gridWidth, gridHeight, rowHeight };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function alignLegendStart(bounds, width, align) {
|
|
48
|
+
if (align === "left") return bounds.x;
|
|
49
|
+
if (align === "right") return bounds.x + bounds.width - width;
|
|
50
|
+
return bounds.x + (bounds.width - width) / 2;
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { measureTextWidth } from "../core/textMetrics.js";
|
|
2
|
+
|
|
3
|
+
export { measureTextWidth } from "../core/textMetrics.js";
|
|
4
|
+
|
|
5
|
+
function wrapCodePoints(text, maxWidth, style) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
let line = "";
|
|
8
|
+
for (const codePoint of [...text]) {
|
|
9
|
+
const candidate = line + codePoint;
|
|
10
|
+
if (line !== "" && measureTextWidth(candidate, style) > maxWidth) {
|
|
11
|
+
lines.push(line);
|
|
12
|
+
line = codePoint;
|
|
13
|
+
} else {
|
|
14
|
+
line = candidate;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (line !== "") lines.push(line);
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function wrapWords(text, maxWidth, style) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
let line = "";
|
|
24
|
+
for (const word of text.trim().split(/\s+/u)) {
|
|
25
|
+
if (measureTextWidth(word, style) > maxWidth) {
|
|
26
|
+
if (line !== "") {
|
|
27
|
+
lines.push(line);
|
|
28
|
+
line = "";
|
|
29
|
+
}
|
|
30
|
+
const fragments = wrapCodePoints(word, maxWidth, style);
|
|
31
|
+
lines.push(...fragments.slice(0, -1));
|
|
32
|
+
line = fragments.at(-1);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const candidate = line === "" ? word : `${line} ${word}`;
|
|
36
|
+
if (line !== "" && measureTextWidth(candidate, style) > maxWidth) {
|
|
37
|
+
lines.push(line);
|
|
38
|
+
line = word;
|
|
39
|
+
} else {
|
|
40
|
+
line = candidate;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (line !== "") lines.push(line);
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function wrapText(text, {
|
|
48
|
+
maxWidth,
|
|
49
|
+
mode = "word",
|
|
50
|
+
style
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (maxWidth === undefined) return [text];
|
|
53
|
+
if (!Number.isFinite(maxWidth) || maxWidth <= 0) {
|
|
54
|
+
throw new RangeError("Text maxWidth must be positive.");
|
|
55
|
+
}
|
|
56
|
+
if (!["word", "character"].includes(mode)) {
|
|
57
|
+
throw new Error(`Unsupported text wrap mode "${mode}".`);
|
|
58
|
+
}
|
|
59
|
+
return mode === "word"
|
|
60
|
+
? wrapWords(text, maxWidth, style)
|
|
61
|
+
: wrapCodePoints(text, maxWidth, style);
|
|
62
|
+
}
|