ggaction 0.0.3 → 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 (223) hide show
  1. package/CHANGELOG.md +58 -3
  2. package/README.md +117 -83
  3. package/package.json +27 -11
  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/regression.js +1 -1
  26. package/src/actions/data/window.js +50 -0
  27. package/src/actions/encodings/appearance.js +1 -1
  28. package/src/actions/encodings/color/continuous.js +137 -0
  29. package/src/actions/encodings/color/index.js +167 -0
  30. package/src/actions/encodings/color/layout.js +99 -0
  31. package/src/actions/encodings/color/policy.js +88 -0
  32. package/src/actions/encodings/density/resolve.js +109 -0
  33. package/src/actions/encodings/density.js +193 -88
  34. package/src/actions/encodings/horizon/resolve.js +283 -0
  35. package/src/actions/encodings/horizon.js +364 -0
  36. package/src/actions/encodings/index.js +8 -2
  37. package/src/actions/encodings/offset.js +1 -1
  38. package/src/actions/encodings/parallel.js +115 -0
  39. package/src/actions/encodings/pathOrder.js +131 -0
  40. package/src/actions/encodings/position/apply.js +73 -4
  41. package/src/actions/encodings/{position.js → position/index.js} +21 -8
  42. package/src/actions/encodings/position/policies/arc.js +27 -4
  43. package/src/actions/encodings/position/policies/area.js +33 -2
  44. package/src/actions/encodings/position/policies/index.js +7 -2
  45. package/src/actions/encodings/position/policies/line.js +18 -2
  46. package/src/actions/encodings/position/resolve.js +25 -3
  47. package/src/actions/encodings/ranged.js +5 -3
  48. package/src/actions/encodings/ruleAppearance.js +88 -10
  49. package/src/actions/encodings/strokeDash.js +1 -1
  50. package/src/actions/encodings/text.js +1 -1
  51. package/src/actions/errorBands/options.js +1 -1
  52. package/src/actions/errorBars/options.js +1 -1
  53. package/src/actions/facets/actions.js +2 -3
  54. package/src/actions/facets/derive.js +35 -5
  55. package/src/actions/facets/guides.js +1 -1
  56. package/src/actions/facets/replay.js +7 -36
  57. package/src/actions/gradientPlots/components.js +177 -0
  58. package/src/actions/gradientPlots/create.js +96 -0
  59. package/src/actions/gradientPlots/edit.js +153 -0
  60. package/src/actions/gradientPlots/index.js +24 -0
  61. package/src/actions/gradientPlots/materialize.js +287 -0
  62. package/src/actions/gradientPlots/options.js +120 -0
  63. package/src/actions/gradientPlots/paint.js +107 -0
  64. package/src/actions/gradientPlots/rebind.js +51 -0
  65. package/src/actions/gradientPlots/resolve.js +81 -0
  66. package/src/actions/guides/applicability.js +44 -17
  67. package/src/actions/guides/axes/axes.js +37 -3
  68. package/src/actions/guides/axes/index.js +2 -0
  69. package/src/actions/guides/axes/labels.js +12 -7
  70. package/src/actions/guides/axes/parallel/resolve.js +86 -0
  71. package/src/actions/guides/axes/parallel.js +228 -0
  72. package/src/actions/guides/axes/ticks.js +1 -1
  73. package/src/actions/guides/axes/titles.js +12 -9
  74. package/src/actions/guides/grids/resolve.js +1 -1
  75. package/src/actions/guides/guides.js +60 -5
  76. package/src/actions/guides/legends/categorical/actions.js +48 -11
  77. package/src/actions/guides/legends/categorical/layout.js +4 -2
  78. package/src/actions/guides/legends/continuous/interval.js +1 -1
  79. package/src/actions/guides/legends/continuous/opacity.js +1 -1
  80. package/src/actions/guides/legends/edit.js +7 -20
  81. package/src/actions/guides/legends/index.js +2 -0
  82. package/src/actions/guides/legends/remove.js +6 -31
  83. package/src/actions/guides/legends/size.js +1 -1
  84. package/src/actions/guides/legends/strokeWidth.js +170 -0
  85. package/src/actions/guides/legends/target.js +25 -0
  86. package/src/actions/guides/polar/{axes.js → axes/index.js} +5 -5
  87. package/src/actions/guides/polar/index.js +1 -1
  88. package/src/actions/guides/polar/resolve.js +1 -1
  89. package/src/actions/guides/tickValues.js +1 -1
  90. package/src/actions/index.js +6 -0
  91. package/src/actions/intervals/resolve.js +1 -1
  92. package/src/actions/marks/arc/actions.js +2 -2
  93. package/src/actions/marks/area/actions.js +61 -137
  94. package/src/actions/marks/area/materialize.js +163 -0
  95. package/src/actions/marks/bar/edit.js +1 -1
  96. package/src/actions/marks/index.js +5 -1
  97. package/src/actions/marks/line/actions.js +100 -81
  98. package/src/actions/marks/line/materialize.js +146 -0
  99. package/src/actions/marks/point/create.js +76 -0
  100. package/src/actions/marks/point/edit.js +85 -0
  101. package/src/actions/marks/point/index.js +11 -1
  102. package/src/actions/marks/point/jitter.js +72 -0
  103. package/src/actions/marks/point/{actions.js → materialize.js} +175 -170
  104. package/src/actions/marks/rect/actions.js +4 -1
  105. package/src/actions/marks/remove.js +13 -2
  106. package/src/actions/marks/rule/actions.js +26 -4
  107. package/src/actions/marks/shared.js +1 -1
  108. package/src/actions/marks/text/actions.js +20 -5
  109. package/src/actions/marks/text/index.js +7 -1
  110. package/src/actions/marks/text/layout.js +321 -0
  111. package/src/actions/primitives/semanticValidation/dataset.js +22 -0
  112. package/src/actions/primitives/semanticValidation/guide.js +43 -0
  113. package/src/actions/primitives/semanticValidation/index.js +23 -0
  114. package/src/actions/primitives/semanticValidation/layer.js +74 -0
  115. package/src/actions/primitives/semanticValidation/scale.js +74 -0
  116. package/src/actions/primitives/semanticValidation/shared.js +5 -0
  117. package/src/actions/primitives/semanticValue.js +1 -204
  118. package/src/actions/regression/components.js +1 -1
  119. package/src/actions/regression/create.js +1 -1
  120. package/src/actions/regression/edit.js +20 -22
  121. package/src/actions/scales/consumers/common.js +109 -0
  122. package/src/actions/scales/consumers/families.js +58 -0
  123. package/src/actions/scales/consumers/index.js +41 -0
  124. package/src/actions/scales/{consumers.js → consumers/seriesLayout.js} +10 -132
  125. package/src/actions/scales/create.js +1 -1
  126. package/src/actions/scales/definitions.js +32 -2
  127. package/src/actions/scales/edit.js +8 -206
  128. package/src/actions/scales/editPolicy.js +214 -0
  129. package/src/actions/scales/materialize.js +3 -3
  130. package/src/actions/scales/patch.js +26 -0
  131. package/src/actions/selection/actions.js +28 -3
  132. package/src/actions/violinPlots/create.js +198 -0
  133. package/src/actions/violinPlots/index.js +5 -0
  134. package/src/core/ChartProgram.js +22 -236
  135. package/src/core/compositionState.js +169 -0
  136. package/src/core/materializationState.js +65 -0
  137. package/src/core/programState.js +28 -0
  138. package/src/core/textMetrics.js +47 -0
  139. package/src/core/vocabulary.js +7 -2
  140. package/src/grammar/arcs.js +50 -13
  141. package/src/grammar/areaSeries.js +42 -6
  142. package/src/grammar/bars/aggregate.js +1 -1
  143. package/src/grammar/bin2d.js +333 -0
  144. package/src/grammar/categoricalDensity.js +192 -0
  145. package/src/grammar/coordinates.js +1 -1
  146. package/src/grammar/curveCommands.js +3 -1
  147. package/src/grammar/density.js +253 -27
  148. package/src/grammar/facets/dependencies.js +3 -11
  149. package/src/grammar/{facets.js → facets/index.js} +8 -6
  150. package/src/grammar/gradientProfile.js +213 -0
  151. package/src/grammar/horizon.js +455 -0
  152. package/src/grammar/interval.js +2 -96
  153. package/src/grammar/jitter.js +197 -0
  154. package/src/grammar/lineSeries.js +70 -1
  155. package/src/grammar/paint.js +88 -0
  156. package/src/grammar/palettes.js +5 -7
  157. package/src/grammar/parallelCoordinates.js +213 -0
  158. package/src/grammar/pathOrder.js +35 -0
  159. package/src/grammar/pointShapes.js +24 -0
  160. package/src/grammar/polarLineCommands.js +1 -1
  161. package/src/grammar/regression/derive.js +97 -0
  162. package/src/grammar/regression/index.js +22 -0
  163. package/src/grammar/regression/models.js +282 -0
  164. package/src/grammar/regression/parameters.js +106 -0
  165. package/src/grammar/rules.js +2 -1
  166. package/src/grammar/scales/appearance.js +21 -0
  167. package/src/grammar/scales/color.js +4 -2
  168. package/src/grammar/schemas/concreteGraphic.js +11 -1
  169. package/src/grammar/schemas/graphicBounds.js +19 -37
  170. package/src/grammar/schemas/semanticPath.js +17 -2
  171. package/src/grammar/statistics/studentT.js +98 -0
  172. package/src/grammar/transforms.js +101 -12
  173. package/src/grammar/window.js +339 -0
  174. package/src/layout/labels.js +270 -0
  175. package/src/layout/text.js +4 -1
  176. package/src/layout/title.js +10 -20
  177. package/src/materialization/bars/aggregate.js +1 -1
  178. package/src/materialization/bars/grouped.js +1 -1
  179. package/src/materialization/bars/histogram.js +1 -1
  180. package/src/materialization/bars/ranged.js +1 -1
  181. package/src/materialization/dataProvenance.js +56 -2
  182. package/src/materialization/density.js +1 -1
  183. package/src/materialization/dependencies.js +15 -101
  184. package/src/materialization/encodings.js +2 -2
  185. package/src/materialization/facetGuides/index.js +2 -0
  186. package/src/materialization/facetGuides/legacyCategorical.js +125 -0
  187. package/src/materialization/facetGuides/placement.js +126 -0
  188. package/src/materialization/facetGuides/preparation.js +188 -0
  189. package/src/materialization/facets.js +1 -1
  190. package/src/materialization/guides/resources.js +74 -24
  191. package/src/materialization/horizon.js +29 -0
  192. package/src/materialization/layout.js +15 -0
  193. package/src/materialization/marks/capabilities.js +161 -0
  194. package/src/materialization/marks/index.js +108 -0
  195. package/src/materialization/marks/pathOrder.js +71 -0
  196. package/src/materialization/marks/policies.js +91 -0
  197. package/src/materialization/rect.js +1 -1
  198. package/src/materialization/rowEncoding.js +1 -1
  199. package/src/materialization/scaleGuideDependencies.js +102 -0
  200. package/src/materialization/scales/map.js +1 -1
  201. package/src/materialization/scales/policies/series.js +1 -1
  202. package/src/materialization/scales/resolve.js +3 -1
  203. package/src/materialization/selection/items/bar.js +1 -1
  204. package/src/materialization/selection/items/path.js +27 -1
  205. package/src/materialization/selection/items/point.js +1 -5
  206. package/src/materialization/selection/items/rect.js +21 -0
  207. package/src/materialization/selection/styles.js +21 -10
  208. package/src/materialization/text.js +12 -4
  209. package/src/renderers/canvas/fill.js +33 -0
  210. package/src/renderers/canvas/index.js +7 -0
  211. package/src/renderers/canvas/path.js +7 -4
  212. package/src/renderers/canvas/rect.js +9 -3
  213. package/src/renderers/canvas/text.js +14 -1
  214. package/src/selectors/datasets.js +4 -0
  215. package/src/theme/defaults.js +4 -0
  216. package/types/extension.d.ts +10 -1
  217. package/types/index.d.ts +76 -0
  218. package/types/program.d.ts +632 -10
  219. package/src/actions/encodings/color.js +0 -424
  220. package/src/grammar/regression.js +0 -578
  221. package/src/grammar/scales.js +0 -1
  222. package/src/materialization/facetGuides.js +0 -426
  223. package/src/materialization/marks.js +0 -316
