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,46 @@
1
+ import { cloneAndFreeze } from "../core/immutable.js";
2
+ import { readQuantitativeField } from "./scales/index.js";
3
+
4
+ function finite(value, label) {
5
+ if (!Number.isFinite(value)) {
6
+ throw new TypeError(`${label} must be finite.`);
7
+ }
8
+ return value;
9
+ }
10
+
11
+ export function directionVector(degrees) {
12
+ const radians = finite(degrees, "Direction degrees") * Math.PI / 180;
13
+ return cloneAndFreeze({
14
+ x: Math.sin(radians),
15
+ y: -Math.cos(radians)
16
+ });
17
+ }
18
+
19
+ export function centeredDirectionalSegment({ x, y, degrees = 0, length }) {
20
+ const centerX = finite(x, "Directional segment x");
21
+ const centerY = finite(y, "Directional segment y");
22
+ const resolvedLength = finite(length, "Directional segment length");
23
+ if (resolvedLength <= 0) {
24
+ throw new RangeError("Directional segment length must be positive.");
25
+ }
26
+ const vector = directionVector(degrees);
27
+ const half = resolvedLength / 2;
28
+ return cloneAndFreeze({
29
+ x1: centerX - vector.x * half,
30
+ y1: centerY - vector.y * half,
31
+ x2: centerX + vector.x * half,
32
+ y2: centerY + vector.y * half
33
+ });
34
+ }
35
+
36
+ export function resolveDirectionValues(rows, encoding) {
37
+ if (encoding === undefined) return undefined;
38
+ if (Object.hasOwn(encoding, "datum")) {
39
+ const value = finite(encoding.datum, "Direction value");
40
+ return cloneAndFreeze(rows.map(() => value));
41
+ }
42
+ if (encoding.fieldType !== "quantitative") {
43
+ throw new Error("Direction field must be quantitative.");
44
+ }
45
+ return readQuantitativeField(rows, encoding.field);
46
+ }
@@ -5,7 +5,7 @@ import { planFacetDependencies } from "./dependencies.js";
5
5
  import { readNominalField } from "../scales/index.js";
6
6
 
7
7
  const SUPPORTED_MARKS = new Set([
8
- "point", "line", "area", "bar", "rule", "rect"
8
+ "point", "line", "area", "bar", "rule", "tick", "rect"
9
9
  ]);
