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.
Files changed (61) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/actions/categoryOrder/index.js +111 -0
  5. package/src/actions/data/index.js +6 -0
  6. package/src/actions/data/timeUnit.js +48 -0
  7. package/src/actions/encodings/angle.js +77 -0
  8. package/src/actions/encodings/color/index.js +17 -13
  9. package/src/actions/encodings/color/layout.js +2 -0
  10. package/src/actions/encodings/color/policy.js +3 -0
  11. package/src/actions/encodings/index.js +2 -0
  12. package/src/actions/encodings/position/policies/area.js +40 -2
  13. package/src/actions/encodings/position/policies/bar.js +6 -0
  14. package/src/actions/encodings/position/policies/index.js +2 -0
  15. package/src/actions/encodings/position/policies/tick.js +19 -0
  16. package/src/actions/encodings/remove.js +1 -1
  17. package/src/actions/guides/legends/categorical/actions.js +23 -6
  18. package/src/actions/guides/legends/categorical/index.js +8 -0
  19. package/src/actions/guides/legends/continuous/common.js +17 -5
  20. package/src/actions/guides/legends/continuous/gradient.js +12 -8
  21. package/src/actions/guides/legends/continuous/opacity.js +70 -17
  22. package/src/actions/guides/legends/edit.js +13 -8
  23. package/src/actions/guides/legends/lane.js +468 -0
  24. package/src/actions/guides/legends/remove.js +3 -7
  25. package/src/actions/index.js +2 -0
  26. package/src/actions/marks/area/actions.js +3 -1
  27. package/src/actions/marks/area/materialize.js +12 -3
  28. package/src/actions/marks/index.js +2 -0
  29. package/src/actions/marks/point/materialize.js +17 -5
  30. package/src/actions/marks/tick/actions.js +225 -0
  31. package/src/actions/marks/tick/index.js +1 -0
  32. package/src/actions/primitives/semanticValidation/layer.js +2 -0
  33. package/src/actions/scales/consumers/index.js +8 -0
  34. package/src/actions/scales/consumers/seriesLayout.js +22 -2
  35. package/src/actions/scales/materialize.js +2 -0
  36. package/src/actions/selection/actions.js +5 -2
  37. package/src/core/vocabulary.js +7 -3
  38. package/src/grammar/areaSeries.js +112 -2
  39. package/src/grammar/categoryOrder.js +138 -0
  40. package/src/grammar/direction.js +46 -0
  41. package/src/grammar/facets/index.js +1 -1
  42. package/src/grammar/pointShapes.js +34 -6
  43. package/src/grammar/positionCompatibility.js +4 -0
  44. package/src/grammar/schemas/semanticPath.js +2 -1
  45. package/src/grammar/seriesLayout.js +10 -2
  46. package/src/grammar/timeUnit.js +108 -0
  47. package/src/grammar/transforms.js +6 -0
  48. package/src/grammar/window.js +73 -7
  49. package/src/layout/legendLane.js +338 -0
  50. package/src/materialization/marks/capabilities.js +9 -0
  51. package/src/materialization/marks/index.js +2 -1
  52. package/src/materialization/marks/policies.js +13 -0
  53. package/src/materialization/scales/policies/series.js +9 -0
  54. package/src/materialization/scales/resolve.js +25 -3
  55. package/src/materialization/selection/items/index.js +1 -0
  56. package/src/materialization/selection/items/path.js +4 -1
  57. package/src/materialization/selection/items/tick.js +42 -0
  58. package/src/materialization/selection/policies/index.js +2 -0
  59. package/src/materialization/selection/policies/tick.js +10 -0
  60. package/types/index.d.ts +10 -0
  61. 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 { deriveDensityAreaSeries } from "../../../grammar/areaSeries.js";
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?.color?.layout;
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 (resolved.items[0]?.markType !== "rule" && resolved.items.length > 0) {
344
- throw new Error("applyRuleHighlight requires a rule selection.");
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);
@@ -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 ?? 0,
241
- upper: segment?.end ?? 0
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
+ }