@@ -0,0 +1,213 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import {
3
+ deriveKernelDensity,
4
+ validateDensityKernel,
5
+ validateDensityNormalization
6
+ } from "./density.js";
7
+
8
+ export const GRADIENT_PROFILE_FIELDS = Object.freeze({
9
+ category: "__gradientPlot_category",
10
+ values: "__gradientPlot_values",
11
+ intensities: "__gradientPlot_intensities",
12
+ lower: "__gradientPlot_lower",
13
+ upper: "__gradientPlot_upper",
14
+ center: "__gradientPlot_center",
15
+ count: "__gradientPlot_count"
16
+ });
17
+
18
+ const TRANSFORM_KEYS = Object.freeze([
19
+ "type", "category", "field", "bandwidth", "extent", "steps", "kernel",
20
+ "normalization", "center", "as", "resolve", "resolved"
21
+ ]);
22
+ const OUTPUT_KEYS = Object.freeze(Object.keys(GRADIENT_PROFILE_FIELDS));
23
+
24
+ function requireField(value, label) {
25
+ if (typeof value !== "string" || value.length === 0) {
26
+ throw new TypeError(`${label} must be a non-empty string.`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function validatePair(value, label) {
32
+ if (
33
+ !Array.isArray(value) || value.length !== 2 ||
34
+ !value.every(Number.isFinite) || value[0] >= value[1]
35
+ ) {
36
+ throw new RangeError(`${label} must be an ascending pair of finite numbers.`);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function validateOutputs(as, { category, field }) {
42
+ if (!isPlainObject(as)) {
43
+ throw new TypeError("Gradient profile as must be a plain object.");
44
+ }
45
+ const keys = Object.keys(as);
46
+ if (
47
+ keys.length !== OUTPUT_KEYS.length ||
48
+ !OUTPUT_KEYS.every(key => Object.hasOwn(as, key))
49
+ ) {
50
+ throw new Error(`Gradient profile as requires exactly ${OUTPUT_KEYS.join(", ")}.`);
51
+ }
52
+ const values = OUTPUT_KEYS.map(key =>
53
+ requireField(as[key], `Gradient profile as.${key}`)
54
+ );
55
+ if (new Set(values).size !== values.length) {
56
+ throw new Error("Gradient profile output fields must be distinct.");
57
+ }
58
+ const inputCollisions = values.filter(value => value === category || value === field);
59
+ if (
60
+ inputCollisions.some(value => value !== category) ||
61
+ as.category !== category ||
62
+ values.slice(1).includes(category)
63
+ ) {
64
+ throw new Error("Gradient profile output fields must not collide with input fields.");
65
+ }
66
+ }
67
+
68
+ export function validateGradientProfileTransform(value) {
69
+ if (!isPlainObject(value)) {
70
+ throw new TypeError("Gradient profile transform must be a plain object.");
71
+ }
72
+ const unknown = Object.keys(value).find(key => !TRANSFORM_KEYS.includes(key));
73
+ if (unknown !== undefined) {
74
+ throw new Error(`Unknown gradient profile transform property "${unknown}".`);
75
+ }
76
+ if (value.type !== "gradientProfile") {
77
+ throw new Error(`Unsupported gradient profile transform "${value.type}".`);
78
+ }
79
+ const category = requireField(value.category, "Gradient profile category");
80
+ const field = requireField(value.field, "Gradient profile field");
81
+ if (category === field) {
82
+ throw new Error("Gradient profile category and field must be distinct.");
83
+ }
84
+ if (
85
+ value.bandwidth !== "auto" &&
86
+ (!Number.isFinite(value.bandwidth) || value.bandwidth <= 0)
87
+ ) {
88
+ throw new RangeError(
89
+ "Gradient profile bandwidth must be a positive finite number or auto."
90
+ );
91
+ }
92
+ if (value.extent !== "auto") {
93
+ validatePair(value.extent, "Gradient profile extent");
94
+ }
95
+ if (!Number.isInteger(value.steps) || value.steps < 2) {
96
+ throw new RangeError("Gradient profile steps must be an integer of at least 2.");
97
+ }
98
+ validateDensityKernel(value.kernel);
99
+ validateDensityNormalization(value.normalization);
100
+ if (!["mean", "median"].includes(value.center)) {
101
+ throw new Error(`Unsupported gradient profile center "${value.center}".`);
102
+ }
103
+ if (value.resolve !== "shared") {
104
+ throw new Error(`Unsupported gradient profile resolve "${value.resolve}".`);
105
+ }
106
+ validateOutputs(value.as, { category, field });
107
+ if (value.resolved !== undefined) {
108
+ const resolved = value.resolved;
109
+ if (
110
+ !isPlainObject(resolved) ||
111
+ Object.keys(resolved).some(key => ![
112
+ "bandwidth", "extent", "intensityDomain"
113
+ ].includes(key)) ||
114
+ !Number.isFinite(resolved.bandwidth) || resolved.bandwidth <= 0
115
+ ) {
116
+ throw new TypeError("Gradient profile resolved provenance is invalid.");
117
+ }
118
+ validatePair(resolved.extent, "Gradient profile resolved extent");
119
+ validatePair(
120
+ resolved.intensityDomain,
121
+ "Gradient profile resolved intensityDomain"
122
+ );
123
+ }
124
+ return value;
125
+ }
126
+
127
+ export function normalizeGradientProfileTransform(options = {}) {
128
+ const transform = {
129
+ type: "gradientProfile",
130
+ category: options.category,
131
+ field: options.field,
132
+ bandwidth: options.bandwidth ?? "auto",
133
+ extent: options.extent ?? "auto",
134
+ steps: options.steps ?? 64,
135
+ kernel: options.kernel ?? "gaussian",
136
+ normalization: options.normalization ?? "unit",
137
+ center: options.center ?? "median",
138
+ as: options.as ?? { ...GRADIENT_PROFILE_FIELDS, category: options.category },
139
+ resolve: "shared"
140
+ };
141
+ validateGradientProfileTransform(transform);
142
+ return cloneAndFreeze(transform);
143
+ }
144
+
145
+ export function requestedGradientProfileTransform(transform) {
146
+ const { resolved: _resolved, ...requested } = transform;
147
+ return cloneAndFreeze(requested);
148
+ }
149
+
150
+ function centerOf(values, type) {
151
+ if (type === "mean") {
152
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
153
+ }
154
+ const sorted = [...values].sort((left, right) => left - right);
155
+ const middle = (sorted.length - 1) / 2;
156
+ const lower = Math.floor(middle);
157
+ const upper = Math.ceil(middle);
158
+ return (sorted[lower] + sorted[upper]) / 2;
159
+ }
160
+
161
+ export function deriveGradientProfiles(rows, transform) {
162
+ if (!Array.isArray(rows)) {
163
+ throw new TypeError("Gradient profile rows must be an array.");
164
+ }
165
+ validateGradientProfileTransform(transform);
166
+ const density = deriveKernelDensity(rows, {
167
+ field: transform.field,
168
+ groupBy: transform.category,
169
+ bandwidth: transform.bandwidth,
170
+ extent: transform.extent,
171
+ steps: transform.steps,
172
+ kernel: transform.kernel,
173
+ normalization: transform.normalization,
174
+ as: ["__gradientProfile_value", "__gradientProfile_intensity"]
175
+ });
176
+ const validRows = rows.filter(row =>
177
+ row !== null && typeof row === "object" &&
178
+ row[transform.category] !== undefined && row[transform.category] !== null &&
179
+ row[transform.category] !== "" && Number.isFinite(row[transform.field])
180
+ );
181
+ const maximumIntensity = Math.max(
182
+ ...density.values.map(row => row.__gradientProfile_intensity)
183
+ );
184
+ if (!Number.isFinite(maximumIntensity) || maximumIntensity <= 0) {
185
+ throw new Error("Gradient profile requires a positive resolved density range.");
186
+ }
187
+ const values = density.groups.map(category => {
188
+ const samples = density.values.filter(
189
+ row => row[transform.category] === category
190
+ );
191
+ const observations = validRows
192
+ .filter(row => row[transform.category] === category)
193
+ .map(row => row[transform.field]);
194
+ return {
195
+ [transform.as.category]: category,
196
+ [transform.as.values]: samples.map(row => row.__gradientProfile_value),
197
+ [transform.as.intensities]: samples.map(
198
+ row => row.__gradientProfile_intensity
199
+ ),
200
+ [transform.as.lower]: density.extent[0],
201
+ [transform.as.upper]: density.extent[1],
202
+ [transform.as.center]: centerOf(observations, transform.center),
203
+ [transform.as.count]: observations.length
204
+ };
205
+ });
206
+ return cloneAndFreeze({
207
+ values,
208
+ categories: density.groups,
209
+ bandwidth: density.bandwidth,
210
+ extent: density.extent,
211
+ intensityDomain: [0, maximumIntensity]
212
+ });
213
+ }
@@ -0,0 +1,455 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import { normalizePalette } from "./palettes.js";
3
+ import {
4
+ isNominalValue,
5
+ normalizeTemporalValue
6
+ } from "./scales/index.js";
7
+
8
+ export const HORIZON_RESOLUTIONS = Object.freeze(["shared", "independent"]);
9
+ export const HORIZON_MISSING_POLICIES = Object.freeze(["break", "error"]);
10
+ export const HORIZON_OVERFLOW_POLICIES = Object.freeze(["clip", "error"]);
11
+
12
+ const ENCODING_PROPERTIES = Object.freeze(["field", "fieldType"]);
13
+ const OUTPUT_PROPERTIES = Object.freeze([
14
+ "x", "lower", "upper", "group", "color", "sign", "band", "segment"
15
+ ]);
16
+
17
+ function requireField(value, label) {
18
+ if (typeof value !== "string" || value.length === 0) {
19
+ throw new TypeError(`${label} must be a non-empty string.`);
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function validateEncoding(value, role) {
25
+ if (!isPlainObject(value)) {
26
+ throw new TypeError(`Horizon ${role} encoding must be a plain object.`);
27
+ }
28
+ const unknown = Object.keys(value).find(
29
+ property => !ENCODING_PROPERTIES.includes(property)
30
+ );
31
+ if (unknown !== undefined) {
32
+ throw new Error(`Unknown Horizon ${role} encoding property "${unknown}".`);
33
+ }
34
+ const field = requireField(value.field, `Horizon ${role} field`);
35
+ const fieldType = value.fieldType ?? (role === "x" ? "quantitative" : "quantitative");
36
+ const supported = role === "x"
37
+ ? ["quantitative", "temporal"]
38
+ : ["quantitative"];
39
+ if (!supported.includes(fieldType)) {
40
+ throw new Error(
41
+ `Horizon ${role} fieldType must be ${supported.join(" or ")}.`
42
+ );
43
+ }
44
+ return { field, fieldType };
45
+ }
46
+
47
+ function validateOutputFields(value) {
48
+ if (!isPlainObject(value)) {
49
+ throw new TypeError("Horizon as must be a plain object.");
50
+ }
51
+ const unknown = Object.keys(value).find(
52
+ property => !OUTPUT_PROPERTIES.includes(property)
53
+ );
54
+ if (unknown !== undefined) {
55
+ throw new Error(`Unknown Horizon output field "${unknown}".`);
56
+ }
57
+ const output = Object.fromEntries(OUTPUT_PROPERTIES.map(property => [
58
+ property,
59
+ requireField(value[property], `Horizon ${property} output field`)
60
+ ]));
61
+ if (new Set(Object.values(output)).size !== OUTPUT_PROPERTIES.length) {
62
+ throw new Error("Horizon output fields must be distinct.");
63
+ }
64
+ return output;
65
+ }
66
+
67
+ function validatePalette(value = {}) {
68
+ if (!isPlainObject(value)) {
69
+ throw new TypeError("Horizon palette must be a plain object.");
70
+ }
71
+ const unknown = Object.keys(value).find(
72
+ property => !["positive", "negative"].includes(property)
73
+ );
74
+ if (unknown !== undefined) {
75
+ throw new Error(`Unknown Horizon palette property "${unknown}".`);
76
+ }
77
+ return {
78
+ positive: normalizePalette(value.positive ?? "blues"),
79
+ negative: normalizePalette(value.negative ?? "reds")
80
+ };
81
+ }
82
+
83
+ function validateResolved(value) {
84
+ if (!isPlainObject(value) || Object.keys(value).some(key => key !== "extents")) {
85
+ throw new TypeError("Horizon resolved provenance must contain only extents.");
86
+ }
87
+ if (!Array.isArray(value.extents) || value.extents.length === 0) {
88
+ throw new TypeError("Horizon resolved extents must be a non-empty array.");
89
+ }
90
+ for (const [index, entry] of value.extents.entries()) {
91
+ if (
92
+ !isPlainObject(entry) ||
93
+ Object.keys(entry).some(key => !["group", "extent", "bandHeight"].includes(key)) ||
94
+ !Number.isFinite(entry.extent) ||
95
+ entry.extent < 0 ||
96
+ !Number.isFinite(entry.bandHeight) ||
97
+ entry.bandHeight < 0
98
+ ) {
99
+ throw new TypeError(`Horizon resolved extent ${index} is invalid.`);
100
+ }
101
+ if (Object.hasOwn(entry, "group") && !isNominalValue(entry.group)) {
102
+ throw new TypeError(`Horizon resolved group ${index} must be nominal.`);
103
+ }
104
+ }
105
+ return value;
106
+ }
107
+
108
+ export function requestedHorizonTransform(transform) {
109
+ const { resolved: _resolved, ...requested } = transform;
110
+ void _resolved;
111
+ return requested;
112
+ }
113
+
114
+ export function validateHorizonTransform(transform) {
115
+ if (!isPlainObject(transform)) {
116
+ throw new TypeError("Horizon transform must be a plain object.");
117
+ }
118
+ const supported = [
119
+ "type", "x", "y", "groupBy", "bands", "baseline", "extent",
120
+ "resolve", "missing", "overflow", "palette", "as", "resolved"
121
+ ];
122
+ const unknown = Object.keys(transform).find(key => !supported.includes(key));
123
+ if (unknown !== undefined) {
124
+ throw new Error(`Unknown Horizon transform property "${unknown}".`);
125
+ }
126
+ if (transform.type !== "horizon") {
127
+ throw new Error(`Unsupported Horizon transform "${transform.type}".`);
128
+ }
129
+ const x = validateEncoding(transform.x, "x");
130
+ const y = validateEncoding(transform.y, "y");
131
+ if (x.field === y.field) {
132
+ throw new Error("Horizon x and y fields must differ.");
133
+ }
134
+ if (transform.groupBy !== undefined) {
135
+ requireField(transform.groupBy, "Horizon groupBy");
136
+ if ([x.field, y.field].includes(transform.groupBy)) {
137
+ throw new Error("Horizon groupBy must differ from x and y fields.");
138
+ }
139
+ }
140
+ if (!Number.isInteger(transform.bands) || transform.bands <= 0) {
141
+ throw new RangeError("Horizon bands must be a positive integer.");
142
+ }
143
+ if (!Number.isFinite(transform.baseline)) {
144
+ throw new TypeError("Horizon baseline must be finite.");
145
+ }
146
+ if (
147
+ transform.extent !== "auto" &&
148
+ (!Number.isFinite(transform.extent) || transform.extent <= 0)
149
+ ) {
150
+ throw new RangeError('Horizon extent must be "auto" or a positive number.');
151
+ }
152
+ if (!HORIZON_RESOLUTIONS.includes(transform.resolve)) {
153
+ throw new Error(`Unknown Horizon resolve "${transform.resolve}".`);
154
+ }
155
+ if (!HORIZON_MISSING_POLICIES.includes(transform.missing)) {
156
+ throw new Error(`Unknown Horizon missing policy "${transform.missing}".`);
157
+ }
158
+ if (!HORIZON_OVERFLOW_POLICIES.includes(transform.overflow)) {
159
+ throw new Error(`Unknown Horizon overflow policy "${transform.overflow}".`);
160
+ }
161
+ const palette = validatePalette(transform.palette);
162
+ for (const [sign, value] of Object.entries(palette)) {
163
+ if (value.count !== undefined && value.count !== transform.bands) {
164
+ throw new Error(
165
+ `Horizon ${sign} palette count must equal bands (${transform.bands}).`
166
+ );
167
+ }
168
+ }
169
+ const as = validateOutputFields(transform.as);
170
+ if (transform.groupBy !== undefined && Object.values(as).includes(transform.groupBy)) {
171
+ throw new Error("Horizon output fields must not collide with groupBy.");
172
+ }
173
+ if (transform.resolved !== undefined) validateResolved(transform.resolved);
174
+ return cloneAndFreeze({
175
+ ...transform,
176
+ x,
177
+ y,
178
+ palette,
179
+ as
180
+ });
181
+ }
182
+
183
+ function orderedX(value, encoding, rowIndex) {
184
+ if (encoding.fieldType === "temporal") {
185
+ return normalizeTemporalValue(value, encoding.field, rowIndex);
186
+ }
187
+ if (!Number.isFinite(value)) {
188
+ throw new TypeError(
189
+ `Horizon x field "${encoding.field}" must contain finite numbers at row ${rowIndex}.`
190
+ );
191
+ }
192
+ return value;
193
+ }
194
+
195
+ function groupRows(rows, transform) {
196
+ const groups = [];
197
+ const indexed = new Map();
198
+ for (const [sourceRowIndex, row] of rows.entries()) {
199
+ if (!isPlainObject(row)) {
200
+ throw new TypeError(`Horizon row ${sourceRowIndex} must be a plain object.`);
201
+ }
202
+ const group = transform.groupBy === undefined ? undefined : row[transform.groupBy];
203
+ if (transform.groupBy !== undefined && !isNominalValue(group)) {
204
+ throw new TypeError(
205
+ `Horizon groupBy field "${transform.groupBy}" must contain nominal values at row ${sourceRowIndex}.`
206
+ );
207
+ }
208
+ const key = transform.groupBy === undefined
209
+ ? "__horizon_ungrouped__"
210
+ : `${typeof group}:${String(group)}`;
211
+ if (!indexed.has(key)) {
212
+ const entry = { group, groupIndex: groups.length, rows: [] };
213
+ indexed.set(key, entry);
214
+ groups.push(entry);
215
+ }
216
+ const y = row[transform.y.field];
217
+ const missing = !Number.isFinite(y);
218
+ if (missing && transform.missing === "error") {
219
+ throw new Error(
220
+ `Horizon y field "${transform.y.field}" is missing at source row ${sourceRowIndex}.`
221
+ );
222
+ }
223
+ indexed.get(key).rows.push({
224
+ sourceRowIndex,
225
+ x: orderedX(row[transform.x.field], transform.x, sourceRowIndex),
226
+ y,
227
+ missing
228
+ });
229
+ }
230
+ for (const entry of groups) {
231
+ entry.rows.sort((left, right) =>
232
+ left.x - right.x || left.sourceRowIndex - right.sourceRowIndex
233
+ );
234
+ for (let index = 1; index < entry.rows.length; index += 1) {
235
+ if (entry.rows[index - 1].x === entry.rows[index].x) {
236
+ throw new Error(
237
+ `Horizon group ${JSON.stringify(entry.group ?? null)} has duplicate x value ${entry.rows[index].x}.`
238
+ );
239
+ }
240
+ }
241
+ }
242
+ return groups;
243
+ }
244
+
245
+ function splitSegments(rows, baseline) {
246
+ const segments = [];
247
+ let current = [];
248
+ const flush = () => {
249
+ if (current.length > 0) segments.push(current);
250
+ current = [];
251
+ };
252
+ for (const row of rows) {
253
+ if (row.missing) {
254
+ flush();
255
+ continue;
256
+ }
257
+ current.push({ ...row, signed: row.y - baseline, interpolated: false });
258
+ }
259
+ flush();
260
+ return segments;
261
+ }
262
+
263
+ function insertCrossings(points, baseline) {
264
+ if (points.length < 2) return points.map(point => ({ ...point }));
265
+ const output = [{ ...points[0] }];
266
+ for (let index = 1; index < points.length; index += 1) {
267
+ const left = points[index - 1];
268
+ const right = points[index];
269
+ if (left.signed * right.signed < 0) {
270
+ const fraction = (baseline - left.y) / (right.y - left.y);
271
+ output.push({
272
+ sourceRowIndex: undefined,
273
+ x: left.x + (right.x - left.x) * fraction,
274
+ y: baseline,
275
+ missing: false,
276
+ signed: 0,
277
+ interpolated: true,
278
+ leftSourceRowIndex: left.sourceRowIndex,
279
+ rightSourceRowIndex: right.sourceRowIndex,
280
+ fraction
281
+ });
282
+ }
283
+ output.push({ ...right });
284
+ }
285
+ return output;
286
+ }
287
+
288
+ function pointSign(value) {
289
+ return value > 0 ? "positive" : value < 0 ? "negative" : "zero";
290
+ }
291
+
292
+ function runsForSign(points, desired) {
293
+ const runs = [];
294
+ let current = [];
295
+ let pendingZeros = [];
296
+ const flush = () => {
297
+ if (current.some(point => pointSign(point.signed) === desired)) {
298
+ runs.push(current);
299
+ }
300
+ current = [];
301
+ };
302
+ for (const point of points) {
303
+ const sign = pointSign(point.signed);
304
+ if (sign === desired) {
305
+ if (current.length === 0 && pendingZeros.length > 0) {
306
+ current.push(...pendingZeros);
307
+ }
308
+ current.push(point);
309
+ pendingZeros = [];
310
+ } else if (sign === "zero") {
311
+ if (current.length > 0) {
312
+ current.push(point);
313
+ flush();
314
+ }
315
+ pendingZeros = [point];
316
+ } else {
317
+ flush();
318
+ pendingZeros = [];
319
+ }
320
+ }
321
+ flush();
322
+ return runs;
323
+ }
324
+
325
+ function maximumMagnitude(group) {
326
+ return Math.max(
327
+ 0,
328
+ ...group.segments.flatMap(segment =>
329
+ segment.map(point => Math.abs(point.signed))
330
+ )
331
+ );
332
+ }
333
+
334
+ function resolvedExtents(groups, transform) {
335
+ if (transform.extent !== "auto") return groups.map(() => transform.extent);
336
+ if (transform.resolve === "shared") {
337
+ const shared = Math.max(0, ...groups.map(maximumMagnitude));
338
+ return groups.map(() => shared);
339
+ }
340
+ return groups.map(maximumMagnitude);
341
+ }
342
+
343
+ function seriesForGroup(group, extent, transform) {
344
+ if (extent === 0) return [];
345
+ const bandHeight = extent / transform.bands;
346
+ const output = [];
347
+ for (const sign of ["negative", "positive"]) {
348
+ const runs = [];
349
+ let segmentIndex = 0;
350
+ for (const source of group.segments) {
351
+ for (const points of runsForSign(source, sign)) {
352
+ runs.push({ points, segmentIndex });
353
+ segmentIndex += 1;
354
+ }
355
+ }
356
+ for (let bandIndex = 0; bandIndex < transform.bands; bandIndex += 1) {
357
+ for (const run of runs) {
358
+ const points = run.points.map(point => {
359
+ const rawMagnitude = Math.abs(point.signed);
360
+ if (rawMagnitude > extent && transform.overflow === "error") {
361
+ throw new RangeError(
362
+ `Horizon magnitude ${rawMagnitude} exceeds extent ${extent}.`
363
+ );
364
+ }
365
+ const magnitude = Math.min(rawMagnitude, extent);
366
+ const amplitude = Math.min(
367
+ bandHeight,
368
+ Math.max(0, magnitude - bandIndex * bandHeight)
369
+ );
370
+ return {
371
+ ...point,
372
+ magnitude,
373
+ rawMagnitude,
374
+ amplitude,
375
+ fraction: amplitude / bandHeight,
376
+ overflowed: rawMagnitude > extent
377
+ };
378
+ });
379
+ if (points.some(point => point.amplitude > 0)) {
380
+ const groupIdentity = transform.groupBy === undefined
381
+ ? null
382
+ : group.group;
383
+ output.push({
384
+ group: group.group,
385
+ groupIndex: group.groupIndex,
386
+ sign,
387
+ bandIndex,
388
+ segmentIndex: run.segmentIndex,
389
+ seriesKey: JSON.stringify([
390
+ groupIdentity,
391
+ sign,
392
+ bandIndex,
393
+ run.segmentIndex
394
+ ]),
395
+ colorKey: `${sign}:${bandIndex}`,
396
+ bandHeight,
397
+ points
398
+ });
399
+ }
400
+ }
401
+ }
402
+ }
403
+ return output;
404
+ }
405
+
406
+ function generatedRows(series, transform) {
407
+ return series.flatMap(item => item.points.map(point => ({
408
+ ...(transform.groupBy === undefined
409
+ ? {}
410
+ : { [transform.groupBy]: item.group }),
411
+ [transform.as.x]: point.x,
412
+ [transform.as.lower]: 0,
413
+ [transform.as.upper]: point.fraction,
414
+ [transform.as.group]: item.seriesKey,
415
+ [transform.as.color]: item.colorKey,
416
+ [transform.as.sign]: item.sign,
417
+ [transform.as.band]: item.bandIndex,
418
+ [transform.as.segment]: item.segmentIndex
419
+ })));
420
+ }
421
+
422
+ export function deriveHorizon(rows, requested) {
423
+ if (!Array.isArray(rows)) {
424
+ throw new TypeError("Horizon values must be an array.");
425
+ }
426
+ if (rows.length === 0) {
427
+ throw new Error("Horizon requires at least one source row.");
428
+ }
429
+ const transform = validateHorizonTransform(requested);
430
+ const grouped = groupRows(rows, transform).map(group => ({
431
+ ...group,
432
+ segments: splitSegments(group.rows, transform.baseline).map(segment =>
433
+ insertCrossings(segment, transform.baseline)
434
+ )
435
+ }));
436
+ if (!grouped.some(group => group.rows.some(row => !row.missing))) {
437
+ throw new Error("Horizon requires at least one finite y value.");
438
+ }
439
+ const extents = resolvedExtents(grouped, transform);
440
+ const groups = grouped.map((group, index) => ({
441
+ ...(transform.groupBy === undefined ? {} : { group: group.group }),
442
+ extent: extents[index],
443
+ bandHeight: extents[index] / transform.bands
444
+ }));
445
+ const series = grouped.flatMap((group, index) =>
446
+ seriesForGroup(group, extents[index], transform)
447
+ );
448
+ return cloneAndFreeze({
449
+ transform: requestedHorizonTransform(transform),
450
+ resolved: { extents: groups },
451
+ groups,
452
+ series,
453
+ values: generatedRows(series, transform)
454
+ });
455
+ }