ggaction 0.0.4 → 0.0.5

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 (157) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +35 -13
  3. package/package.json +18 -7
  4. package/src/actions/boxPlots/create.js +13 -7
  5. package/src/actions/boxPlots/edit.js +31 -35
  6. package/src/actions/boxPlots/materialize.js +3 -0
  7. package/src/actions/boxPlots/options.js +8 -1
  8. package/src/actions/boxPlots/resolve.js +10 -2
  9. package/src/actions/charts/bar.js +50 -0
  10. package/src/actions/charts/heatmap.js +205 -0
  11. package/src/actions/charts/histogram.js +58 -0
  12. package/src/actions/charts/index.js +15 -0
  13. package/src/actions/charts/line.js +69 -0
  14. package/src/actions/charts/parallel.js +76 -0
  15. package/src/actions/charts/scatter.js +56 -0
  16. package/src/actions/charts/shared.js +121 -0
  17. package/src/actions/coordinates/parallel.js +50 -0
  18. package/src/actions/data/bin2d.js +137 -0
  19. package/src/actions/data/density.js +54 -13
  20. package/src/actions/data/derived.js +22 -0
  21. package/src/actions/data/filter.js +5 -0
  22. package/src/actions/data/gradientProfile.js +65 -0
  23. package/src/actions/data/horizon.js +71 -0
  24. package/src/actions/data/index.js +29 -2
  25. package/src/actions/data/window.js +50 -0
  26. package/src/actions/encodings/color/index.js +17 -1
  27. package/src/actions/encodings/color/layout.js +8 -0
  28. package/src/actions/encodings/density/resolve.js +109 -0
  29. package/src/actions/encodings/density.js +193 -88
  30. package/src/actions/encodings/horizon/resolve.js +283 -0
  31. package/src/actions/encodings/horizon.js +364 -0
  32. package/src/actions/encodings/index.js +6 -0
  33. package/src/actions/encodings/parallel.js +115 -0
  34. package/src/actions/encodings/pathOrder.js +131 -0
  35. package/src/actions/encodings/position/apply.js +73 -4
  36. package/src/actions/encodings/position/index.js +15 -2
  37. package/src/actions/encodings/position/policies/arc.js +27 -4
  38. package/src/actions/encodings/position/policies/area.js +33 -2
  39. package/src/actions/encodings/position/policies/index.js +7 -2
  40. package/src/actions/encodings/position/policies/line.js +18 -2
  41. package/src/actions/encodings/position/resolve.js +24 -2
  42. package/src/actions/encodings/ranged.js +3 -1
  43. package/src/actions/encodings/ruleAppearance.js +88 -10
  44. package/src/actions/facets/actions.js +1 -2
  45. package/src/actions/facets/derive.js +33 -3
  46. package/src/actions/facets/replay.js +7 -36
  47. package/src/actions/gradientPlots/components.js +177 -0
  48. package/src/actions/gradientPlots/create.js +96 -0
  49. package/src/actions/gradientPlots/edit.js +153 -0
  50. package/src/actions/gradientPlots/index.js +24 -0
  51. package/src/actions/gradientPlots/materialize.js +287 -0
  52. package/src/actions/gradientPlots/options.js +120 -0
  53. package/src/actions/gradientPlots/paint.js +107 -0
  54. package/src/actions/gradientPlots/rebind.js +51 -0
  55. package/src/actions/gradientPlots/resolve.js +81 -0
  56. package/src/actions/guides/applicability.js +44 -17
  57. package/src/actions/guides/axes/axes.js +37 -3
  58. package/src/actions/guides/axes/index.js +2 -0
  59. package/src/actions/guides/axes/labels.js +11 -6
  60. package/src/actions/guides/axes/parallel/resolve.js +86 -0
  61. package/src/actions/guides/axes/parallel.js +228 -0
  62. package/src/actions/guides/axes/titles.js +11 -8
  63. package/src/actions/guides/guides.js +60 -5
  64. package/src/actions/guides/legends/categorical/actions.js +48 -11
  65. package/src/actions/guides/legends/edit.js +5 -0
  66. package/src/actions/guides/legends/index.js +2 -0
  67. package/src/actions/guides/legends/remove.js +4 -11
  68. package/src/actions/guides/legends/strokeWidth.js +170 -0
  69. package/src/actions/index.js +6 -0
  70. package/src/actions/marks/area/actions.js +60 -136
  71. package/src/actions/marks/area/materialize.js +163 -0
  72. package/src/actions/marks/index.js +5 -1
  73. package/src/actions/marks/line/actions.js +99 -80
  74. package/src/actions/marks/line/materialize.js +146 -0
  75. package/src/actions/marks/point/create.js +76 -0
  76. package/src/actions/marks/point/edit.js +85 -0
  77. package/src/actions/marks/point/index.js +11 -1
  78. package/src/actions/marks/point/jitter.js +72 -0
  79. package/src/actions/marks/point/{actions.js → materialize.js} +175 -170
  80. package/src/actions/marks/rect/actions.js +3 -0
  81. package/src/actions/marks/remove.js +13 -2
  82. package/src/actions/marks/rule/actions.js +25 -3
  83. package/src/actions/marks/text/actions.js +19 -4
  84. package/src/actions/marks/text/index.js +7 -1
  85. package/src/actions/marks/text/layout.js +321 -0
  86. package/src/actions/primitives/semanticValidation/dataset.js +22 -0
  87. package/src/actions/primitives/semanticValidation/guide.js +43 -0
  88. package/src/actions/primitives/semanticValidation/index.js +23 -0
  89. package/src/actions/primitives/semanticValidation/layer.js +74 -0
  90. package/src/actions/primitives/semanticValidation/scale.js +74 -0
  91. package/src/actions/primitives/semanticValidation/shared.js +5 -0
  92. package/src/actions/primitives/semanticValue.js +1 -204
  93. package/src/actions/regression/edit.js +19 -21
  94. package/src/actions/scales/consumers/common.js +18 -0
  95. package/src/actions/scales/consumers/families.js +14 -3
  96. package/src/actions/scales/definitions.js +30 -0
  97. package/src/actions/scales/edit.js +6 -204
  98. package/src/actions/scales/editPolicy.js +214 -0
  99. package/src/actions/scales/materialize.js +1 -1
  100. package/src/actions/scales/patch.js +26 -0
  101. package/src/actions/selection/actions.js +28 -3
  102. package/src/actions/violinPlots/create.js +198 -0
  103. package/src/actions/violinPlots/index.js +5 -0
  104. package/src/core/textMetrics.js +47 -0
  105. package/src/core/vocabulary.js +7 -2
  106. package/src/grammar/arcs.js +49 -12
  107. package/src/grammar/areaSeries.js +41 -5
  108. package/src/grammar/bin2d.js +333 -0
  109. package/src/grammar/categoricalDensity.js +192 -0
  110. package/src/grammar/coordinates.js +1 -1
  111. package/src/grammar/curveCommands.js +3 -1
  112. package/src/grammar/density.js +252 -26
  113. package/src/grammar/facets/dependencies.js +3 -11
  114. package/src/grammar/facets/index.js +3 -1
  115. package/src/grammar/gradientProfile.js +213 -0
  116. package/src/grammar/horizon.js +455 -0
  117. package/src/grammar/jitter.js +197 -0
  118. package/src/grammar/lineSeries.js +69 -0
  119. package/src/grammar/paint.js +88 -0
  120. package/src/grammar/parallelCoordinates.js +213 -0
  121. package/src/grammar/pathOrder.js +35 -0
  122. package/src/grammar/pointShapes.js +24 -0
  123. package/src/grammar/rules.js +1 -0
  124. package/src/grammar/scales/appearance.js +21 -0
  125. package/src/grammar/schemas/concreteGraphic.js +11 -1
  126. package/src/grammar/schemas/graphicBounds.js +19 -37
  127. package/src/grammar/schemas/semanticPath.js +17 -2
  128. package/src/grammar/transforms.js +100 -11
  129. package/src/grammar/window.js +339 -0
  130. package/src/layout/labels.js +270 -0
  131. package/src/layout/text.js +4 -1
  132. package/src/layout/title.js +10 -20
  133. package/src/materialization/dataProvenance.js +56 -2
  134. package/src/materialization/dependencies.js +9 -14
  135. package/src/materialization/guides/resources.js +74 -24
  136. package/src/materialization/horizon.js +29 -0
  137. package/src/materialization/layout.js +15 -0
  138. package/src/materialization/marks/capabilities.js +21 -1
  139. package/src/materialization/marks/index.js +3 -0
  140. package/src/materialization/marks/pathOrder.js +71 -0
  141. package/src/materialization/marks/policies.js +6 -2
  142. package/src/materialization/scaleGuideDependencies.js +8 -0
  143. package/src/materialization/scales/resolve.js +2 -0
  144. package/src/materialization/selection/items/path.js +27 -1
  145. package/src/materialization/selection/items/point.js +1 -5
  146. package/src/materialization/selection/items/rect.js +21 -0
  147. package/src/materialization/selection/styles.js +20 -9
  148. package/src/materialization/text.js +12 -4
  149. package/src/renderers/canvas/fill.js +33 -0
  150. package/src/renderers/canvas/index.js +7 -0
  151. package/src/renderers/canvas/path.js +7 -4
  152. package/src/renderers/canvas/rect.js +9 -3
  153. package/src/selectors/datasets.js +4 -0
  154. package/src/theme/defaults.js +4 -0
  155. package/types/extension.d.ts +6 -0
  156. package/types/index.d.ts +76 -0
  157. package/types/program.d.ts +632 -10
