ggaction 0.0.7 → 0.0.8
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 +20 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/actions/categoryOrder/index.js +111 -0
- package/src/actions/data/index.js +6 -0
- package/src/actions/data/timeUnit.js +48 -0
- package/src/actions/encodings/angle.js +77 -0
- package/src/actions/encodings/color/index.js +17 -13
- package/src/actions/encodings/color/layout.js +2 -0
- package/src/actions/encodings/color/policy.js +3 -0
- package/src/actions/encodings/index.js +2 -0
- package/src/actions/encodings/position/policies/area.js +40 -2
- package/src/actions/encodings/position/policies/bar.js +6 -0
- package/src/actions/encodings/position/policies/index.js +2 -0
- package/src/actions/encodings/position/policies/tick.js +19 -0
- package/src/actions/encodings/remove.js +1 -1
- package/src/actions/guides/legends/categorical/actions.js +23 -6
- package/src/actions/guides/legends/categorical/index.js +8 -0
- package/src/actions/guides/legends/continuous/common.js +17 -5
- package/src/actions/guides/legends/continuous/gradient.js +12 -8
- package/src/actions/guides/legends/continuous/opacity.js +70 -17
- package/src/actions/guides/legends/edit.js +13 -8
- package/src/actions/guides/legends/lane.js +468 -0
- package/src/actions/guides/legends/remove.js +3 -7
- package/src/actions/index.js +2 -0
- package/src/actions/marks/area/actions.js +3 -1
- package/src/actions/marks/area/materialize.js +12 -3
- package/src/actions/marks/index.js +2 -0
- package/src/actions/marks/point/materialize.js +17 -5
- package/src/actions/marks/tick/actions.js +225 -0
- package/src/actions/marks/tick/index.js +1 -0
- package/src/actions/primitives/semanticValidation/layer.js +2 -0
- package/src/actions/scales/consumers/index.js +8 -0
- package/src/actions/scales/consumers/seriesLayout.js +22 -2
- package/src/actions/scales/materialize.js +2 -0
- package/src/actions/selection/actions.js +5 -2
- package/src/core/vocabulary.js +7 -3
- package/src/grammar/areaSeries.js +112 -2
- package/src/grammar/categoryOrder.js +138 -0
- package/src/grammar/direction.js +46 -0
- package/src/grammar/facets/index.js +1 -1
- package/src/grammar/pointShapes.js +34 -6
- package/src/grammar/positionCompatibility.js +4 -0
- package/src/grammar/schemas/semanticPath.js +2 -1
- package/src/grammar/seriesLayout.js +10 -2
- package/src/grammar/timeUnit.js +108 -0
- package/src/grammar/transforms.js +6 -0
- package/src/grammar/window.js +73 -7
- package/src/layout/legendLane.js +338 -0
- package/src/materialization/marks/capabilities.js +9 -0
- package/src/materialization/marks/index.js +2 -1
- package/src/materialization/marks/policies.js +13 -0
- package/src/materialization/scales/policies/series.js +9 -0
- package/src/materialization/scales/resolve.js +25 -3
- package/src/materialization/selection/items/index.js +1 -0
- package/src/materialization/selection/items/path.js +4 -1
- package/src/materialization/selection/items/tick.js +42 -0
- package/src/materialization/selection/policies/index.js +2 -0
- package/src/materialization/selection/policies/tick.js +10 -0
- package/types/index.d.ts +10 -0
- package/types/program.d.ts +96 -3
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { action } from "../../../core/action.js";
|
|
2
|
+
import { validateUserId } from "../../../core/identifiers.js";
|
|
3
|
+
import {
|
|
4
|
+
validateNonEmptyString,
|
|
5
|
+
validateNonNegativeFinite,
|
|
6
|
+
validatePositiveFinite,
|
|
7
|
+
validateUnitInterval
|
|
8
|
+
} from "../../../core/validation.js";
|
|
9
|
+
import {
|
|
10
|
+
centeredDirectionalSegment,
|
|
11
|
+
resolveDirectionValues
|
|
12
|
+
} from "../../../grammar/direction.js";
|
|
13
|
+
import { resolveMarkGraphicPlacement } from
|
|
14
|
+
"../../../materialization/graphicHierarchy.js";
|
|
15
|
+
import { resolveRowEncodingValues } from
|
|
16
|
+
"../../../materialization/rowEncoding.js";
|
|
17
|
+
import { findDataset } from "../../../selectors/datasets.js";
|
|
18
|
+
import { findLayer } from "../../../selectors/layers.js";
|
|
19
|
+
import { DEFAULT_COLORS } from "../../../theme/defaults.js";
|
|
20
|
+
import { rematerializeHighlightBaseline } from "../lifecycle.js";
|
|
21
|
+
import {
|
|
22
|
+
applyLayeredMarkInheritance,
|
|
23
|
+
assertMarkAvailable,
|
|
24
|
+
materializeInheritedMark,
|
|
25
|
+
resolveLayeredMarkInheritance,
|
|
26
|
+
resolveMarkData,
|
|
27
|
+
resolveMarkId,
|
|
28
|
+
validateMarkOptions
|
|
29
|
+
} from "../shared.js";
|
|
30
|
+
|
|
31
|
+
const CREATE_OPTIONS = Object.freeze([
|
|
32
|
+
"id", "data", "length", "stroke", "strokeWidth", "opacity"
|
|
33
|
+
]);
|
|
34
|
+
const EDIT_OPTIONS = Object.freeze([
|
|
35
|
+
"target", "length", "stroke", "strokeWidth", "opacity"
|
|
36
|
+
]);
|
|
37
|
+
const REMATERIALIZE_OPTIONS = Object.freeze(["id"]);
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_TICK_CONFIG = Object.freeze({
|
|
40
|
+
length: 14,
|
|
41
|
+
stroke: DEFAULT_COLORS.mark,
|
|
42
|
+
strokeWidth: 2,
|
|
43
|
+
opacity: 1
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function resolveTick(program, requested, operation) {
|
|
47
|
+
const candidates = program.semanticSpec.layers.filter(
|
|
48
|
+
layer => layer.mark?.type === "tick"
|
|
49
|
+
);
|
|
50
|
+
if (requested !== undefined) {
|
|
51
|
+
const id = validateUserId(requested, "Tick mark id");
|
|
52
|
+
const layer = findLayer(program, id);
|
|
53
|
+
if (layer?.mark?.type !== "tick") {
|
|
54
|
+
throw new Error(`Unknown tick mark "${id}".`);
|
|
55
|
+
}
|
|
56
|
+
return layer;
|
|
57
|
+
}
|
|
58
|
+
const current = findLayer(program, program.context.currentMark);
|
|
59
|
+
if (current?.mark?.type === "tick") return current;
|
|
60
|
+
if (candidates.length === 1) return candidates[0];
|
|
61
|
+
if (candidates.length === 0) {
|
|
62
|
+
throw new Error(`${operation} requires an existing tick mark.`);
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`${operation} target is ambiguous; provide target.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateTickConfig(args, current = DEFAULT_TICK_CONFIG) {
|
|
68
|
+
return {
|
|
69
|
+
length: Object.hasOwn(args, "length")
|
|
70
|
+
? validatePositiveFinite(args.length, "Tick length")
|
|
71
|
+
: current.length,
|
|
72
|
+
stroke: Object.hasOwn(args, "stroke")
|
|
73
|
+
? validateNonEmptyString(args.stroke, "Tick stroke")
|
|
74
|
+
: current.stroke,
|
|
75
|
+
strokeWidth: Object.hasOwn(args, "strokeWidth")
|
|
76
|
+
? validateNonNegativeFinite(args.strokeWidth, "Tick strokeWidth")
|
|
77
|
+
: current.strokeWidth,
|
|
78
|
+
opacity: Object.hasOwn(args, "opacity")
|
|
79
|
+
? validateUnitInterval(args.opacity, "Tick opacity")
|
|
80
|
+
: current.opacity
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const createTickMark = action(
|
|
85
|
+
{
|
|
86
|
+
op: "createTickMark",
|
|
87
|
+
description: "Create a centered fixed-length Tick mark."
|
|
88
|
+
},
|
|
89
|
+
function (args = {}) {
|
|
90
|
+
validateMarkOptions(args, CREATE_OPTIONS, "createTickMark");
|
|
91
|
+
const id = resolveMarkId(this, args.id, {
|
|
92
|
+
defaultId: "tick",
|
|
93
|
+
label: "Tick mark id",
|
|
94
|
+
markType: "tick",
|
|
95
|
+
operation: "createTickMark"
|
|
96
|
+
});
|
|
97
|
+
const inherited = resolveLayeredMarkInheritance(this, args, "tick");
|
|
98
|
+
const { data } = resolveMarkData(this, {
|
|
99
|
+
...args,
|
|
100
|
+
...(args.data === undefined && this.context.currentData === undefined &&
|
|
101
|
+
inherited?.data !== undefined ? { data: inherited.data } : {})
|
|
102
|
+
});
|
|
103
|
+
const config = validateTickConfig(args);
|
|
104
|
+
assertMarkAvailable(this, id);
|
|
105
|
+
|
|
106
|
+
let created = this
|
|
107
|
+
.editSemantic({ property: `layer[${id}].mark.type`, value: "tick" })
|
|
108
|
+
.editSemantic({ property: `layer[${id}].data`, value: data });
|
|
109
|
+
created = applyLayeredMarkInheritance(created, id, inherited)
|
|
110
|
+
.createGraphics({
|
|
111
|
+
id,
|
|
112
|
+
type: "line",
|
|
113
|
+
length: 0,
|
|
114
|
+
...resolveMarkGraphicPlacement(created, { data, markType: "tick" })
|
|
115
|
+
})
|
|
116
|
+
._withMarkConfig(id, DEFAULT_TICK_CONFIG);
|
|
117
|
+
const materialized = materializeInheritedMark(created, id);
|
|
118
|
+
const appearance = Object.fromEntries(
|
|
119
|
+
["length", "stroke", "strokeWidth", "opacity"]
|
|
120
|
+
.filter(property => Object.hasOwn(args, property))
|
|
121
|
+
.map(property => [property, config[property]])
|
|
122
|
+
);
|
|
123
|
+
return Object.keys(appearance).length === 0
|
|
124
|
+
? materialized
|
|
125
|
+
: materialized.editTickMark({ target: id, ...appearance });
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
export const editTickMark = action(
|
|
130
|
+
{
|
|
131
|
+
op: "editTickMark",
|
|
132
|
+
description: "Edit Tick length and constant line appearance."
|
|
133
|
+
},
|
|
134
|
+
function (args = {}) {
|
|
135
|
+
validateMarkOptions(args, EDIT_OPTIONS, "editTickMark");
|
|
136
|
+
const editable = ["length", "stroke", "strokeWidth", "opacity"];
|
|
137
|
+
if (!editable.some(property => Object.hasOwn(args, property))) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"editTickMark requires length, stroke, strokeWidth, or opacity."
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const layer = resolveTick(this, args.target, "editTickMark");
|
|
143
|
+
const config = validateTickConfig(args, {
|
|
144
|
+
...DEFAULT_TICK_CONFIG,
|
|
145
|
+
...this.markConfigs[layer.id]
|
|
146
|
+
});
|
|
147
|
+
return this
|
|
148
|
+
._withMarkConfig(layer.id, config)
|
|
149
|
+
.rematerializeTickMark({ id: layer.id });
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
export const rematerializeTickMark = action(
|
|
154
|
+
{
|
|
155
|
+
op: "rematerializeTickMark",
|
|
156
|
+
description: "Recompute concrete centered Tick endpoints and appearance."
|
|
157
|
+
},
|
|
158
|
+
function (args = {}) {
|
|
159
|
+
validateMarkOptions(
|
|
160
|
+
args,
|
|
161
|
+
REMATERIALIZE_OPTIONS,
|
|
162
|
+
"rematerializeTickMark"
|
|
163
|
+
);
|
|
164
|
+
const id = validateUserId(args.id, "Tick mark id");
|
|
165
|
+
const highlighted = rematerializeHighlightBaseline(this, {
|
|
166
|
+
target: id,
|
|
167
|
+
operation: "rematerializeTickMark",
|
|
168
|
+
resetProperty: "length",
|
|
169
|
+
resetValue: 0
|
|
170
|
+
});
|
|
171
|
+
if (highlighted !== undefined) return highlighted;
|
|
172
|
+
const layer = findLayer(this, id);
|
|
173
|
+
if (layer?.mark?.type !== "tick") {
|
|
174
|
+
throw new Error(`Unknown tick mark "${id}".`);
|
|
175
|
+
}
|
|
176
|
+
const graphic = this.graphicSpec.objects[id];
|
|
177
|
+
if (graphic?.type !== "line" || !Array.isArray(graphic.items)) {
|
|
178
|
+
throw new Error(`Tick mark "${id}" requires line collection graphics.`);
|
|
179
|
+
}
|
|
180
|
+
const dataset = findDataset(this, layer.data);
|
|
181
|
+
if (dataset === undefined) {
|
|
182
|
+
throw new Error(`Tick mark "${id}" requires an existing dataset.`);
|
|
183
|
+
}
|
|
184
|
+
if (
|
|
185
|
+
layer.encoding?.x?.scale === undefined ||
|
|
186
|
+
layer.encoding?.y?.scale === undefined
|
|
187
|
+
) {
|
|
188
|
+
return graphic.items.length === 0
|
|
189
|
+
? this
|
|
190
|
+
: this.editGraphics({ target: id, property: "length", value: 0 });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const x = resolveRowEncodingValues(this, layer, dataset, "x");
|
|
194
|
+
const y = resolveRowEncodingValues(this, layer, dataset, "y");
|
|
195
|
+
const angles = resolveDirectionValues(dataset.values, layer.encoding?.angle);
|
|
196
|
+
const config = validateTickConfig({}, {
|
|
197
|
+
...DEFAULT_TICK_CONFIG,
|
|
198
|
+
...this.markConfigs[id]
|
|
199
|
+
});
|
|
200
|
+
const segments = dataset.values.map((_, index) =>
|
|
201
|
+
centeredDirectionalSegment({
|
|
202
|
+
x: x[index],
|
|
203
|
+
y: y[index],
|
|
204
|
+
degrees: angles?.[index] ?? 0,
|
|
205
|
+
length: config.length
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
return this
|
|
210
|
+
.editGraphics({ target: id, property: "length", value: segments.length })
|
|
211
|
+
.editGraphics({ target: id, property: "x1", value: segments.map(item => item.x1) })
|
|
212
|
+
.editGraphics({ target: id, property: "y1", value: segments.map(item => item.y1) })
|
|
213
|
+
.editGraphics({ target: id, property: "x2", value: segments.map(item => item.x2) })
|
|
214
|
+
.editGraphics({ target: id, property: "y2", value: segments.map(item => item.y2) })
|
|
215
|
+
.editGraphics({ target: id, property: "stroke", value: config.stroke })
|
|
216
|
+
.editGraphics({ target: id, property: "strokeWidth", value: config.strokeWidth })
|
|
217
|
+
.editGraphics({ target: id, property: "opacity", value: config.opacity });
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
export function registerTickMarkActions(ProgramClass) {
|
|
222
|
+
ProgramClass.prototype.createTickMark = createTickMark;
|
|
223
|
+
ProgramClass.prototype.editTickMark = editTickMark;
|
|
224
|
+
ProgramClass.prototype.rematerializeTickMark = rematerializeTickMark;
|
|
225
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerTickMarkActions } from "./actions.js";
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
validateParallelMissingPolicy
|
|
17
17
|
} from "../../../grammar/parallelCoordinates.js";
|
|
18
18
|
import { validatePathOrderDirection } from "../../../grammar/pathOrder.js";
|
|
19
|
+
import { normalizeCategoryOrder } from "../../../grammar/categoryOrder.js";
|
|
19
20
|
import { validateSemanticFieldType } from "../../../grammar/scales/index.js";
|
|
20
21
|
import { findLayer } from "../../../selectors/layers.js";
|
|
21
22
|
import { validateNonEmptySemanticString } from "./shared.js";
|
|
@@ -50,6 +51,7 @@ export function validateLayerSemanticValue(program, parsed, value) {
|
|
|
50
51
|
}
|
|
51
52
|
if (property.endsWith(".fieldType")) validateSemanticFieldType(value);
|
|
52
53
|
if (property === "encoding.pathOrder.order") validatePathOrderDirection(value);
|
|
54
|
+
if (property.endsWith(".categoryOrder")) normalizeCategoryOrder(value);
|
|
53
55
|
if (property === "encoding.parallel.dimensions") {
|
|
54
56
|
validateParallelDimensions(value, { normalized: true });
|
|
55
57
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
requireConsumerDataset
|
|
7
7
|
} from "./common.js";
|
|
8
8
|
import { resolveMarkFamilyConsumerValues } from "./families.js";
|
|
9
|
+
import { resolveCategoryOrder } from "../../../grammar/categoryOrder.js";
|
|
9
10
|
|
|
10
11
|
export { findScale, findScaleConsumers } from "./common.js";
|
|
11
12
|
export {
|
|
@@ -39,3 +40,10 @@ export function resolveConsumerValues(program, consumer) {
|
|
|
39
40
|
? family.values
|
|
40
41
|
: readConsumerFieldValues(program, consumer, dataset, scale);
|
|
41
42
|
}
|
|
43
|
+
|
|
44
|
+
export function resolveConsumerCategoryOrder(program, consumer) {
|
|
45
|
+
const order = consumer.encoding.categoryOrder;
|
|
46
|
+
if (order === undefined) return undefined;
|
|
47
|
+
const dataset = requireConsumerDataset(program, consumer);
|
|
48
|
+
return resolveCategoryOrder(dataset.values, consumer.encoding.field, order);
|
|
49
|
+
}
|
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
findHistogramBinIndex,
|
|
11
11
|
resolveHistogramBins
|
|
12
12
|
} from "../../../grammar/histogram.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
deriveCenteredAreaSeries,
|
|
15
|
+
deriveDensityAreaSeries
|
|
16
|
+
} from "../../../grammar/areaSeries.js";
|
|
14
17
|
import {
|
|
15
18
|
resolveSeriesLayoutDomainValues
|
|
16
19
|
} from "../../../grammar/seriesLayout.js";
|
|
@@ -122,6 +125,16 @@ function resolveAreaPartitions(program, consumer) {
|
|
|
122
125
|
dataset.transform[0].type === "density"
|
|
123
126
|
? dataset.transform[0]
|
|
124
127
|
: undefined;
|
|
128
|
+
if (
|
|
129
|
+
transform === undefined &&
|
|
130
|
+
layer.encoding?.y?.stack === "center" &&
|
|
131
|
+
layer.encoding?.x !== undefined
|
|
132
|
+
) {
|
|
133
|
+
const derived = deriveCenteredAreaSeries(dataset.values, layer);
|
|
134
|
+
return Array.from({ length: derived.series[0].values.length }, (_, index) =>
|
|
135
|
+
derived.series.map(series => series.values[index].y)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
125
138
|
if (transform === undefined) return undefined;
|
|
126
139
|
const derived = deriveDensityAreaSeries(dataset.values, layer, transform);
|
|
127
140
|
if (derived.mode !== "y-density") return undefined;
|
|
@@ -153,10 +166,17 @@ export function resolveSeriesLayoutScaleValues(program, consumer) {
|
|
|
153
166
|
}
|
|
154
167
|
if (consumer.channel !== "y") return undefined;
|
|
155
168
|
if (consumer.layer.mark?.type === "area") {
|
|
156
|
-
const layout = consumer.layer.encoding?.
|
|
169
|
+
const layout = consumer.layer.encoding?.y?.stack === "center"
|
|
170
|
+
? "center"
|
|
171
|
+
: consumer.layer.encoding?.color?.layout;
|
|
157
172
|
if (layout === undefined || layout === "overlay") return undefined;
|
|
158
173
|
const partitions = resolveAreaPartitions(program, consumer);
|
|
159
174
|
if (partitions === undefined) {
|
|
175
|
+
if (
|
|
176
|
+
layout === "center" &&
|
|
177
|
+
consumer.layer.encoding?.y?.stack === "center" &&
|
|
178
|
+
consumer.layer.encoding?.x === undefined
|
|
179
|
+
) return undefined;
|
|
160
180
|
throw new Error(
|
|
161
181
|
`Area layout "${layout}" currently requires vertical density series.`
|
|
162
182
|
);
|
|
@@ -19,6 +19,7 @@ import { resolveScaleMaterialization } from
|
|
|
19
19
|
import {
|
|
20
20
|
findScale,
|
|
21
21
|
findScaleConsumers,
|
|
22
|
+
resolveConsumerCategoryOrder,
|
|
22
23
|
resolveConsumerValues,
|
|
23
24
|
resolveSeriesLayoutScaleValues
|
|
24
25
|
} from "./consumers/index.js";
|
|
@@ -62,6 +63,7 @@ export const rematerializeScale = action(
|
|
|
62
63
|
const valuesByConsumer = consumers.map(consumer => ({
|
|
63
64
|
consumer,
|
|
64
65
|
values: resolveConsumerValues(this, consumer),
|
|
66
|
+
categoryOrder: resolveConsumerCategoryOrder(this, consumer),
|
|
65
67
|
seriesLayout: resolveSeriesLayoutScaleValues(this, consumer)
|
|
66
68
|
}));
|
|
67
69
|
const resolvedScale = resolveScaleMaterialization({
|
|
@@ -340,8 +340,11 @@ export const applyRuleHighlight = action(
|
|
|
340
340
|
validateKeys(args, INTERNAL_SELECTION_OPTIONS, "applyRuleHighlight");
|
|
341
341
|
const resolved = resolveStoredSelection(this, args.selection);
|
|
342
342
|
const keys = selectedKeys(args, resolved);
|
|
343
|
-
if (
|
|
344
|
-
|
|
343
|
+
if (
|
|
344
|
+
resolved.items.length > 0 &&
|
|
345
|
+
!["rule", "tick"].includes(resolved.items[0]?.markType)
|
|
346
|
+
) {
|
|
347
|
+
throw new Error("applyRuleHighlight requires a rule or Tick selection.");
|
|
345
348
|
}
|
|
346
349
|
if (keys.length === 0) return this;
|
|
347
350
|
const selected = new Set(keys);
|
package/src/core/vocabulary.js
CHANGED
|
@@ -5,6 +5,7 @@ export const MARK_TYPES = Object.freeze([
|
|
|
5
5
|
"area",
|
|
6
6
|
"arc",
|
|
7
7
|
"rule",
|
|
8
|
+
"tick",
|
|
8
9
|
"text",
|
|
9
10
|
"rect"
|
|
10
11
|
]);
|
|
@@ -16,6 +17,7 @@ export const MARK_GRAPHIC_TYPES = Object.freeze({
|
|
|
16
17
|
area: Object.freeze(["path"]),
|
|
17
18
|
arc: Object.freeze(["path"]),
|
|
18
19
|
rule: Object.freeze(["line"]),
|
|
20
|
+
tick: Object.freeze(["line"]),
|
|
19
21
|
text: Object.freeze(["text"]),
|
|
20
22
|
rect: Object.freeze(["rect"])
|
|
21
23
|
});
|
|
@@ -34,6 +36,7 @@ export const ENCODING_CHANNELS = Object.freeze([
|
|
|
34
36
|
"strokeWidth",
|
|
35
37
|
"size",
|
|
36
38
|
"shape",
|
|
39
|
+
"angle",
|
|
37
40
|
"group",
|
|
38
41
|
"pathOrder",
|
|
39
42
|
"opacity",
|
|
@@ -41,7 +44,7 @@ export const ENCODING_CHANNELS = Object.freeze([
|
|
|
41
44
|
]);
|
|
42
45
|
|
|
43
46
|
const CARTESIAN_MARK_TYPES = Object.freeze([
|
|
44
|
-
"point", "line", "bar", "area", "rule", "text", "rect"
|
|
47
|
+
"point", "line", "bar", "area", "rule", "tick", "text", "rect"
|
|
45
48
|
]);
|
|
46
49
|
const POLAR_MARK_TYPES = Object.freeze(["point", "line", "arc"]);
|
|
47
50
|
|
|
@@ -114,7 +117,7 @@ export const POSITION_CHANNEL_DEFINITIONS = Object.freeze({
|
|
|
114
117
|
|
|
115
118
|
export const SCALED_ENCODING_CHANNELS = Object.freeze(
|
|
116
119
|
ENCODING_CHANNELS.filter(channel =>
|
|
117
|
-
!["group", "pathOrder", "text"].includes(channel)
|
|
120
|
+
!["angle", "group", "pathOrder", "text"].includes(channel)
|
|
118
121
|
)
|
|
119
122
|
);
|
|
120
123
|
|
|
@@ -185,12 +188,13 @@ export function getMarkGraphicTypes(markType) {
|
|
|
185
188
|
export const COLOR_LAYOUTS = Object.freeze([
|
|
186
189
|
"stack",
|
|
187
190
|
"fill",
|
|
191
|
+
"center",
|
|
188
192
|
"group",
|
|
189
193
|
"overlay",
|
|
190
194
|
"diverging"
|
|
191
195
|
]);
|
|
192
196
|
|
|
193
|
-
export const STACK_MODES = Object.freeze(["zero", "normalize"]);
|
|
197
|
+
export const STACK_MODES = Object.freeze(["zero", "normalize", "center"]);
|
|
194
198
|
|
|
195
199
|
export const CATEGORICAL_LEGEND_CHANNELS = Object.freeze([
|
|
196
200
|
"color",
|
|
@@ -120,6 +120,110 @@ export function deriveAreaSeries(rows, layer) {
|
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
export function deriveCenteredAreaSeries(rows, layer) {
|
|
124
|
+
if (layer?.mark?.type !== "area") {
|
|
125
|
+
throw new Error("Centered area series derivation requires an area mark.");
|
|
126
|
+
}
|
|
127
|
+
const { x, y, x2, y2, group, color } = layer.encoding ?? {};
|
|
128
|
+
if (
|
|
129
|
+
!["quantitative", "temporal"].includes(x?.fieldType) ||
|
|
130
|
+
y?.fieldType !== "quantitative" ||
|
|
131
|
+
y?.stack !== "center" ||
|
|
132
|
+
x2 !== undefined ||
|
|
133
|
+
y2 !== undefined
|
|
134
|
+
) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Centered area mark "${layer.id}" requires one x field and one quantitative y field without ranged endpoints.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (group?.fieldType !== "nominal") {
|
|
140
|
+
throw new Error(`Centered area mark "${layer.id}" requires a nominal group encoding.`);
|
|
141
|
+
}
|
|
142
|
+
if (color !== undefined && color.field !== group.field) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Centered area color on mark "${layer.id}" must match its group field.`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const xValues = x.fieldType === "temporal"
|
|
148
|
+
? readTemporalField(rows, x.field)
|
|
149
|
+
: readQuantitativeField(rows, x.field);
|
|
150
|
+
const yValues = readQuantitativeField(rows, y.field);
|
|
151
|
+
const groups = readNominalField(rows, group.field);
|
|
152
|
+
const groupOrder = [];
|
|
153
|
+
const positions = new Set();
|
|
154
|
+
const byGroup = new Map();
|
|
155
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
156
|
+
const key = groups[index];
|
|
157
|
+
if (!byGroup.has(key)) {
|
|
158
|
+
groupOrder.push(key);
|
|
159
|
+
byGroup.set(key, new Map());
|
|
160
|
+
}
|
|
161
|
+
const values = byGroup.get(key);
|
|
162
|
+
if (values.has(xValues[index])) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Centered area mark "${layer.id}" has duplicate ${group.field}/${x.field} rows.`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
values.set(xValues[index], yValues[index]);
|
|
168
|
+
positions.add(xValues[index]);
|
|
169
|
+
}
|
|
170
|
+
const orderedPositions = [...positions].sort((left, right) => left - right);
|
|
171
|
+
if (groupOrder.length === 0 || orderedPositions.length < 2) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Centered area mark "${layer.id}" requires at least two aligned positions.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
for (const [key, values] of byGroup) {
|
|
177
|
+
if (
|
|
178
|
+
values.size !== orderedPositions.length ||
|
|
179
|
+
orderedPositions.some(position => !values.has(position))
|
|
180
|
+
) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Centered area series "${String(key)}" requires one aligned value at every x position.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const valuesBySeries = groupOrder.map(() => []);
|
|
188
|
+
for (const position of orderedPositions) {
|
|
189
|
+
const partition = groupOrder.map(key => byGroup.get(key).get(position));
|
|
190
|
+
const segments = new Map(
|
|
191
|
+
layoutSeriesPartition(partition, "center").map(segment => [
|
|
192
|
+
segment.index,
|
|
193
|
+
segment
|
|
194
|
+
])
|
|
195
|
+
);
|
|
196
|
+
let endpoint = -partition.reduce((sum, value) => sum + value, 0) / 2;
|
|
197
|
+
for (let index = 0; index < groupOrder.length; index += 1) {
|
|
198
|
+
const segment = segments.get(index);
|
|
199
|
+
valuesBySeries[index].push({
|
|
200
|
+
x: position,
|
|
201
|
+
y: partition[index],
|
|
202
|
+
lower: segment?.start ?? endpoint,
|
|
203
|
+
upper: segment?.end ?? endpoint
|
|
204
|
+
});
|
|
205
|
+
if (segment !== undefined) endpoint = segment.end;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const series = groupOrder.map((key, index) => ({
|
|
209
|
+
key: {
|
|
210
|
+
[group.field]: key,
|
|
211
|
+
...(color === undefined ? {} : { [color.field]: key })
|
|
212
|
+
},
|
|
213
|
+
values: valuesBySeries[index]
|
|
214
|
+
}));
|
|
215
|
+
return cloneAndFreeze({
|
|
216
|
+
mode: "y-center",
|
|
217
|
+
orientation: "vertical",
|
|
218
|
+
xValues: orderedPositions,
|
|
219
|
+
yValues: series.flatMap(item => item.values.flatMap(value => [
|
|
220
|
+
value.lower,
|
|
221
|
+
value.upper
|
|
222
|
+
])),
|
|
223
|
+
series
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
123
227
|
export function deriveDensityAreaSeries(rows, layer, transform) {
|
|
124
228
|
if (layer?.mark?.type !== "area") {
|
|
125
229
|
throw new Error("Density area derivation requires a semantic area mark.");
|
|
@@ -233,13 +337,19 @@ export function layoutDensityAreaSeries(derived, layout = "overlay") {
|
|
|
233
337
|
segment
|
|
234
338
|
])
|
|
235
339
|
);
|
|
340
|
+
let zeroThicknessEndpoint = layout === "center"
|
|
341
|
+
? -densities.reduce((sum, value) => sum + value, 0) / 2
|
|
342
|
+
: 0;
|
|
236
343
|
for (let index = 0; index < derived.series.length; index += 1) {
|
|
237
344
|
const segment = segments.get(index);
|
|
238
345
|
valuesBySeries[index].push({
|
|
239
346
|
x,
|
|
240
|
-
lower: segment?.start ??
|
|
241
|
-
upper: segment?.end ??
|
|
347
|
+
lower: segment?.start ?? zeroThicknessEndpoint,
|
|
348
|
+
upper: segment?.end ?? zeroThicknessEndpoint
|
|
242
349
|
});
|
|
350
|
+
if (segment !== undefined && ["stack", "fill", "center"].includes(layout)) {
|
|
351
|
+
zeroThicknessEndpoint = segment.end;
|
|
352
|
+
}
|
|
243
353
|
}
|
|
244
354
|
}
|
|
245
355
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
|
|
2
|
+
import { isNominalValue, readNominalField } from "./scales/fields.js";
|
|
3
|
+
|
|
4
|
+
const DIRECTIONS = Object.freeze(["ascending", "descending"]);
|
|
5
|
+
const AGGREGATES = Object.freeze(["sum", "mean", "min", "max"]);
|
|
6
|
+
|
|
7
|
+
function nonEmptyField(value, label) {
|
|
8
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
9
|
+
throw new TypeError(`${label} must be a non-empty string.`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sameValue(left, right) {
|
|
15
|
+
return Object.is(left, right);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeCategoryOrder({ values, by, direction } = {}) {
|
|
19
|
+
const hasValues = values !== undefined;
|
|
20
|
+
const hasBy = by !== undefined;
|
|
21
|
+
if (hasValues === hasBy) {
|
|
22
|
+
throw new Error("Category order requires exactly one of values or by.");
|
|
23
|
+
}
|
|
24
|
+
if (hasValues) {
|
|
25
|
+
if (direction !== undefined) {
|
|
26
|
+
throw new Error("Explicit category order does not support direction.");
|
|
27
|
+
}
|
|
28
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
29
|
+
throw new TypeError("Category order values must be a non-empty array.");
|
|
30
|
+
}
|
|
31
|
+
if (!values.every(isNominalValue)) {
|
|
32
|
+
throw new TypeError("Category order values must contain nominal values.");
|
|
33
|
+
}
|
|
34
|
+
if (values.some((value, index) =>
|
|
35
|
+
values.slice(0, index).some(previous => sameValue(previous, value)))) {
|
|
36
|
+
throw new Error("Category order values must be unique.");
|
|
37
|
+
}
|
|
38
|
+
return cloneAndFreeze({ values });
|
|
39
|
+
}
|
|
40
|
+
if (!DIRECTIONS.includes(direction ?? "ascending")) {
|
|
41
|
+
throw new Error(`Unsupported category order direction "${direction}".`);
|
|
42
|
+
}
|
|
43
|
+
let normalizedBy;
|
|
44
|
+
if (["category", "count"].includes(by)) {
|
|
45
|
+
normalizedBy = by;
|
|
46
|
+
} else {
|
|
47
|
+
if (!isPlainObject(by)) {
|
|
48
|
+
throw new TypeError("Category order by must be category, count, or a summary object.");
|
|
49
|
+
}
|
|
50
|
+
const unknown = Object.keys(by).find(key => !["field", "aggregate"].includes(key));
|
|
51
|
+
if (unknown !== undefined) {
|
|
52
|
+
throw new Error(`Unknown category summary property "${unknown}".`);
|
|
53
|
+
}
|
|
54
|
+
const field = nonEmptyField(by.field, "Category summary field");
|
|
55
|
+
if (!AGGREGATES.includes(by.aggregate)) {
|
|
56
|
+
throw new Error(`Unsupported category summary aggregate "${by.aggregate}".`);
|
|
57
|
+
}
|
|
58
|
+
normalizedBy = { field, aggregate: by.aggregate };
|
|
59
|
+
}
|
|
60
|
+
return cloneAndFreeze({ by: normalizedBy, direction: direction ?? "ascending" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function observedCategories(rows, field) {
|
|
64
|
+
return [...new Set(readNominalField(rows, field))];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function categoryComparison(left, right) {
|
|
68
|
+
if (typeof left !== typeof right) {
|
|
69
|
+
throw new TypeError("Category value ordering requires one uniform primitive type.");
|
|
70
|
+
}
|
|
71
|
+
if (typeof left === "number") return left - right;
|
|
72
|
+
if (typeof left === "boolean") return Number(left) - Number(right);
|
|
73
|
+
const leftPoints = Array.from(left, character => character.codePointAt(0));
|
|
74
|
+
const rightPoints = Array.from(right, character => character.codePointAt(0));
|
|
75
|
+
for (let index = 0; index < Math.min(leftPoints.length, rightPoints.length); index += 1) {
|
|
76
|
+
if (leftPoints[index] !== rightPoints[index]) {
|
|
77
|
+
return leftPoints[index] - rightPoints[index];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return leftPoints.length - rightPoints.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function summaryValues(rows, categoryField, summary) {
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
for (const [index, row] of rows.entries()) {
|
|
86
|
+
const category = row[categoryField];
|
|
87
|
+
const value = row[summary.field];
|
|
88
|
+
if (!Number.isFinite(value)) {
|
|
89
|
+
throw new TypeError(
|
|
90
|
+
`Category summary field "${summary.field}" must contain a finite number at row ${index}.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const values = groups.get(category) ?? [];
|
|
94
|
+
values.push(value);
|
|
95
|
+
groups.set(category, values);
|
|
96
|
+
}
|
|
97
|
+
return new Map([...groups].map(([category, values]) => {
|
|
98
|
+
let value;
|
|
99
|
+
if (summary.aggregate === "sum") value = values.reduce((sum, item) => sum + item, 0);
|
|
100
|
+
else if (summary.aggregate === "mean") {
|
|
101
|
+
value = values.reduce((sum, item) => sum + item, 0) / values.length;
|
|
102
|
+
} else if (summary.aggregate === "min") value = Math.min(...values);
|
|
103
|
+
else value = Math.max(...values);
|
|
104
|
+
return [category, value];
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveCategoryOrder(rows, categoryField, order) {
|
|
109
|
+
const categories = observedCategories(rows, categoryField);
|
|
110
|
+
if (Object.hasOwn(order, "values")) {
|
|
111
|
+
for (const value of order.values) {
|
|
112
|
+
if (!categories.some(category => sameValue(category, value))) {
|
|
113
|
+
throw new Error(`Unknown category order value "${value}".`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return cloneAndFreeze([
|
|
117
|
+
...order.values,
|
|
118
|
+
...categories.filter(category =>
|
|
119
|
+
!order.values.some(value => sameValue(value, category)))
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
const first = new Map(categories.map((value, index) => [value, index]));
|
|
123
|
+
const metrics = order.by === "count"
|
|
124
|
+
? new Map(categories.map(category => [
|
|
125
|
+
category,
|
|
126
|
+
rows.filter(row => sameValue(row[categoryField], category)).length
|
|
127
|
+
]))
|
|
128
|
+
: isPlainObject(order.by)
|
|
129
|
+
? summaryValues(rows, categoryField, order.by)
|
|
130
|
+
: undefined;
|
|
131
|
+
const direction = order.direction === "descending" ? -1 : 1;
|
|
132
|
+
return cloneAndFreeze([...categories].sort((left, right) => {
|
|
133
|
+
const comparison = order.by === "category"
|
|
134
|
+
? categoryComparison(left, right)
|
|
135
|
+
: metrics.get(left) - metrics.get(right);
|
|
136
|
+
return direction * comparison || first.get(left) - first.get(right);
|
|
137
|
+
}));
|
|
138
|
+
}
|