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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable changes to `ggaction` are recorded in this file.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.0.8] - 2026-08-04
8
+
9
+ ### Added
10
+
11
+ - Added immutable UTC time-unit derivation, semantic category ordering and reset, and partitioned moving mean/sum
12
+ windows with matching public actions, strict declarations, examples, and Current contracts.
13
+ - Added the Tick mark lifecycle, point/Tick angle encoding, and non-negative centered area stacking with stable
14
+ Canvas, SVG, PNG, and PDF evidence.
15
+
16
+ ### Changed
17
+
18
+ - Expanded the public example and documentation corpus with actual-data temporal, ordering, directional Tick,
19
+ centered-area, and multi-legend charts, plus installed-package coverage for their supported workflows.
20
+
21
+ ### Fixed
22
+
23
+ - Replaced independent same-edge legend placement with deterministic shared right/left lanes and left-packed
24
+ top/bottom rows, aligning titles, symbols, and labels while preserving lifecycle convergence and compact margins.
25
+
7
26
  ## [0.0.7] - 2026-07-23
8
27
 
9
28
  ### Added
@@ -157,6 +176,7 @@ All notable changes to `ggaction` are recorded in this file.
157
176
  - Cartesian charts are the complete current path. Polar semantic tokens exist only where explicitly documented and do
158
177
  not imply complete polar rendering.
159
178
 
179
+ [0.0.8]: https://github.com/ggaction/ggaction/releases/tag/v0.0.8
160
180
  [0.0.7]: https://github.com/ggaction/ggaction/releases/tag/v0.0.7
161
181
  [0.0.6]: https://github.com/ggaction/ggaction/releases/tag/v0.0.6
162
182
  [0.0.5]: https://github.com/ggaction/ggaction/releases/tag/v0.0.5
package/README.md CHANGED
@@ -201,7 +201,7 @@ the extra discussion required before public API or architecture changes.
201
201
 
202
202
  ## Status and development
203
203
 
204
- > **Status:** `0.0.7` is the current experimental public release. APIs may change before `1.0.0`; changes are recorded in the [changelog](./CHANGELOG.md).
204
+ > **Status:** `0.0.8` is the current experimental public release. APIs may change before `1.0.0`; changes are recorded in the [changelog](./CHANGELOG.md).
205
205
 