@@ -1,6 +1,5 @@
1
1
  import { action } from "../../core/action.js";
2
2
  import { validateUserId } from "../../core/identifiers.js";
3
- import { isPlainObject } from "../../core/immutable.js";
4
3
  import {
5
4
  findDataset,
6
5
  findLayer,
@@ -11,15 +10,27 @@ import { applyMaterializationPlan } from
11
10
  "../../materialization/dependencies.js";
12
11
  import { planDensityRematerialization } from
13
12
  "../../materialization/density.js";
13
+ import { planDerivedDataRevision } from
14
+ "../../materialization/dataProvenance.js";
14
15
  import { validateOptions } from "./shared.js";
16
+ import { normalizeDensityPlacement } from "../../grammar/density.js";
17
+ import { scaleEditPatch } from "../scales/patch.js";
18
+ import {
19
+ requireDensityField,
20
+ resolveDensityCategoryScaleOptions,
21
+ resolveDensityPositionDefinition,
22
+ resolveDensityScaleOptions,
23
+ resolveDensityTransitionScaleDefinitions
24
+ } from "./density/resolve.js";
15
25
 
16
26
  const OPTIONS = Object.freeze([
17
27
  "field", "target", "source", "groupBy", "bandwidth", "extent", "steps",
18
28
  "kernel", "normalization", "as", "densityChannel", "coordinate",
19
- "valueScale", "densityScale"
29
+ "valueScale", "densityScale", "placement"
20
30
  ]);
21
31
  const EDIT_OPTIONS = Object.freeze([
22
- "target", "bandwidth", "extent", "steps", "kernel", "normalization"
32
+ "target", "bandwidth", "extent", "steps", "kernel", "normalization",
33
+ "placement"
23
34
  ]);
24
35
  const EDITABLE = Object.freeze(EDIT_OPTIONS.filter(option => option !== "target"));
25
36
 
@@ -42,32 +53,35 @@ function findArea(program, requested) {
42
53
  throw new Error("encodeDensity target is ambiguous; provide target.");
43
54
  }
44
55
 
45
- function requireField(value, label) {
46
- if (typeof value !== "string" || value.length === 0) {
47
- throw new TypeError(`${label} must be a non-empty string.`);
48
- }
49
- return value;
50
- }
51
-
52
- function scaleOptions(value, defaults, label) {
53
- if (value !== undefined && !isPlainObject(value)) {
54
- throw new TypeError(`${label} must be a plain object.`);
56
+ function applyDensityPosition(program, layerId, definition, groupBy) {
57
+ const withCoordinate = value => ({
58
+ target: layerId,
59
+ field: value.field,
60
+ fieldType: value.fieldType,
61
+ scale: value.scale,
62
+ ...(value.coordinate === undefined ? {} : { coordinate: value.coordinate })
63
+ });
64
+ let next = program
65
+ .encodeX(withCoordinate(definition.x))
66
+ .encodeY(withCoordinate(definition.y));
67
+ if (groupBy !== undefined) {
68
+ next = next.encodeGroup({ target: layerId, field: groupBy });
55
69
  }
56
- return { ...defaults, ...(value ?? {}) };
70
+ return next;
57
71
  }
58
72
 
59
73
  const encodeDensity = action(
60
74
  {
61
75
  op: "encodeDensity",
62
- description: "Derive and encode a baseline-oriented kernel density area."
76
+ description: "Derive and encode a baseline or categorical kernel-density area."
63
77
  },
64
78
  function (args = {}) {
65
79
  validateOptions(args, OPTIONS, "encodeDensity");
66
80
  const layer = findArea(this, args.target);
67
- const field = requireField(args.field, "Density field");
81
+ const field = requireDensityField(args.field, "Density field");
68
82
  const groupBy = args.groupBy === undefined
69
83
  ? undefined
70
- : requireField(args.groupBy, "Density groupBy");
84
+ : requireDensityField(args.groupBy, "Density groupBy");
71
85
  const source = validateUserId(
72
86
  args.source ?? layer.data,
73
87
  "Density source dataset id"
@@ -82,7 +96,8 @@ const encodeDensity = action(
82
96
  `Density area target "${layer.id}" already has positional or group encodings.`
83
97
  );
84
98
  }
85
- const densityChannel = args.densityChannel ?? "y";
99
+ const categoryPlacement = args.placement?.type === "category";
100
+ const densityChannel = args.densityChannel ?? (categoryPlacement ? "x" : "y");
86
101
  if (!["x", "y"].includes(densityChannel)) {
87
102
  throw new Error(`Unsupported densityChannel "${densityChannel}".`);
88
103
  }
@@ -94,56 +109,67 @@ const encodeDensity = action(
94
109
  if (hasDataset(this, derivedId)) {
95
110
  throw new Error(`Dataset "${derivedId}" already exists.`);
96
111
  }
97
- const valueScale = scaleOptions(
112
+ const valueScale = resolveDensityScaleOptions(
98
113
  args.valueScale,
99
114
  { nice: false, zero: false },
100
115
  "Density valueScale"
101
116
  );
102
- const densityScale = scaleOptions(
103
- args.densityScale,
104
- { nice: true, zero: true },
105
- "Density densityScale"
106
- );
107
- const xIsDensity = densityChannel === "x";
108
- const xField = output[xIsDensity ? 1 : 0];
109
- const yField = output[xIsDensity ? 0 : 1];
110
- const xScale = xIsDensity ? densityScale : valueScale;
111
- const yScale = xIsDensity ? valueScale : densityScale;
117
+ const placement = args.placement === undefined
118
+ ? undefined
119
+ : normalizeDensityPlacement(args.placement, {
120
+ densityChannel,
121
+ groupBy,
122
+ categoryField: groupBy ?? `${layer.id}DensityCategory`
123
+ });
124
+ if (placement !== undefined && args.densityScale !== undefined) {
125
+ throw new Error(
126
+ "Density category placement cannot be combined with densityScale."
127
+ );
128
+ }
129
+ const densityScale = placement === undefined
130
+ ? resolveDensityScaleOptions(
131
+ args.densityScale,
132
+ { nice: true, zero: true },
133
+ "Density densityScale"
134
+ )
135
+ : undefined;
136
+ const placementScale = placement === undefined
137
+ ? undefined
138
+ : resolveDensityCategoryScaleOptions(args.placement.scale);
112
139
 
113
- let next = this
114
- .createDensityData({
115
- id: derivedId,
116
- source,
117
- field,
118
- ...(groupBy === undefined ? {} : { groupBy }),
119
- ...(args.bandwidth === undefined ? {} : { bandwidth: args.bandwidth }),
120
- ...(args.extent === undefined ? {} : { extent: args.extent }),
121
- ...(args.steps === undefined ? {} : { steps: args.steps }),
122
- ...(args.kernel === undefined ? {} : { kernel: args.kernel }),
123
- ...(args.normalization === undefined
124
- ? {}
125
- : { normalization: args.normalization }),
126
- ...(args.as === undefined ? {} : { as: args.as })
127
- })
140
+ const dataArgs = {
141
+ id: derivedId,
142
+ source,
143
+ field,
144
+ ...(groupBy === undefined ? {} : { groupBy }),
145
+ ...(args.bandwidth === undefined ? {} : { bandwidth: args.bandwidth }),
146
+ ...(args.extent === undefined ? {} : { extent: args.extent }),
147
+ ...(args.steps === undefined ? {} : { steps: args.steps }),
148
+ ...(args.kernel === undefined ? {} : { kernel: args.kernel }),
149
+ ...(args.normalization === undefined
150
+ ? {}
151
+ : { normalization: args.normalization }),
152
+ ...(args.as === undefined ? {} : { as: args.as }),
153
+ ...(placement === undefined ? {} : { placement })
154
+ };
155
+ let next = placement === undefined
156
+ ? this.createDensityData(dataArgs)
157
+ : this.createCategoricalDensityData(dataArgs);
158
+ next = next
128
159
  .editSemantic({
129
160
  property: `layer[${layer.id}].data`,
130
161
  value: derivedId
131
- })
132
- .encodeX({
133
- target: layer.id,
134
- field: xField,
135
- ...(args.coordinate === undefined ? {} : { coordinate: args.coordinate }),
136
- scale: xScale
137
- })
138
- .encodeY({
139
- target: layer.id,
140
- field: yField,
141
- ...(args.coordinate === undefined ? {} : { coordinate: args.coordinate }),
142
- scale: yScale
143
162
  });
144
- if (groupBy !== undefined) {
145
- next = next.encodeGroup({ target: layer.id, field: groupBy });
146
- }
163
+ next = applyDensityPosition(next, layer.id, resolveDensityPositionDefinition({
164
+ layer: { densityChannel },
165
+ output,
166
+ groupBy,
167
+ placement,
168
+ coordinate: args.coordinate,
169
+ valueScale,
170
+ densityScale,
171
+ placementScale
172
+ }), groupBy);
147
173
  return next.rematerializeAreaMark({ id: layer.id });
148
174
  }
149
175
  );
@@ -164,14 +190,6 @@ function findDensityArea(program, requested) {
164
190
  });
165
191
  }
166
192
 
167
- function nextDensityRevisionId(program, target) {
168
- let revision = 1;
169
- while (hasDataset(program, `${target}DensityDataRevision${revision}`)) {
170
- revision += 1;
171
- }
172
- return `${target}DensityDataRevision${revision}`;
173
- }
174
-
175
193
  const editDensity = action(
176
194
  {
177
195
  op: "editDensity",
@@ -185,31 +203,118 @@ const editDensity = action(
185
203
  const layer = findDensityArea(this, args.target);
186
204
  const previous = findDataset(this, layer.data);
187
205
  const transform = previous.transform[0];
188
- const revisionId = nextDensityRevisionId(this, layer.id);
206
+ const requestedPlacement = Object.hasOwn(args, "placement")
207
+ ? normalizeDensityPlacement(args.placement, {
208
+ densityChannel: transform.placement?.channel ?? "x",
209
+ groupBy: transform.groupBy,
210
+ categoryField: transform.groupBy ??
211
+ transform.placement?.categoryField ??
212
+ `${layer.id}DensityCategory`
213
+ })
214
+ : transform.placement;
215
+ const colorField = layer.encoding?.color?.field;
216
+ const availableColorFields = [
217
+ transform.groupBy,
218
+ requestedPlacement?.split?.field
219
+ ].filter(Boolean);
220
+ if (colorField !== undefined && !availableColorFields.includes(colorField)) {
221
+ throw new Error(
222
+ `editDensity placement would remove color field "${colorField}" from the density series.`
223
+ );
224
+ }
225
+ const revision = planDerivedDataRevision(this, {
226
+ owner: layer.id,
227
+ role: "DensityData",
228
+ previous: previous.id,
229
+ consumers: [layer.id]
230
+ });
189
231
  const option = property => Object.hasOwn(args, property)
190
232
  ? args[property]
191
233
  : transform[property];
192
234
 
193
- let next = this
194
- .createDensityData({
195
- id: revisionId,
196
- source: previous.source,
197
- field: transform.field,
198
- ...(transform.groupBy === undefined
199
- ? {}
200
- : { groupBy: transform.groupBy }),
201
- bandwidth: option("bandwidth"),
202
- extent: option("extent"),
203
- steps: option("steps"),
204
- kernel: option("kernel") ?? "gaussian",
205
- normalization: option("normalization") ?? "unit",
206
- as: transform.as
207
- })
208
- .editSemantic({
209
- property: `layer[${layer.id}].data`,
210
- value: revisionId
211
- })
212
- .releaseDerivedData({ id: previous.id });
235
+ const dataArgs = {
236
+ id: revision.id,
237
+ source: previous.source,
238
+ field: transform.field,
239
+ ...(transform.groupBy === undefined
240
+ ? {}
241
+ : { groupBy: transform.groupBy }),
242
+ bandwidth: option("bandwidth"),
243
+ extent: option("extent"),
244
+ steps: option("steps"),
245
+ kernel: option("kernel") ?? "gaussian",
246
+ normalization: option("normalization") ?? "unit",
247
+ as: transform.as,
248
+ ...(requestedPlacement === undefined
249
+ ? {}
250
+ : { placement: requestedPlacement })
251
+ };
252
+ let next = requestedPlacement === undefined
253
+ ? this.createDensityData(dataArgs)
254
+ : this.createCategoricalDensityData(dataArgs);
255
+ next = next.rebindLayerData(revision.rebinds[0])
256
+ .releaseDerivedData(revision.release);
257
+
258
+ const changesPlacementMode = Object.hasOwn(args, "placement") &&
259
+ (transform.placement === undefined) !== (requestedPlacement === undefined);
260
+ if (changesPlacementMode) {
261
+ const xScaleId = layer.encoding?.x?.scale;
262
+ const yScaleId = layer.encoding?.y?.scale;
263
+ const scaleDefinitions = resolveDensityTransitionScaleDefinitions(
264
+ this,
265
+ layer,
266
+ transform,
267
+ requestedPlacement,
268
+ args.placement
269
+ );
270
+ next = next
271
+ .editSemantic({ property: `layer[${layer.id}].encoding.x`, remove: true })
272
+ .editSemantic({ property: `layer[${layer.id}].encoding.y`, remove: true });
273
+ next = next
274
+ .editScale(scaleEditPatch(xScaleId, scaleDefinitions.x))
275
+ .editScale(scaleEditPatch(yScaleId, scaleDefinitions.y));
276
+ const densityChannel = requestedPlacement?.channel ?? (
277
+ layer.encoding.x.field === transform.as[1] ? "x" : "y"
278
+ );
279
+ const definition = resolveDensityPositionDefinition({
280
+ layer: { densityChannel },
281
+ output: transform.as,
282
+ groupBy: transform.groupBy,
283
+ placement: requestedPlacement,
284
+ coordinate: layer.coordinate,
285
+ valueScale: {
286
+ id: requestedPlacement?.channel === "x" ? yScaleId : xScaleId,
287
+ ...scaleDefinitions[requestedPlacement?.channel === "x" ? "y" : "x"]
288
+ },
289
+ densityScale: requestedPlacement === undefined
290
+ ? {
291
+ id: densityChannel === "x" ? xScaleId : yScaleId,
292
+ ...scaleDefinitions[densityChannel]
293
+ }
294
+ : undefined,
295
+ placementScale: requestedPlacement === undefined
296
+ ? undefined
297
+ : {
298
+ id: requestedPlacement.channel === "x" ? xScaleId : yScaleId,
299
+ ...scaleDefinitions[requestedPlacement.channel]
300
+ }
301
+ });
302
+ next = applyDensityPosition(next, layer.id, definition);
303
+ } else if (
304
+ requestedPlacement !== undefined &&
305
+ Object.hasOwn(args.placement ?? {}, "scale")
306
+ ) {
307
+ const channel = requestedPlacement.channel;
308
+ const scaleId = layer.encoding[channel].scale;
309
+ const requestedScale = resolveDensityCategoryScaleOptions(
310
+ args.placement.scale,
311
+ scaleId
312
+ );
313
+ if (requestedScale.id !== scaleId) {
314
+ throw new Error("editDensity placement scale cannot change its id.");
315
+ }
316
+ next = next.editScale(scaleEditPatch(scaleId, requestedScale));
317
+ }
213
318
 
214
319
  next = applyMaterializationPlan(
215
320
  next,
@@ -0,0 +1,283 @@
1
+ import { validateUserId } from "../../../core/identifiers.js";
2
+ import { isPlainObject } from "../../../core/immutable.js";
3
+ import { validateOptionObject } from "../../../core/validation.js";
4
+ import { resolvePalette } from "../../../grammar/palettes.js";
5
+ import {
6
+ normalizeTemporalValue,
7
+ readNominalField
8
+ } from "../../../grammar/scales/index.js";
9
+ import {
10
+ findDataset,
11
+ findSemanticScale,
12
+ hasDataset,
13
+ resolveEligibleLayer
14
+ } from "../../../selectors/index.js";
15
+
16
+ export function findHorizonArea(program, requested) {
17
+ const target = requested === undefined
18
+ ? undefined
19
+ : validateUserId(requested, "Horizon target id");
20
+ return resolveEligibleLayer(program, {
21
+ target,
22
+ label: "Horizon area",
23
+ predicate: layer => layer.mark?.type === "area"
24
+ });
25
+ }
26
+
27
+ export function resolveHorizonSource(program, layer, requested) {
28
+ const id = validateUserId(
29
+ requested ?? layer.data ?? program.context.currentData,
30
+ "Horizon source dataset id"
31
+ );
32
+ if (!hasDataset(program, id)) {
33
+ throw new Error(`Unknown Horizon source dataset "${id}".`);
34
+ }
35
+ return findDataset(program, id);
36
+ }
37
+
38
+ function fieldOption(value, role) {
39
+ if (typeof value === "string") return { field: value };
40
+ if (!isPlainObject(value)) {
41
+ throw new TypeError(`Horizon ${role} must be a field string or plain object.`);
42
+ }
43
+ validateOptionObject(
44
+ value,
45
+ ["field", "fieldType", "scale"],
46
+ `Horizon ${role}`
47
+ );
48
+ return value;
49
+ }
50
+
51
+ function inferredFieldType(rows, field, role) {
52
+ if (role === "y") {
53
+ const valid = rows.filter(row => Number.isFinite(row?.[field]));
54
+ if (valid.length === 0) {
55
+ throw new TypeError(`Horizon y field "${field}" requires a finite value.`);
56
+ }
57
+ return "quantitative";
58
+ }
59
+ if (rows.every(row => Number.isFinite(row?.[field]))) return "quantitative";
60
+ for (const [index, row] of rows.entries()) {
61
+ normalizeTemporalValue(row?.[field], field, index);
62
+ }
63
+ return "temporal";
64
+ }
65
+
66
+ function compatibleEncoding(encoding, role) {
67
+ return encoding?.field !== undefined && (
68
+ role === "x"
69
+ ? ["quantitative", "temporal"].includes(encoding.fieldType)
70
+ : encoding.fieldType === "quantitative"
71
+ );
72
+ }
73
+
74
+ function uniqueStoredEncoding(program, dataset, role) {
75
+ const candidates = program.semanticSpec.layers
76
+ .filter(layer => layer.data === dataset.id)
77
+ .map(layer => layer.encoding?.[role])
78
+ .filter(encoding => compatibleEncoding(encoding, role));
79
+ const unique = new Map(candidates.map(encoding => [
80
+ JSON.stringify([
81
+ encoding.field,
82
+ encoding.fieldType,
83
+ encoding.scale,
84
+ encoding.title
85
+ ]),
86
+ encoding
87
+ ]));
88
+ if (unique.size > 1) {
89
+ throw new Error(`Horizon ${role} inference is ambiguous; provide ${role}.`);
90
+ }
91
+ return [...unique.values()][0];
92
+ }
93
+
94
+ export function resolveHorizonField(program, layer, dataset, requested, role) {
95
+ const existing = compatibleEncoding(layer.encoding?.[role], role)
96
+ ? layer.encoding[role]
97
+ : undefined;
98
+ if (requested === undefined) {
99
+ const inferred = existing ?? uniqueStoredEncoding(program, dataset, role);
100
+ if (inferred === undefined) {
101
+ throw new Error(`encodeHorizon requires ${role} or one inferable ${role} encoding.`);
102
+ }
103
+ return {
104
+ field: inferred.field,
105
+ fieldType: inferred.fieldType,
106
+ ...(role === "x" && inferred.scale !== undefined
107
+ ? { scale: { id: inferred.scale } }
108
+ : {}),
109
+ ...(inferred.title === undefined ? {} : { title: inferred.title })
110
+ };
111
+ }
112
+ const option = fieldOption(requested, role);
113
+ if (typeof option.field !== "string" || option.field.length === 0) {
114
+ throw new TypeError(`Horizon ${role} field must be a non-empty string.`);
115
+ }
116
+ const matching = existing?.field === option.field ? existing : undefined;
117
+ if (option.scale !== undefined && !isPlainObject(option.scale)) {
118
+ throw new TypeError(`Horizon ${role} scale must be a plain object.`);
119
+ }
120
+ const fieldType = option.fieldType ?? matching?.fieldType ??
121
+ inferredFieldType(dataset.values, option.field, role);
122
+ if (
123
+ (role === "x" && !["quantitative", "temporal"].includes(fieldType)) ||
124
+ (role === "y" && fieldType !== "quantitative")
125
+ ) {
126
+ throw new Error(
127
+ `Horizon ${role} fieldType must be ${role === "x" ? "quantitative or temporal" : "quantitative"}.`
128
+ );
129
+ }
130
+ return {
131
+ field: option.field,
132
+ fieldType,
133
+ ...(option.scale !== undefined
134
+ ? { scale: option.scale }
135
+ : role === "x" && matching?.scale !== undefined
136
+ ? { scale: { id: matching.scale } }
137
+ : {}),
138
+ ...(matching?.title === undefined ? {} : { title: matching.title })
139
+ };
140
+ }
141
+
142
+ export function resolveHorizonGroupBy(layer, dataset, requested) {
143
+ if (requested === false) {
144
+ throw new Error("encodeHorizon groupBy must be a field string when provided.");
145
+ }
146
+ const value = requested ?? layer.encoding?.group?.field;
147
+ if (value === undefined) return undefined;
148
+ if (typeof value !== "string" || value.length === 0) {
149
+ throw new TypeError("Horizon groupBy must be a non-empty string.");
150
+ }
151
+ readNominalField(dataset.values, value);
152
+ return value;
153
+ }
154
+
155
+ export function horizonOutputFields(target) {
156
+ const prefix = `${target}Horizon`;
157
+ return {
158
+ x: `${prefix}X`, lower: `${prefix}Lower`, upper: `${prefix}Upper`,
159
+ group: `${prefix}Group`, color: `${prefix}Color`, sign: `${prefix}Sign`,
160
+ band: `${prefix}Band`, segment: `${prefix}Segment`
161
+ };
162
+ }
163
+
164
+ export function resolveHorizonPaletteColors(transform) {
165
+ const domain = [];
166
+ const range = [];
167
+ for (const sign of ["negative", "positive"]) {
168
+ const palette = transform.palette[sign];
169
+ const dense = palette.count === undefined && transform.bands > 1
170
+ ? resolvePalette({ ...palette, count: transform.bands * 2 }, transform.bands * 2)
171
+ : resolvePalette(palette, transform.bands);
172
+ const colors = palette.count === undefined && transform.bands > 1
173
+ ? Array.from({ length: transform.bands }, (_, index) =>
174
+ index === transform.bands - 1 ? dense.at(-1) : dense[index * 2])
175
+ : dense;
176
+ for (let band = 0; band < transform.bands; band += 1) {
177
+ domain.push(`${sign}:${band}`);
178
+ range.push(colors[band]);
179
+ }
180
+ }
181
+ return { domain, range };
182
+ }
183
+
184
+ export function resolveFoldedHorizonScaleOptions(target, requested) {
185
+ if (requested !== undefined && !isPlainObject(requested)) {
186
+ throw new TypeError("Horizon y scale must be a plain object.");
187
+ }
188
+ const options = requested ?? {};
189
+ if (
190
+ options.domain !== undefined &&
191
+ (!Array.isArray(options.domain) ||
192
+ options.domain.length !== 2 ||
193
+ options.domain[0] !== 0 ||
194
+ options.domain[1] !== 1)
195
+ ) {
196
+ throw new Error("Horizon y scale domain must be [0, 1].");
197
+ }
198
+ if (options.type !== undefined && options.type !== "linear") {
199
+ throw new Error('Horizon y scale type must be "linear".');
200
+ }
201
+ return {
202
+ ...options,
203
+ id: options.id ?? `${target}HorizonAmplitude`,
204
+ type: "linear",
205
+ domain: [0, 1],
206
+ nice: false,
207
+ zero: true
208
+ };
209
+ }
210
+
211
+ export function inferHorizonTitle(field) {
212
+ const words = field.replaceAll("_", " ");
213
+ return words.length === 0 ? field : words[0].toUpperCase() + words.slice(1);
214
+ }
215
+
216
+ export function validateUnusedHorizonEncodings(layer) {
217
+ const conflicts = ["x2", "y2", "color", "pathOrder"].filter(
218
+ channel => layer.encoding?.[channel] !== undefined
219
+ );
220
+ if (conflicts.length > 0) {
221
+ throw new Error(
222
+ `Horizon area target "${layer.id}" already has unsupported ${conflicts.join(", ")} encoding.`
223
+ );
224
+ }
225
+ }
226
+
227
+ export function findExistingHorizonScale(program, id, label) {
228
+ const scale = findSemanticScale(program, id);
229
+ if (scale === undefined) throw new Error(`${label} scale "${id}" is missing.`);
230
+ return scale;
231
+ }
232
+
233
+ export function resolveEditedHorizonField(
234
+ program,
235
+ layer,
236
+ source,
237
+ transform,
238
+ requested,
239
+ role
240
+ ) {
241
+ if (requested === undefined) {
242
+ return {
243
+ ...transform[role],
244
+ ...(role === "x" ? { title: layer.encoding.x.title } : {})
245
+ };
246
+ }
247
+ const prior = transform[role];
248
+ return resolveHorizonField(program, {
249
+ ...layer,
250
+ encoding: {
251
+ ...layer.encoding,
252
+ [role]: {
253
+ field: prior.field,
254
+ fieldType: prior.fieldType,
255
+ scale: layer.encoding[role].scale,
256
+ ...(role === "x" && layer.encoding.x.title !== undefined
257
+ ? { title: layer.encoding.x.title }
258
+ : {})
259
+ }
260
+ }
261
+ }, source, requested, role);
262
+ }
263
+
264
+ export function resolveEditedHorizonGroupBy(source, previous, requested) {
265
+ if (requested === undefined) return previous;
266
+ if (requested === false) return undefined;
267
+ if (typeof requested !== "string" || requested.length === 0) {
268
+ throw new TypeError("Horizon groupBy must be a field string or false.");
269
+ }
270
+ readNominalField(source.values, requested);
271
+ return requested;
272
+ }
273
+
274
+ export function mergeHorizonPalette(previous, requested) {
275
+ if (requested === undefined) return previous;
276
+ if (!isPlainObject(requested)) {
277
+ throw new TypeError("Horizon palette must be a plain object.");
278
+ }
279
+ return {
280
+ positive: requested.positive ?? previous.positive,
281
+ negative: requested.negative ?? previous.negative
282
+ };
283
+ }