10
10
  const SUPPORTED_BAR_GRAINS = new Set([
11
11
  BAR_GRAINS.histogram,
@@ -77,6 +77,12 @@ function shapePolygon(shape) {
77
77
  { x: -1, y: -arm }, { x: -arm, y: -arm }
78
78
  ];
79
79
  return {
80
+ square: [
81
+ { x: -1, y: -1 },
82
+ { x: 1, y: -1 },
83
+ { x: 1, y: 1 },
84
+ { x: -1, y: 1 }
85
+ ],
80
86
  diamond: [
81
87
  { x: 0, y: -1 },
82
88
  { x: 1, y: 0 },
@@ -120,7 +126,8 @@ export function createPointShapeGraphic({
120
126
  fill,
121
127
  stroke,
122
128
  strokeWidth,
123
- opacity
129
+ opacity,
130
+ angle
124
131
  }) {
125
132
  const validated = validatePointShape(shape);
126
133
  if (![x, y, area].every(Number.isFinite) || area < 0) {
@@ -130,13 +137,16 @@ export function createPointShapeGraphic({
130
137
  throw new TypeError("Point shape fill must be a non-empty string.");
131
138
  }
132
139
  const shared = appearance(fill, stroke, strokeWidth, opacity);
140
+ if (angle !== undefined && !Number.isFinite(angle)) {
141
+ throw new TypeError("Point shape angle must be finite degrees.");
142
+ }
133
143
  if (validated === "circle") {
134
144
  return {
135
145
  type: "circle",
136
146
  properties: { x, y, radius: Math.sqrt(area / Math.PI), ...shared }
137
147
  };
138
148
  }
139
- if (validated === "square") {
149
+ if (validated === "square" && angle === undefined) {
140
150
  const side = Math.sqrt(area);
141
151
  return {
142
152
  type: "rect",
@@ -155,7 +165,12 @@ export function createPointShapeGraphic({
155
165
  type: "path",
156
166
  properties: {
157
167
  commands: buildLinearPathCommands(
158
- normalizePolygon(shapePolygon(validated), x, y, area),
168
+ normalizePolygon(
169
+ rotate(shapePolygon(validated), (angle ?? 0) * Math.PI / 180),
170
+ x,
171
+ y,
172
+ area
173
+ ),
159
174
  { close: true }
160
175
  ),
161
176
  ...shared
@@ -163,7 +178,12 @@ export function createPointShapeGraphic({
163
178
  };
164
179
  }
165
180
 
166
- export function resolvePointShapeExtent({ shape, area, strokeWidth = 0 }) {
181
+ export function resolvePointShapeExtent({
182
+ shape,
183
+ area,
184
+ strokeWidth = 0,
185
+ angle
186
+ }) {
167
187
  const validated = validatePointShape(shape);
168
188
  if (!Number.isFinite(area) || area < 0) {
169
189
  throw new TypeError("Point shape area must be finite and non-negative.");
@@ -176,11 +196,19 @@ export function resolvePointShapeExtent({ shape, area, strokeWidth = 0 }) {
176
196
  const radius = Math.sqrt(area / Math.PI) + strokeExtent;
177
197
  return cloneAndFreeze({ x: radius, y: radius });
178
198
  }
179
- if (validated === "square") {
199
+ if (angle !== undefined && !Number.isFinite(angle)) {
200
+ throw new TypeError("Point shape angle must be finite degrees.");
201
+ }
202
+ if (validated === "square" && angle === undefined) {
180
203
  const halfSide = Math.sqrt(area) / 2 + strokeExtent;
181
204
  return cloneAndFreeze({ x: halfSide, y: halfSide });
182
205
  }
183
- const points = normalizePolygon(shapePolygon(validated), 0, 0, area);
206
+ const points = normalizePolygon(
207
+ rotate(shapePolygon(validated), (angle ?? 0) * Math.PI / 180),
208
+ 0,
209
+ 0,
210
+ area
211
+ );
184
212
  return cloneAndFreeze({
185
213
  x: Math.max(0, ...points.map(point => Math.abs(point.x))) + strokeExtent,
186
214
  y: Math.max(0, ...points.map(point => Math.abs(point.y))) + strokeExtent
@@ -23,6 +23,10 @@ export const POSITION_FIELD_COMPATIBILITY = Object.freeze({
23
23
  x: Object.freeze(["quantitative", "temporal", "ordinal", "nominal"]),
24
24
  y: Object.freeze(["quantitative", "temporal", "ordinal", "nominal"])
25
25
  }),
26
+ tick: Object.freeze({
27
+ x: Object.freeze(["quantitative", "temporal", "ordinal", "nominal"]),
28
+ y: Object.freeze(["quantitative", "temporal", "ordinal", "nominal"])
29
+ }),
26
30
  bar: Object.freeze({
27
31
  x: Object.freeze(["quantitative", "temporal", "ordinal", "nominal"]),
28
32
  y: Object.freeze(["quantitative", "temporal", "ordinal", "nominal"])
@@ -31,7 +31,8 @@ const ENCODING_PATHS = Object.freeze([
31
31
  "encoding.parallel.missing",
32
32
  ...CARTESIAN_POSITION_CHANNELS.flatMap(channel => [
33
33
  `encoding.${channel}.aggregate`,
34
- `encoding.${channel}.stack`
34
+ `encoding.${channel}.stack`,
35
+ `encoding.${channel}.categoryOrder`
35
36
  ])
36
37
  ]);
37
38
 
@@ -35,13 +35,13 @@ export function layoutSeriesPartition(
35
35
  ));
36
36
  }
37
37
 
38
- if (layout === "stack" || layout === "fill") {
38
+ if (["stack", "fill", "center"].includes(layout)) {
39
39
  if (values.some(value => value < 0)) {
40
40
  throw new RangeError(`${layout} layout requires non-negative values.`);
41
41
  }
42
42
  const total = values.reduce((sum, value) => sum + value, 0);
43
43
  if (layout === "fill" && total === 0) return cloneAndFreeze([]);
44
- let offset = 0;
44
+ let offset = layout === "center" ? -total / 2 : 0;
45
45
  return cloneAndFreeze(values.flatMap((value, index) => {
46
46
  if (value === 0) return [];
47
47
  const resolvedValue = layout === "fill" ? value / total : value;
@@ -78,6 +78,14 @@ export function resolveSeriesLayoutDomainValues(partitions, layout) {
78
78
  ...partition
79
79
  ]);
80
80
  }
81
+ if (layout === "center") {
82
+ return partitions.flatMap(partition => {
83
+ const segments = layoutSeriesPartition(partition, layout);
84
+ return segments.length === 0
85
+ ? [DEFAULT_SERIES_BASELINE]
86
+ : segments.flatMap(segment => [segment.start, segment.end]);
87
+ });
88
+ }
81
89
  return partitions.flatMap(partition =>
82
90
  layoutSeriesPartition(partition, layout).flatMap(segment => [
83
91
  segment.start,
@@ -0,0 +1,108 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import { normalizeTemporalValue } from "./scales/fields.js";
3
+
4
+ export const TIME_UNITS = cloneAndFreeze([
5
+ "year",
6
+ "quarter",
7
+ "month",
8
+ "day",
9
+ "hour",
10
+ "minute",
11
+ "second"
12
+ ]);
13
+
14
+ const TRANSFORM_KEYS = Object.freeze(["type", "field", "unit", "as"]);
15
+
16
+ function requireField(value, label) {
17
+ if (typeof value !== "string" || value.length === 0) {
18
+ throw new TypeError(`${label} must be a non-empty string.`);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ function rejectUnknownKeys(value) {
24
+ const unknown = Object.keys(value).find(key => !TRANSFORM_KEYS.includes(key));
25
+ if (unknown !== undefined) {
26
+ throw new Error(`Unknown time-unit transform property "${unknown}".`);
27
+ }
28
+ }
29
+
30
+ export function validateTimeUnitTransform(transform) {
31
+ if (!isPlainObject(transform)) {
32
+ throw new TypeError("Time-unit transform must be a plain object.");
33
+ }
34
+ rejectUnknownKeys(transform);
35
+ if (transform.type !== "timeUnit") {
36
+ throw new Error(`Unsupported time-unit transform "${transform.type}".`);
37
+ }
38
+ const field = requireField(transform.field, "Time-unit field");
39
+ const output = requireField(transform.as, "Time-unit output field");
40
+ if (field === output) {
41
+ throw new Error("Time-unit input and output fields must be distinct.");
42
+ }
43
+ if (!TIME_UNITS.includes(transform.unit)) {
44
+ throw new Error(`Unsupported time unit "${transform.unit}".`);
45
+ }
46
+ return transform;
47
+ }
48
+
49
+ export function normalizeTimeUnitTransform({ field, unit, as } = {}) {
50
+ const transform = { type: "timeUnit", field, unit, as };
51
+ validateTimeUnitTransform(transform);
52
+ return cloneAndFreeze(transform);
53
+ }
54
+
55
+ function utcTimestamp(year, month = 0, day = 1, hour = 0, minute = 0) {
56
+ const date = new Date(0);
57
+ date.setUTCFullYear(year, month, day);
58
+ date.setUTCHours(hour, minute, 0, 0);
59
+ return date.getTime();
60
+ }
61
+
62
+ export function floorUtcTimeUnit(timestamp, unit) {
63
+ if (!Number.isFinite(timestamp)) {
64
+ throw new TypeError("Time-unit timestamp must be finite.");
65
+ }
66
+ if (!TIME_UNITS.includes(unit)) {
67
+ throw new Error(`Unsupported time unit "${unit}".`);
68
+ }
69
+ const date = new Date(timestamp);
70
+ if (!Number.isFinite(date.getTime())) {
71
+ throw new TypeError("Time-unit timestamp must represent a valid date.");
72
+ }
73
+ const year = date.getUTCFullYear();
74
+ const month = date.getUTCMonth();
75
+ if (unit === "year") return utcTimestamp(year);
76
+ if (unit === "quarter") return utcTimestamp(year, Math.floor(month / 3) * 3);
77
+ if (unit === "month") return utcTimestamp(year, month);
78
+ const day = date.getUTCDate();
79
+ if (unit === "day") return utcTimestamp(year, month, day);
80
+ const hour = date.getUTCHours();
81
+ if (unit === "hour") return utcTimestamp(year, month, day, hour);
82
+ const minute = date.getUTCMinutes();
83
+ if (unit === "minute") return utcTimestamp(year, month, day, hour, minute);
84
+ return Math.floor(timestamp / 1000) * 1000;
85
+ }
86
+
87
+ export function deriveTimeUnitRows(rows, transform) {
88
+ validateTimeUnitTransform(transform);
89
+ if (!Array.isArray(rows) || !rows.every(isPlainObject)) {
90
+ throw new TypeError("Time-unit source must contain plain row objects.");
91
+ }
92
+ const values = rows.map((row, index) => {
93
+ if (!Object.hasOwn(row, transform.field)) {
94
+ throw new Error(
95
+ `Time-unit source does not contain field "${transform.field}" at row ${index}.`
96
+ );
97
+ }
98
+ if (Object.hasOwn(row, transform.as)) {
99
+ throw new Error(`Time-unit output field "${transform.as}" already exists.`);
100
+ }
101
+ const timestamp = normalizeTemporalValue(row[transform.field], transform.field, index);
102
+ return {
103
+ ...row,
104
+ [transform.as]: floorUtcTimeUnit(timestamp, transform.unit)
105
+ };
106
+ });
107
+ return cloneAndFreeze(values);
108
+ }
@@ -18,6 +18,7 @@ import {
18
18
  import { validateMarkFilterTransform } from "./markFilter.js";
19
19
  import { validateRegressionTransform } from "./regression/index.js";
20
20
  import { validateWindowTransform } from "./window.js";
21
+ import { validateTimeUnitTransform } from "./timeUnit.js";
21
22
 
22
23
  function requestedDensityTransform(transform) {
23
24
  const { resolved: _resolved, ...requested } = transform;
@@ -94,6 +95,11 @@ const TRANSFORM_POLICIES = Object.freeze({
94
95
  materializeOp: "materializeRegressionData",
95
96
  facetTopology: "statistical"
96
97
  }),
98
+ timeUnit: Object.freeze({
99
+ validate: validateTimeUnitTransform,
100
+ materializeOp: "materializeTimeUnitData",
101
+ facetTopology: "rowPreserving"
102
+ }),
97
103
  window: Object.freeze({
98
104
  validate: validateWindowTransform,
99
105
  materializeOp: "materializeWindowData",
@@ -6,10 +6,13 @@ const TRANSFORM_KEYS = Object.freeze([
6
6
  const SORT_KEYS = Object.freeze(["field", "order"]);
7
7
  const ORDER_VALUES = Object.freeze(["ascending", "descending"]);
8
8
  const OPERATION_VALUES = Object.freeze([
9
- "rowNumber", "rank", "denseRank", "cumulativeSum", "lag", "lead"
9
+ "rowNumber", "rank", "denseRank", "cumulativeSum", "lag", "lead",
10
+ "movingMean", "movingSum"
10
11
  ]);
11
12
  const POSITION_OPERATIONS = new Set(["rowNumber", "rank", "denseRank"]);
12
13
  const OFFSET_OPERATIONS = new Set(["lag", "lead"]);
14
+ const MOVING_OPERATIONS = new Set(["movingMean", "movingSum"]);
15
+ const FRAME_KEYS = Object.freeze(["preceding", "following"]);
13
16
 
14
17
  function requireField(value, label) {
15
18
  if (typeof value !== "string" || value.length === 0) {
@@ -63,9 +66,29 @@ function operationKeys(operation) {
63
66
  if (OFFSET_OPERATIONS.has(operation.op)) {
64
67
  return ["op", "field", "as", "offset", "default"];
65
68
  }
69
+ if (MOVING_OPERATIONS.has(operation.op)) {
70
+ return ["op", "field", "as", "frame"];
71
+ }
66
72
  return ["op"];
67
73
  }
68
74
 
75
+ function validateFrame(frame, operation) {
76
+ if (!isPlainObject(frame)) {
77
+ throw new TypeError(`Window ${operation} frame must be a plain object.`);
78
+ }
79
+ rejectUnknownKeys(frame, FRAME_KEYS, `window ${operation} frame`);
80
+ if (!Number.isInteger(frame.preceding) || frame.preceding < 0) {
81
+ throw new RangeError(
82
+ `Window ${operation} frame preceding must be a non-negative integer.`
83
+ );
84
+ }
85
+ if (!Number.isInteger(frame.following) || frame.following < 0) {
86
+ throw new RangeError(
87
+ `Window ${operation} frame following must be a non-negative integer.`
88
+ );
89
+ }
90
+ }
91
+
69
92
  function validateOperations(operations, sortBy) {
70
93
  if (!Array.isArray(operations) || operations.length === 0) {
71
94
  throw new TypeError("Window operations must be a non-empty array.");
@@ -98,6 +121,9 @@ function validateOperations(operations, sortBy) {
98
121
  throw new TypeError(`Window ${operation.op} requires a default value.`);
99
122
  }
100
123
  }
124
+ if (MOVING_OPERATIONS.has(operation.op)) {
125
+ validateFrame(operation.frame, operation.op);
126
+ }
101
127
  });
102
128
  }
103
129
 
@@ -132,14 +158,26 @@ function normalizeSortBy(value) {
132
158
  function normalizeOperations(value) {
133
159
  if (!Array.isArray(value)) return value;
134
160
  return value.map(operation => {
135
- if (!isPlainObject(operation) || !OFFSET_OPERATIONS.has(operation.op)) {
161
+ if (!isPlainObject(operation)) {
136
162
  return operation;
137
163
  }
138
- return {
139
- ...operation,
140
- offset: operation.offset ?? 1,
141
- default: Object.hasOwn(operation, "default") ? operation.default : null
142
- };
164
+ if (OFFSET_OPERATIONS.has(operation.op)) {
165
+ return {
166
+ ...operation,
167
+ offset: operation.offset ?? 1,
168
+ default: Object.hasOwn(operation, "default") ? operation.default : null
169
+ };
170
+ }
171
+ if (MOVING_OPERATIONS.has(operation.op) && isPlainObject(operation.frame)) {
172
+ return {
173
+ ...operation,
174
+ frame: {
175
+ ...operation.frame,
176
+ following: operation.frame.following ?? 0
177
+ }
178
+ };
179
+ }
180
+ return operation;
143
181
  });
144
182
  }
145
183
 
@@ -305,6 +343,34 @@ function applyOperation(partition, operation, sortBy) {
305
343
  }
306
344
  return;
307
345
  }
346
+ if (MOVING_OPERATIONS.has(operation.op)) {
347
+ partition.forEach((entry, index) => {
348
+ const first = Math.max(0, index - operation.frame.preceding);
349
+ const last = Math.min(
350
+ partition.length - 1,
351
+ index + operation.frame.following
352
+ );
353
+ let total = 0;
354
+ for (let peerIndex = first; peerIndex <= last; peerIndex += 1) {
355
+ const value = partition[peerIndex].row[operation.field];
356
+ if (!Number.isFinite(value)) {
357
+ throw new TypeError(
358
+ `Window ${operation.op} field "${operation.field}" must contain finite numbers.`
359
+ );
360
+ }
361
+ total += value;
362
+ if (!Number.isFinite(total)) {
363
+ throw new RangeError(
364
+ `Window ${operation.op} output "${operation.as}" must be finite.`
365
+ );
366
+ }
367
+ }
368
+ entry.row[operation.as] = operation.op === "movingMean"
369
+ ? total / (last - first + 1)
370
+ : total;
371
+ });
372
+ return;
373
+ }
308
374
  const direction = operation.op === "lag" ? -1 : 1;
309
375
  partition.forEach((entry, index) => {
310
376
  const peer = partition[index + direction * operation.offset];