206
206
  ```bash
207
207
  npm install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ggaction",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "Build charts through immutable, traceable graphical actions.",
5
5
  "license": "MIT",
6
6
  "author": "Hyeon Jeon",
@@ -0,0 +1,111 @@
1
+ import { action } from "../../core/action.js";
2
+ import { validateUserId } from "../../core/identifiers.js";
3
+ import { validateKeys } from "../../core/validation.js";
4
+ import { findDataset } from "../../selectors/datasets.js";
5
+ import { resolveEligibleLayer } from "../../selectors/layers.js";
6
+ import { findScaleConsumers } from "../scales/consumers/index.js";
7
+ import {
8
+ applyMaterializationPlan,
9
+ planScaleGuideRematerialization
10
+ } from "../../materialization/dependencies.js";
11
+ import { getMarkMaterializationStep } from "../../materialization/marks/index.js";
12
+ import { buildMaterializationPlan } from "../../materialization/planner.js";
13
+ import {
14
+ normalizeCategoryOrder,
15
+ resolveCategoryOrder
16
+ } from "../../grammar/categoryOrder.js";
17
+
18
+ const ORDER_OPTIONS = Object.freeze(["target", "channel", "values", "by", "direction"]);
19
+ const REMOVE_OPTIONS = Object.freeze(["target", "channel"]);
20
+
21
+ function resolveAssignment(program, args, operation, { activeOnly = false } = {}) {
22
+ if (!["x", "y"].includes(args.channel)) {
23
+ throw new Error(`${operation} channel must be x or y.`);
24
+ }
25
+ const requested = args.target === undefined
26
+ ? undefined
27
+ : validateUserId(args.target, "Mark id");
28
+ const layer = resolveEligibleLayer(program, {
29
+ target: requested,
30
+ label: "categorical position mark",
31
+ predicate: candidate => {
32
+ const encoding = candidate.encoding?.[args.channel];
33
+ return ["nominal", "ordinal"].includes(encoding?.fieldType) &&
34
+ typeof encoding.field === "string" && encoding.scale !== undefined &&
35
+ (!activeOnly || encoding.categoryOrder !== undefined);
36
+ }
37
+ });
38
+ const encoding = layer.encoding[args.channel];
39
+ const dataset = findDataset(program, layer.data);
40
+ if (dataset === undefined) {
41
+ throw new Error(`Mark "${layer.id}" requires an existing dataset.`);
42
+ }
43
+ const consumers = findScaleConsumers(program, encoding.scale);
44
+ const incompatible = consumers.find(consumer =>
45
+ consumer.layer.id !== layer.id &&
46
+ (consumer.layer.data !== layer.data || consumer.encoding.field !== encoding.field)
47
+ );
48
+ if (incompatible !== undefined) {
49
+ throw new Error(
50
+ `Category order scale "${encoding.scale}" has an incompatible shared consumer.`
51
+ );
52
+ }
53
+ return { layer, encoding, dataset };
54
+ }
55
+
56
+ function rematerializeAssignment(program, scaleId) {
57
+ const consumers = findScaleConsumers(program, scaleId);
58
+ return applyMaterializationPlan(program, buildMaterializationPlan({
59
+ scales: [{
60
+ op: "rematerializeScale",
61
+ args: { id: scaleId, guides: false, marks: false }
62
+ }],
63
+ marks: consumers.map(consumer =>
64
+ getMarkMaterializationStep(program, consumer.layer)
65
+ ),
66
+ guides: planScaleGuideRematerialization(program, scaleId)
67
+ }));
68
+ }
69
+
70
+ export const orderCategories = action(
71
+ { op: "orderCategories", description: "Assign semantic order to a categorical position." },
72
+ function (args = {}) {
73
+ validateKeys(args, ORDER_OPTIONS, "orderCategories");
74
+ const { layer, encoding, dataset } = resolveAssignment(this, args, "orderCategories");
75
+ const order = normalizeCategoryOrder(args);
76
+ resolveCategoryOrder(dataset.values, encoding.field, order);
77
+ const edited = this.editSemantic({
78
+ property: `layer[${layer.id}].encoding.${args.channel}.categoryOrder`,
79
+ value: order
80
+ });
81
+ return rematerializeAssignment(edited, encoding.scale);
82
+ }
83
+ );
84
+
85
+ export const removeCategoryOrder = action(
86
+ { op: "removeCategoryOrder", description: "Restore automatic category order." },
87
+ function (args = {}) {
88
+ validateKeys(args, REMOVE_OPTIONS, "removeCategoryOrder");
89
+ const { layer, encoding } = resolveAssignment(
90
+ this,
91
+ args,
92
+ "removeCategoryOrder",
93
+ { activeOnly: args.target === undefined }
94
+ );
95
+ if (encoding.categoryOrder === undefined) {
96
+ throw new Error(
97
+ `Mark "${layer.id}" ${args.channel} encoding has no category order assignment.`
98
+ );
99
+ }
100
+ const edited = this.editSemantic({
101
+ property: `layer[${layer.id}].encoding.${args.channel}.categoryOrder`,
102
+ remove: true
103
+ });
104
+ return rematerializeAssignment(edited, encoding.scale);
105
+ }
106
+ );
107
+
108
+ export function registerCategoryOrderActions(ProgramClass) {
109
+ ProgramClass.prototype.orderCategories = orderCategories;
110
+ ProgramClass.prototype.removeCategoryOrder = removeCategoryOrder;
111
+ }
@@ -23,6 +23,10 @@ import {
23
23
  import { createIntervalData, materializeIntervalData } from "./interval.js";
24
24
  import { createHorizonData, materializeHorizonData } from "./horizon.js";
25
25
  import { createWindowData, materializeWindowData } from "./window.js";
26
+ import {
27
+ createTimeUnitData,
28
+ materializeTimeUnitData
29
+ } from "./timeUnit.js";
26
30
  import {
27
31
  createBin2DData,
28
32
  editBin2DData,
@@ -54,6 +58,8 @@ export function registerDataActions(ProgramClass) {
54
58
  ProgramClass.prototype.materializeHorizonData = materializeHorizonData;
55
59
  ProgramClass.prototype.createWindowData = createWindowData;
56
60
  ProgramClass.prototype.materializeWindowData = materializeWindowData;
61
+ ProgramClass.prototype.createTimeUnitData = createTimeUnitData;
62
+ ProgramClass.prototype.materializeTimeUnitData = materializeTimeUnitData;
57
63
  ProgramClass.prototype.createBin2DData = createBin2DData;
58
64
  ProgramClass.prototype.editBin2DData = editBin2DData;
59
65
  ProgramClass.prototype.materializeBin2DData = materializeBin2DData;
@@ -0,0 +1,48 @@
1
+ import { action } from "../../core/action.js";
2
+ import { validateUserId } from "../../core/identifiers.js";
3
+ import { validateKeys } from "../../core/validation.js";
4
+ import {
5
+ deriveTimeUnitRows,
6
+ normalizeTimeUnitTransform
7
+ } from "../../grammar/timeUnit.js";
8
+ import { MATERIALIZE_OPTIONS, requireDerivedDataset } from "./shared.js";
9
+
10
+ const OPTIONS = Object.freeze(["id", "source", "field", "unit", "as"]);
11
+
12
+ export const materializeTimeUnitData = action(
13
+ {
14
+ op: "materializeTimeUnitData",
15
+ description: "Materialize one immutable UTC time-unit dataset."
16
+ },
17
+ function (args = {}) {
18
+ validateKeys(args, MATERIALIZE_OPTIONS, "materializeTimeUnitData");
19
+ const { id, source, transform } = requireDerivedDataset(
20
+ this,
21
+ args.id,
22
+ "timeUnit"
23
+ );
24
+ return this.editSemantic({
25
+ property: `dataset[${id}].values`,
26
+ value: deriveTimeUnitRows(source.values, transform)
27
+ });
28
+ }
29
+ );
30
+
31
+ export const createTimeUnitData = action(
32
+ {
33
+ op: "createTimeUnitData",
34
+ description: "Create immutable UTC calendar bucket values."
35
+ },
36
+ function (args = {}) {
37
+ validateKeys(args, OPTIONS, "createTimeUnitData");
38
+ const id = validateUserId(args.id, "Time-unit dataset id");
39
+ const source = validateUserId(
40
+ args.source ?? this.context.currentData,
41
+ "Source dataset id"
42
+ );
43
+ const transform = normalizeTimeUnitTransform(args);
44
+ return this
45
+ .createDerivedData({ id, source, transform: [transform] })
46
+ .materializeTimeUnitData({ id });
47
+ }
48
+ );
@@ -0,0 +1,77 @@
1
+ import { action } from "../../core/action.js";
2
+ import { readQuantitativeField } from "../../grammar/scales/index.js";
3
+ import { resolveTarget, validateOptions } from "./shared.js";
4
+
5
+ const OPTIONS = Object.freeze(["target", "value", "field", "fieldType"]);
6
+
7
+ function rematerialize(program, layer, target) {
8
+ return layer.mark.type === "tick"
9
+ ? program.rematerializeTickMark({ id: target })
10
+ : program.rematerializePointMark({ id: target });
11
+ }
12
+
13
+ export const encodeAngle = action(
14
+ {
15
+ op: "encodeAngle",
16
+ description: "Assign direct clockwise degree rotation to point or Tick glyphs."
17
+ },
18
+ function (args = {}) {
19
+ validateOptions(args, OPTIONS, "encodeAngle");
20
+ const hasValue = Object.hasOwn(args, "value");
21
+ const hasField = Object.hasOwn(args, "field");
22
+ if (hasValue === hasField) {
23
+ throw new Error("encodeAngle requires exactly one of value or field.");
24
+ }
25
+ if (hasValue && args.fieldType !== undefined) {
26
+ throw new Error("Constant angle does not accept fieldType.");
27
+ }
28
+ const { id: target, dataset, layer } = resolveTarget(
29
+ this,
30
+ args.target,
31
+ ["point", "tick"],
32
+ "point or Tick mark"
33
+ );
34
+ if (hasValue && !Number.isFinite(args.value)) {
35
+ throw new TypeError("encodeAngle value must be finite degrees.");
36
+ }
37
+ if (hasField) {
38
+ const fieldType = args.fieldType ?? "quantitative";
39
+ if (fieldType !== "quantitative") {
40
+ throw new Error("encodeAngle requires a quantitative field.");
41
+ }
42
+ readQuantitativeField(dataset.values, args.field);
43
+ }
44
+
45
+ const previous = layer.encoding?.angle;
46
+ let next = this;
47
+ for (const property of ["datum", "field", "fieldType"]) {
48
+ if (Object.hasOwn(previous ?? {}, property)) {
49
+ next = next.editSemantic({
50
+ property: `layer[${target}].encoding.angle.${property}`,
51
+ remove: true
52
+ });
53
+ }
54
+ }
55
+ if (hasValue) {
56
+ next = next.editSemantic({
57
+ property: `layer[${target}].encoding.angle.datum`,
58
+ value: args.value
59
+ });
60
+ } else {
61
+ next = next
62
+ .editSemantic({
63
+ property: `layer[${target}].encoding.angle.field`,
64
+ value: args.field
65
+ })
66
+ .editSemantic({
67
+ property: `layer[${target}].encoding.angle.fieldType`,
68
+ value: "quantitative"
69
+ });
70
+ }
71
+ return rematerialize(next, layer, target);
72
+ }
73
+ );
74
+
75
+ export function registerAngleEncodingAction(ProgramClass) {
76
+ ProgramClass.prototype.encodeAngle = encodeAngle;
77
+ }
@@ -70,8 +70,18 @@ const encodeColor = action(
70
70
  const horizonColor = horizonTransform !== undefined &&
71
71
  horizonTransform.as?.color === args.field &&
72
72
  layer.encoding?.group?.field === horizonTransform.as?.group;
73
+ const barGrain = resolveBarGrain(layer);
74
+ if (layer.mark.type === "bar" && barGrain === undefined) {
75
+ throw new Error(
76
+ "Bar color encoding requires a complete histogram encoding or a complete ordinal aggregate encoding."
77
+ );
78
+ }
79
+ const layout = resolveColorLayout(layer, args.layout, barGrain);
80
+ const createsCenterGroup = layer.mark.type === "area" &&
81
+ layout === "center" && layer.encoding?.group?.field === undefined;
73
82
  if (
74
83
  layer.mark.type === "area" &&
84
+ !createsCenterGroup &&
75
85
  (layer.encoding?.group?.field === undefined ||
76
86
  layer.encoding.group.field !== args.field) &&
77
87
  !densitySeriesFields.includes(args.field) &&
@@ -82,14 +92,6 @@ const encodeColor = action(
82
92
  );
83
93
  }
84
94
  validateLineSeriesCompatibility(layer, "color", args.field);
85
-
86
- const barGrain = resolveBarGrain(layer);
87
- if (layer.mark.type === "bar" && barGrain === undefined) {
88
- throw new Error(
89
- "Bar color encoding requires a complete histogram encoding or a complete ordinal aggregate encoding."
90
- );
91
- }
92
- const layout = resolveColorLayout(layer, args.layout, barGrain);
93
95
  const requestedScale = resolveReassignmentScaleOptions(
94
96
  layer.encoding?.color,
95
97
  resolveColorScaleOptions(args)
@@ -112,11 +114,13 @@ const encodeColor = action(
112
114
  readNominalField(dataset.values, args.field);
113
115
  }
114
116
 
115
- let next = this
116
- .editSemantic({
117
- property: `layer[${target}].encoding.color.field`,
118
- value: args.field
119
- })
117
+ let next = createsCenterGroup
118
+ ? this.encodeGroup({ target, field: args.field })
119
+ : this;
120
+ next = next.editSemantic({
121
+ property: `layer[${target}].encoding.color.field`,
122
+ value: args.field
123
+ })
120
124
  .editSemantic({
121
125
  property: `layer[${target}].encoding.color.fieldType`,
122
126
  value: fieldType
@@ -37,6 +37,8 @@ export function applyColorLayoutCompanion(
37
37
  }
38
38
  const stack = layout === "fill"
39
39
  ? "normalize"
40
+ : layout === "center"
41
+ ? "center"
40
42
  : layout === "overlay" || layout === "group"
41
43
  ? null
42
44
  : "zero";
@@ -74,6 +74,9 @@ export function resolveColorLayout(layer, requested, barGrain) {
74
74
  if (layer.mark.type === "area" && layout === "group") {
75
75
  throw new Error('Area color layout does not support "group".');
76
76
  }
77
+ if (layer.mark.type === "bar" && layout === "center") {
78
+ throw new Error("Centered bars are not supported.");
79
+ }
77
80
  if (
78
81
  layer.mark.type === "arc" &&
79
82
  layout !== undefined &&
@@ -1,4 +1,5 @@
1
1
  import { registerAppearanceEncodingAction } from "./appearance.js";
2
+ import { registerAngleEncodingAction } from "./angle.js";
2
3
  import { registerBarWidthEncodingAction } from "./barWidth.js";
3
4
  import { registerColorEncodingAction } from "./color/index.js";
4
5
  import { registerDensityEncodingAction } from "./density.js";
@@ -26,6 +27,7 @@ export function registerEncodingActions(ProgramClass) {
26
27
  registerColorEncodingAction(ProgramClass);
27
28
  registerStrokeDashEncodingActions(ProgramClass);
28
29
  registerAppearanceEncodingAction(ProgramClass);
30
+ registerAngleEncodingAction(ProgramClass);
29
31
  registerRuleAppearanceEncodingActions(ProgramClass);
30
32
  registerTextEncodingAction(ProgramClass);
31
33
  registerParallelEncodingAction(ProgramClass);
@@ -1,4 +1,6 @@
1
1
  import { validateStack, emptyPositionPolicy } from "./common.js";
2
+ import { deriveCenteredAreaSeries } from "../../../../grammar/areaSeries.js";
3
+ import { readQuantitativeField } from "../../../../grammar/scales/index.js";
2
4
 
3
5
  export function isCategoricalDensityPosition({
4
6
  layer,
@@ -15,7 +17,14 @@ export function isCategoricalDensityPosition({
15
17
  density.placement.categoryField === field;
16
18
  }
17
19
 
18
- export function resolveAreaPositionPolicy({ dataset, channel, args, fieldType, field }) {
20
+ export function resolveAreaPositionPolicy({
21
+ dataset,
22
+ layer,
23
+ channel,
24
+ args,
25
+ fieldType,
26
+ field
27
+ }) {
19
28
  const density = dataset.transform?.find(transform => transform.type === "density");
20
29
  if (["nominal", "ordinal"].includes(fieldType)) {
21
30
  if (
@@ -41,6 +50,35 @@ export function resolveAreaPositionPolicy({ dataset, channel, args, fieldType, f
41
50
  throw new Error("Area position encoding does not support aggregate or bin.");
42
51
  }
43
52
  if (args.stack === undefined) return emptyPositionPolicy();
53
+ const stack = validateStack(args.stack, "Area y encoding");
54
+ if (stack === "center") {
55
+ if (channel !== "y") {
56
+ throw new Error("Area center stack requires a y encoding.");
57
+ }
58
+ if (layer.encoding?.group?.fieldType !== "nominal") {
59
+ throw new Error("Area center stack requires a nominal group encoding.");
60
+ }
61
+ if (
62
+ layer.encoding?.color?.layout !== undefined &&
63
+ layer.encoding.color.layout !== "center"
64
+ ) {
65
+ throw new Error("Area center stack requires a matching center color layout.");
66
+ }
67
+ const values = readQuantitativeField(dataset.values, field);
68
+ if (values.some(value => value < 0)) {
69
+ throw new RangeError("Area center stack requires non-negative values.");
70
+ }
71
+ if (layer.encoding?.x !== undefined) {
72
+ deriveCenteredAreaSeries(dataset.values, {
73
+ ...layer,
74
+ encoding: {
75
+ ...layer.encoding,
76
+ y: { field, fieldType, stack: "center" }
77
+ }
78
+ });
79
+ }
80
+ return { bin: undefined, aggregate: undefined, stack };
81
+ }
44
82
  if (
45
83
  channel !== "y" ||
46
84
  density === undefined
@@ -50,6 +88,6 @@ export function resolveAreaPositionPolicy({ dataset, channel, args, fieldType, f
50
88
  return {
51
89
  bin: undefined,
52
90
  aggregate: undefined,
53
- stack: validateStack(args.stack, "Area y encoding")
91
+ stack
54
92
  };
55
93
  }
@@ -52,6 +52,9 @@ export function resolveBarPositionPolicy({
52
52
  throw new Error('Histogram bar y aggregate must be "count".');
53
53
  }
54
54
  stack = validateStack(stack, "Histogram bar y encoding");
55
+ if (stack === "center") {
56
+ throw new Error("Centered bars are not supported.");
57
+ }
55
58
  } else if (fieldType === "quantitative") {
56
59
  if (args.bin !== undefined) {
57
60
  throw new Error(
@@ -80,6 +83,9 @@ export function resolveBarPositionPolicy({
80
83
  aggregate = validateAggregate(aggregate);
81
84
  validateAggregateFieldType(aggregate, fieldType);
82
85
  stack = validateStack(stack, `Bar ${channel} encoding`);
86
+ if (stack === "center") {
87
+ throw new Error("Centered bars are not supported.");
88
+ }
83
89
  } else {
84
90
  throw new Error(
85
91
  "Bar position requires quantitative, temporal, ordinal, or nominal fields."
@@ -9,6 +9,7 @@ import { resolveBarPositionPolicy } from "./bar.js";
9
9
  import { resolveLinePositionPolicy } from "./line.js";
10
10
  import { resolvePointPositionPolicy } from "./point.js";
11
11
  import { resolveRulePositionPolicy } from "./rule.js";
12
+ import { resolveTickPositionPolicy } from "./tick.js";
12
13
 
13
14
  const POSITION_POLICIES = Object.freeze({
14
15
  area: resolveAreaPositionPolicy,
@@ -17,6 +18,7 @@ const POSITION_POLICIES = Object.freeze({
17
18
  line: resolveLinePositionPolicy,
18
19
  point: resolvePointPositionPolicy,
19
20
  rule: resolveRulePositionPolicy,
21
+ tick: resolveTickPositionPolicy,
20
22
  text: resolvePointPositionPolicy,
21
23
  rect: resolvePointPositionPolicy
22
24
  });
@@ -0,0 +1,19 @@
1
+ import { emptyPositionPolicy } from "./common.js";
2
+
3
+ export function resolveTickPositionPolicy({ args, fieldType }) {
4
+ if (!["quantitative", "temporal", "ordinal", "nominal"].includes(fieldType)) {
5
+ throw new Error(
6
+ "Tick position encoding requires quantitative, temporal, ordinal, or nominal fields."
7
+ );
8
+ }
9
+ if (args.aggregate !== undefined) {
10
+ throw new Error("Tick position encoding does not support aggregate.");
11
+ }
12
+ if (args.bin !== undefined) {
13
+ throw new Error("Tick position encoding does not support bin.");
14
+ }
15
+ if (args.stack !== undefined) {
16
+ throw new Error("Tick position encoding does not support stack.");
17
+ }
18
+ return emptyPositionPolicy();
19
+ }
@@ -21,7 +21,7 @@ const OPTIONS = Object.freeze(["target", "channel"]);
21
21
  const REMOVABLE_CHANNELS = Object.freeze([
22
22
  "x", "y", "x2", "y2", "xOffset", "yOffset", "theta", "radius",
23
23
  "color", "strokeDash", "strokeWidth", "size", "shape", "group",
24
- "opacity", "text"
24
+ "angle", "opacity", "text"
25
25
  ]);
26
26
  const REMOVE_AXIS = Object.freeze({
27
27
  x: "removeXAxis",
@@ -8,6 +8,11 @@ import { isSizeLegendPoint } from "../size.js";
8
8
  import { isStrokeWidthLegendLayer } from "../strokeWidth.js";
9
9
  import { legendResourcePolicies } from
10
10
  "../../../../materialization/guides/resources.js";
11
+ import {
12
+ hasMultiHorizontalLegendLane,
13
+ hasMultiLegendLane,
14
+ hasMultiSideLegendLane
15
+ } from "../lane.js";
11
16
  import {
12
17
  resolveCurrentDefinition,
13
18
  resolveDefinition,
@@ -91,6 +96,12 @@ export const rematerializeLegend = action(
91
96
  next = next[policy.rematerializeOp]();
92
97
  }
93
98
  }
99
+ if (hasMultiSideLegendLane(next)) {
100
+ next = next.rematerializeSideLegendLane();
101
+ }
102
+ if (hasMultiHorizontalLegendLane(next)) {
103
+ next = next.rematerializeHorizontalLegendLane();
104
+ }
94
105
  return next;
95
106
  }
96
107
  );
@@ -212,10 +223,11 @@ export const createLegend = action(
212
223
  if (position !== undefined && position !== "right") {
213
224
  throw new Error('Standalone size legends currently require position "right".');
214
225
  }
215
- return this.createSizeLegend({
226
+ const next = this.createSizeLegend({
216
227
  ...(target === undefined ? {} : { target }),
217
228
  ...(count === undefined ? {} : { count })
218
229
  });
230
+ return hasMultiLegendLane(next) ? next.rematerializeLegend() : next;
219
231
  }
220
232
  const strokeWidthCandidates = this.semanticSpec.layers.filter(
221
233
  isStrokeWidthLegendLayer
@@ -248,10 +260,11 @@ export const createLegend = action(
248
260
  'Standalone stroke-width legends currently require position "right".'
249
261
  );
250
262
  }
251
- return this.createStrokeWidthLegend({
263
+ const next = this.createStrokeWidthLegend({
252
264
  ...(target === undefined ? {} : { target }),
253
265
  ...(count === undefined ? {} : { count })
254
266
  });
267
+ return hasMultiLegendLane(next) ? next.rematerializeLegend() : next;
255
268
  }
256
269
  const opacityCandidates = this.semanticSpec.layers.filter(layer =>
257
270
  layer.mark?.type === "point" && layer.encoding?.opacity?.scale !== undefined
@@ -266,7 +279,8 @@ export const createLegend = action(
266
279
  (channels === undefined && opacityCandidates.length === 1 &&
267
280
  !hasOtherLegendCandidate)
268
281
  ) {
269
- return this.createOpacityLegend(args);
282
+ const next = this.createOpacityLegend(args);
283
+ return hasMultiLegendLane(next) ? next.rematerializeLegend() : next;
270
284
  }
271
285
  const continuousColorCandidates = this.semanticSpec.layers.filter(layer => {
272
286
  const encoding = ["point", "bar", "rect"].includes(layer.mark?.type)
@@ -287,7 +301,8 @@ export const createLegend = action(
287
301
  (channels?.length === 1 && channels[0] === "color" && continuousColor) ||
288
302
  (channels === undefined && continuousColor)
289
303
  ) {
290
- return this.createGradientLegend(args);
304
+ const next = this.createGradientLegend(args);
305
+ return hasMultiLegendLane(next) ? next.rematerializeLegend() : next;
291
306
  }
292
307
  const intervalColorCandidates = this.semanticSpec.layers.filter(layer => {
293
308
  const encoding = layer.mark?.type === "point" ? layer.encoding?.color : undefined;
@@ -307,7 +322,8 @@ export const createLegend = action(
307
322
  intervalColorCandidates.length > 0) ||
308
323
  (channels === undefined && intervalColorCandidates.length > 0)
309
324
  ) {
310
- return this.createIntervalLegend(args);
325
+ const next = this.createIntervalLegend(args);
326
+ return hasMultiLegendLane(next) ? next.rematerializeLegend() : next;
311
327
  }
312
328
  const wantsShape = channels?.includes("shape") === true;
313
329
  const pointCandidates = this.semanticSpec.layers.filter(layer =>
@@ -383,7 +399,8 @@ export const createLegend = action(
383
399
  }
384
400
  return next;
385
401
  }
386
- return this.createCategoricalLegend(args);
402
+ const next = this.createCategoricalLegend(args);
403
+ return hasMultiLegendLane(next) ? next.rematerializeLegend() : next;
387
404
  }
388
405
  );
389
406
 
@@ -24,6 +24,10 @@ import {
24
24
  rematerializeLegendHighlights
25
25
  } from "./symbols.js";
26
26
  import { editLegend } from "../edit.js";
27
+ import {
28
+ rematerializeHorizontalLegendLane,
29
+ rematerializeSideLegendLane
30
+ } from "../lane.js";
27
31
 
28
32
  export function registerBasicCategoricalLegendActions(ProgramClass) {
29
33
  ProgramClass.prototype.createLegend = createLegend;
@@ -45,6 +49,10 @@ export function registerBasicCategoricalLegendActions(ProgramClass) {
45
49
  ProgramClass.prototype.createLegendTitle = createLegendTitle;
46
50
  ProgramClass.prototype.rematerializeLegendTitle = rematerializeLegendTitle;
47
51
  ProgramClass.prototype.rematerializeLegend = rematerializeLegend;
52
+ ProgramClass.prototype.rematerializeSideLegendLane =
53
+ rematerializeSideLegendLane;
54
+ ProgramClass.prototype.rematerializeHorizontalLegendLane =
55
+ rematerializeHorizontalLegendLane;
48
56
  }
49
57
 
50
58
  export function registerCategoricalLegendActions(ProgramClass) {