ggaction 0.0.10 → 0.0.12

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 (224) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -3
  3. package/knowledge/action-card.schema.json +1 -0
  4. package/knowledge/action-cards.json +1 -10941
  5. package/knowledge/action-cards.schema.json +1 -0
  6. package/knowledge/intent-taxonomy.json +1 -183
  7. package/knowledge/intent-taxonomy.schema.json +1 -0
  8. package/knowledge/mcp-resources.json +1 -95
  9. package/knowledge/mcp-resources.schema.json +1 -0
  10. package/knowledge/task-packet.schema.json +1 -159
  11. package/knowledge/task-resolver.js +1 -1230
  12. package/package.json +18 -2
  13. package/src/actions/boxPlots/edit.js +45 -298
  14. package/src/actions/boxPlots/options.js +46 -77
  15. package/src/actions/boxPlots/resolve.js +1 -1
  16. package/src/actions/charts/bar.js +3 -0
  17. package/src/actions/charts/heatmap.js +73 -83
  18. package/src/actions/charts/index.js +1 -1
  19. package/src/actions/charts/parallel.js +35 -1
  20. package/src/actions/charts/shared.js +20 -0
  21. package/src/actions/composition/actions.js +46 -49
  22. package/src/actions/data/bin2d.js +105 -2
  23. package/src/actions/data/box.js +17 -17
  24. package/src/actions/data/density.js +42 -78
  25. package/src/actions/data/gradientProfile.js +22 -52
  26. package/src/actions/data/horizon.js +16 -41
  27. package/src/actions/data/index.js +10 -10
  28. package/src/actions/data/interval.js +16 -38
  29. package/src/actions/data/intervalEdit.js +55 -0
  30. package/src/actions/data/regression.js +7 -17
  31. package/src/actions/data/shared.js +47 -0
  32. package/src/actions/data/timeUnit.js +14 -38
  33. package/src/actions/data/window.js +14 -38
  34. package/src/actions/distributions/revision.js +154 -34
  35. package/src/actions/encodings/angle.js +9 -10
  36. package/src/actions/encodings/appearance.js +17 -51
  37. package/src/actions/encodings/density/resolve.js +3 -1
  38. package/src/actions/encodings/density.js +1 -15
  39. package/src/actions/encodings/horizon/resolve.js +13 -3
  40. package/src/actions/encodings/horizon.js +8 -7
  41. package/src/actions/encodings/index.js +9 -9
  42. package/src/actions/encodings/offset.js +41 -45
  43. package/src/actions/encodings/parallel.js +9 -10
  44. package/src/actions/encodings/pathOrder.js +6 -13
  45. package/src/actions/encodings/position/apply.js +30 -0
  46. package/src/actions/encodings/position/policies/arc.js +23 -2
  47. package/src/actions/encodings/position/policies/index.js +4 -4
  48. package/src/actions/encodings/position/policies/line.js +47 -17
  49. package/src/actions/encodings/position/resolve.js +36 -7
  50. package/src/actions/encodings/ranged.js +18 -23
  51. package/src/actions/encodings/remove.js +15 -25
  52. package/src/actions/encodings/ruleAppearance.js +9 -24
  53. package/src/actions/encodings/shared.js +37 -0
  54. package/src/actions/encodings/strokeDash.js +8 -23
  55. package/src/actions/encodings/text.js +9 -10
  56. package/src/actions/errorBands/create.js +105 -39
  57. package/src/actions/errorBands/edit.js +89 -93
  58. package/src/actions/errorBands/index.js +1 -2
  59. package/src/actions/errorBars/create.js +212 -30
  60. package/src/actions/errorBars/edit.js +91 -48
  61. package/src/actions/errorBars/index.js +1 -2
  62. package/src/actions/facets/actions.js +45 -69
  63. package/src/actions/facets/derive.js +38 -53
  64. package/src/actions/facets/replay.js +0 -1
  65. package/src/actions/gradientPlots/components.js +59 -48
  66. package/src/actions/gradientPlots/edit.js +35 -216
  67. package/src/actions/gradientPlots/index.js +11 -10
  68. package/src/actions/gradientPlots/materialize.js +21 -35
  69. package/src/actions/gradientPlots/options.js +5 -7
  70. package/src/actions/gradientPlots/paint.js +9 -6
  71. package/src/actions/gradientPlots/resolve.js +1 -1
  72. package/src/actions/guides/axes/index.js +3 -3
  73. package/src/actions/guides/axes/labels.js +68 -26
  74. package/src/actions/guides/axes/lines.js +22 -2
  75. package/src/actions/guides/axes/policy.js +28 -8
  76. package/src/actions/guides/axes/ticks.js +27 -9
  77. package/src/actions/guides/axes/titles.js +113 -65
  78. package/src/actions/guides/grids/resolve.js +14 -0
  79. package/src/actions/guides/guides.js +2 -4
  80. package/src/actions/guides/index.js +2 -2
  81. package/src/actions/guides/legends/categorical/actions.js +44 -68
  82. package/src/actions/guides/legends/categorical/components.js +6 -1
  83. package/src/actions/guides/legends/categorical/index.js +2 -2
  84. package/src/actions/guides/legends/categorical/layout.js +148 -175
  85. package/src/actions/guides/legends/categorical/recipes.js +5 -0
  86. package/src/actions/guides/legends/categorical/resolve.js +8 -1
  87. package/src/actions/guides/legends/continuous/common.js +117 -66
  88. package/src/actions/guides/legends/continuous/gradient.js +38 -37
  89. package/src/actions/guides/legends/continuous/interval.js +97 -76
  90. package/src/actions/guides/legends/continuous/opacity.js +28 -38
  91. package/src/actions/guides/legends/edit.js +14 -1
  92. package/src/actions/guides/legends/index.js +1 -1
  93. package/src/actions/guides/legends/lane.js +2 -1
  94. package/src/actions/guides/legends/remove.js +8 -0
  95. package/src/actions/guides/legends/size.js +36 -43
  96. package/src/actions/guides/legends/strokeWidth.js +20 -26
  97. package/src/actions/guides/polar/axes/labels.js +17 -1
  98. package/src/actions/guides/polar/axes/shared.js +7 -1
  99. package/src/actions/guides/polar/axes/titles.js +19 -2
  100. package/src/actions/guides/polar/grids.js +4 -0
  101. package/src/actions/guides/polar/resolve.js +6 -0
  102. package/src/actions/index.js +13 -13
  103. package/src/actions/intervals/resolve.js +87 -117
  104. package/src/actions/marks/arc/actions.js +31 -36
  105. package/src/actions/marks/area/actions.js +23 -31
  106. package/src/actions/marks/index.js +6 -6
  107. package/src/actions/marks/line/actions.js +33 -52
  108. package/src/actions/marks/line/materialize.js +7 -5
  109. package/src/actions/marks/point/materialize.js +38 -16
  110. package/src/actions/marks/remove.js +21 -0
  111. package/src/actions/marks/rule/actions.js +79 -54
  112. package/src/actions/marks/shared.js +8 -4
  113. package/src/actions/marks/text/actions.js +14 -6
  114. package/src/actions/marks/text/layout.js +21 -45
  115. package/src/actions/marks/tick/actions.js +22 -21
  116. package/src/actions/primitives/createGraphics.js +4 -0
  117. package/src/actions/primitives/editGraphics.js +4 -0
  118. package/src/actions/primitives/index.js +3 -3
  119. package/src/actions/primitives/semantic.js +1 -1
  120. package/src/actions/primitives/semanticValidation/index.js +2 -2
  121. package/src/actions/primitives/semanticValidation/layer.js +1 -1
  122. package/src/actions/regression/edit.js +3 -43
  123. package/src/actions/scales/consumers/families.js +24 -4
  124. package/src/actions/scales/consumers/index.js +1 -1
  125. package/src/actions/scales/consumers/seriesLayout.js +17 -0
  126. package/src/actions/scales/definitions.js +100 -103
  127. package/src/actions/scales/materialize.js +5 -0
  128. package/src/actions/selection/actions.js +79 -121
  129. package/src/actions/titles/actions.js +48 -94
  130. package/src/actions/titles/resolve.js +14 -27
  131. package/src/actions/violinPlots/create.js +37 -44
  132. package/src/core/textMetrics.js +74 -37
  133. package/src/core/validation.js +18 -0
  134. package/src/core/vocabulary.js +2 -2
  135. package/src/grammar/aggregate.js +48 -28
  136. package/src/grammar/arcs.js +170 -32
  137. package/src/grammar/areaSeries.js +14 -16
  138. package/src/grammar/bin2d.js +37 -23
  139. package/src/grammar/boxPlot.js +14 -13
  140. package/src/grammar/categoricalDensity.js +14 -14
  141. package/src/grammar/categoryOrder.js +8 -14
  142. package/src/grammar/curveCommands.js +87 -20
  143. package/src/grammar/density.js +162 -141
  144. package/src/grammar/facets/index.js +14 -0
  145. package/src/grammar/facets/scales.js +7 -4
  146. package/src/grammar/gradientProfile.js +12 -15
  147. package/src/grammar/histogram.js +145 -83
  148. package/src/grammar/horizon.js +60 -23
  149. package/src/grammar/interval.js +14 -13
  150. package/src/grammar/lineSeries.js +91 -61
  151. package/src/grammar/numeric.js +290 -0
  152. package/src/grammar/paint.js +3 -4
  153. package/src/grammar/palettes.js +21 -32
  154. package/src/grammar/pathCommands.js +6 -0
  155. package/src/grammar/polarPaths.js +5 -5
  156. package/src/grammar/positionCompatibility.js +1 -1
  157. package/src/grammar/regression/derive.js +48 -9
  158. package/src/grammar/regression/models.js +248 -69
  159. package/src/grammar/regression/parameters.js +8 -0
  160. package/src/grammar/rules.js +2 -0
  161. package/src/grammar/scales/color.js +35 -26
  162. package/src/grammar/scales/continuous.js +71 -24
  163. package/src/grammar/scales/definition.js +1 -1
  164. package/src/grammar/scales/discretized.js +42 -12
  165. package/src/grammar/scales/fields.js +21 -4
  166. package/src/grammar/scales/ordinal.js +139 -60
  167. package/src/grammar/scales/temporal.js +40 -11
  168. package/src/grammar/scales/transformed.js +199 -52
  169. package/src/grammar/schemas/graphicBounds.js +270 -79
  170. package/src/grammar/seriesLayout.js +50 -18
  171. package/src/grammar/ticks.js +123 -53
  172. package/src/grammar/timeUnit.js +18 -8
  173. package/src/grammar/transforms.js +4 -1
  174. package/src/grammar/window.js +70 -57
  175. package/src/index.js +1 -1
  176. package/src/layout/canvas.js +4 -8
  177. package/src/layout/composition.js +39 -40
  178. package/src/layout/facets.js +55 -30
  179. package/src/layout/labels.js +91 -15
  180. package/src/layout/legend.js +22 -20
  181. package/src/layout/legendLane.js +54 -40
  182. package/src/layout/title.js +7 -7
  183. package/src/materialization/bars/aggregate.js +14 -40
  184. package/src/materialization/bars/grouped.js +7 -18
  185. package/src/materialization/bars/histogram.js +2 -19
  186. package/src/materialization/bars/ranged.js +20 -15
  187. package/src/materialization/bars/resolve.js +21 -0
  188. package/src/materialization/facetGuides/legacyCategorical.js +9 -2
  189. package/src/materialization/facetGuides/placement.js +49 -19
  190. package/src/materialization/facetGuides/preparation.js +35 -6
  191. package/src/materialization/facets.js +42 -28
  192. package/src/materialization/guides/resources.js +2 -1
  193. package/src/materialization/marks/capabilities.js +15 -4
  194. package/src/materialization/marks/index.js +29 -6
  195. package/src/materialization/rowEncoding.js +57 -1
  196. package/src/materialization/scales/policies/bar.js +61 -102
  197. package/src/materialization/scales/policies/binnedPosition.js +34 -0
  198. package/src/materialization/scales/policies/offset.js +54 -0
  199. package/src/materialization/scales/policies/series.js +13 -7
  200. package/src/materialization/scales/resolve.js +6 -9
  201. package/src/materialization/selection/items/arc.js +16 -2
  202. package/src/materialization/selection/items/bar.js +4 -2
  203. package/src/materialization/selection/items/common.js +21 -1
  204. package/src/materialization/selection/items/path.js +19 -2
  205. package/src/materialization/selection/items/point.js +4 -16
  206. package/src/materialization/selection/items/tick.js +13 -21
  207. package/src/materialization/selection/point.js +11 -4
  208. package/src/materialization/selection/styles.js +19 -27
  209. package/src/materialization/text.js +4 -1
  210. package/src/mcp/adapter.js +8 -3
  211. package/src/renderers/canvas/fill.js +2 -2
  212. package/src/renderers/canvas/index.js +35 -2
  213. package/src/renderers/canvas/native.js +160 -0
  214. package/src/renderers/pdf.js +14 -0
  215. package/src/renderers/svg.js +37 -4
  216. package/types/index.d.ts +3 -0
  217. package/types/program.d.ts +523 -131
  218. package/types/svg.d.ts +1 -0
  219. package/src/actions/errorBands/components.js +0 -81
  220. package/src/actions/errorBands/options.js +0 -39
  221. package/src/actions/errorBands/resolve.js +0 -28
  222. package/src/actions/errorBars/components.js +0 -62
  223. package/src/actions/errorBars/options.js +0 -58
  224. package/src/actions/errorBars/resolve.js +0 -25
@@ -1,5 +1,9 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
2
2
  import { validateUserId } from "../../core/identifiers.js";
3
+ import {
4
+ validateGeneratedItemLimit,
5
+ validateWorkLimit
6
+ } from "../../core/validation.js";
3
7
  import { BAR_GRAINS, resolveBarGrain } from "../bars/policy.js";
4
8
  import { planFacetDependencies } from "./dependencies.js";
5
9
  import { readNominalField } from "../scales/index.js";
@@ -12,6 +16,7 @@ const SUPPORTED_BAR_GRAINS = new Set([
12
16
  BAR_GRAINS.aggregate,
13
17
  BAR_GRAINS.ranged
14
18
  ]);
19
+ const MAX_FACET_CHILDREN = 100;
15
20
 
16
21
  function requireFacetField(field) {
17
22
  if (typeof field !== "string" || field.length === 0) {
@@ -109,6 +114,15 @@ export function resolveFacetDefinition(semanticSpec, options = {}) {
109
114
  throw new Error(`facet field "${field}" has no values.`);
110
115
  }
111
116
  const values = resolveValues(observed, options.values);
117
+ validateGeneratedItemLimit(
118
+ values.length,
119
+ "Facet child count",
120
+ MAX_FACET_CHILDREN
121
+ );
122
+ validateWorkLimit(
123
+ dataset.values.length * values.length,
124
+ "Facet partition work"
125
+ );
112
126
  return cloneAndFreeze({
113
127
  id,
114
128
  data,
@@ -131,10 +131,13 @@ function continuousUnion(domains, id) {
131
131
  )) {
132
132
  throw new TypeError(`Facet shared scale "${id}" requires finite pair domains.`);
133
133
  }
134
- return [
135
- Math.min(...domains.map(domain => Math.min(...domain))),
136
- Math.max(...domains.map(domain => Math.max(...domain)))
137
- ];
134
+ let minimum = Infinity;
135
+ let maximum = -Infinity;
136
+ for (const domain of domains) {
137
+ minimum = Math.min(minimum, domain[0], domain[1]);
138
+ maximum = Math.max(maximum, domain[0], domain[1]);
139
+ }
140
+ return [minimum, maximum];
138
141
  }
139
142
 
140
143
  function stableUnion(domains) {
@@ -2,8 +2,10 @@ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
2
  import {
3
3
  deriveKernelDensity,
4
4
  validateDensityKernel,
5
- validateDensityNormalization
5
+ validateDensityNormalization,
6
+ validateDensitySteps
6
7
  } from "./density.js";
8
+ import { finiteMidpoint, stableFiniteMean } from "./numeric.js";
7
9
 
8
10
  export const GRADIENT_PROFILE_FIELDS = Object.freeze({
9
11
  category: "__gradientPlot_category",
@@ -15,11 +17,11 @@ export const GRADIENT_PROFILE_FIELDS = Object.freeze({
15
17
  count: "__gradientPlot_count"
16
18
  });
17
19
 
18
- const TRANSFORM_KEYS = Object.freeze([
20
+ const TRANSFORM_KEYS = [
19
21
  "type", "category", "field", "bandwidth", "extent", "steps", "kernel",
20
22
  "normalization", "center", "as", "resolve", "resolved"
21
- ]);
22
- const OUTPUT_KEYS = Object.freeze(Object.keys(GRADIENT_PROFILE_FIELDS));
23
+ ];
24
+ const OUTPUT_KEYS = Object.keys(GRADIENT_PROFILE_FIELDS);
23
25
 
24
26
  function requireField(value, label) {
25
27
  if (typeof value !== "string" || value.length === 0) {
@@ -92,9 +94,7 @@ export function validateGradientProfileTransform(value) {
92
94
  if (value.extent !== "auto") {
93
95
  validatePair(value.extent, "Gradient profile extent");
94
96
  }
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
- }
97
+ validateDensitySteps(value.steps, "Gradient profile steps");
98
98
  validateDensityKernel(value.kernel);
99
99
  validateDensityNormalization(value.normalization);
100
100
  if (!["mean", "median"].includes(value.center)) {
@@ -148,14 +148,10 @@ export function requestedGradientProfileTransform(transform) {
148
148
  }
149
149
 
150
150
  function centerOf(values, type) {
151
- if (type === "mean") {
152
- return values.reduce((sum, value) => sum + value, 0) / values.length;
153
- }
151
+ if (type === "mean") return stableFiniteMean(values);
154
152
  const sorted = [...values].sort((left, right) => left - right);
155
153
  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;
154
+ return finiteMidpoint(sorted[Math.floor(middle)], sorted[Math.ceil(middle)]);
159
155
  }
160
156
 
161
157
  export function deriveGradientProfiles(rows, transform) {
@@ -178,8 +174,9 @@ export function deriveGradientProfiles(rows, transform) {
178
174
  row[transform.category] !== undefined && row[transform.category] !== null &&
179
175
  row[transform.category] !== "" && Number.isFinite(row[transform.field])
180
176
  );
181
- const maximumIntensity = Math.max(
182
- ...density.values.map(row => row.__gradientProfile_intensity)
177
+ const maximumIntensity = density.values.reduce(
178
+ (maximum, row) => Math.max(maximum, row.__gradientProfile_intensity),
179
+ -Infinity
183
180
  );
184
181
  if (!Number.isFinite(maximumIntensity) || maximumIntensity <= 0) {
185
182
  throw new Error("Gradient profile requires a positive resolved density range.");
@@ -1,6 +1,14 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
-
3
- const NICE_FACTORS = Object.freeze([1, 2, 3, 5]);
2
+ import { MAX_GENERATED_ITEMS } from "../core/validation.js";
3
+ import {
4
+ alignNumericStep,
5
+ cleanNumericValue,
6
+ interpolateNumber,
7
+ NICE_FACTORS,
8
+ niceNumericStep,
9
+ numericExtent,
10
+ normalizeNumericRange
11
+ } from "./numeric.js";
4
12
 
5
13
  function validateValues(values) {
6
14
  if (!Array.isArray(values) || !values.every(Number.isFinite)) {
@@ -23,14 +31,12 @@ export function validateHistogramBinStep(step) {
23
31
  }
24
32
 
25
33
  export function validateHistogramBinBoundaries(boundaries) {
26
- if (
27
- !Array.isArray(boundaries) ||
28
- boundaries.length < 2 ||
29
- !boundaries.every(Number.isFinite) ||
30
- boundaries.some(
31
- (value, index) => index > 0 && value <= boundaries[index - 1]
32
- )
33
- ) {
34
+ if (Array.isArray(boundaries) && boundaries.length > MAX_GENERATED_ITEMS + 1) {
35
+ throw new RangeError(
36
+ `Histogram bin boundaries must generate at most ${MAX_GENERATED_ITEMS} bins.`
37
+ );
38
+ }
39
+ if (!validBoundaries(boundaries)) {
34
40
  throw new TypeError(
35
41
  "Histogram bin boundaries must contain at least two strictly increasing finite numbers."
36
42
  );
@@ -38,6 +44,14 @@ export function validateHistogramBinBoundaries(boundaries) {
38
44
  return boundaries;
39
45
  }
40
46
 
47
+ function validBoundaries(boundaries) {
48
+ return Array.isArray(boundaries) && boundaries.length >= 2 &&
49
+ boundaries.every(Number.isFinite) &&
50
+ !boundaries.some(
51
+ (value, index) => index > 0 && value <= boundaries[index - 1]
52
+ );
53
+ }
54
+
41
55
  export function normalizeHistogramBin(bin = {}) {
42
56
  if (!isPlainObject(bin)) {
43
57
  throw new TypeError("Histogram bin must be a plain object.");
@@ -78,24 +92,6 @@ function validateDomain(domain) {
78
92
  return domain;
79
93
  }
80
94
 
81
- function firstNiceStep(rough) {
82
- const power = 10 ** Math.floor(Math.log10(rough));
83
- const fraction = rough / power;
84
- const factor = NICE_FACTORS.find(candidate => candidate >= fraction);
85
- return (factor ?? 10) * power;
86
- }
87
-
88
- function nextNiceStep(step) {
89
- const power = 10 ** Math.floor(Math.log10(step));
90
- const fraction = step / power;
91
- const factor = NICE_FACTORS.find(candidate => candidate > fraction + 1e-12);
92
- return (factor ?? 10) * power;
93
- }
94
-
95
- function normalizeBoundary(value) {
96
- return Number(value.toPrecision(15));
97
- }
98
-
99
95
  function includesExtent(domain, values) {
100
96
  return values.every(value => value >= domain[0] && value <= domain[1]);
101
97
  }
@@ -122,11 +118,16 @@ function exactStepBoundaries(domain, step) {
122
118
  );
123
119
  }
124
120
  const count = Math.round((domain[1] - domain[0]) / step);
121
+ if (!Number.isSafeInteger(count) || count > MAX_GENERATED_ITEMS) {
122
+ throw new RangeError(
123
+ `Histogram bin step must generate at most ${MAX_GENERATED_ITEMS} bins.`
124
+ );
125
+ }
125
126
  return Array.from(
126
127
  { length: count + 1 },
127
128
  (_, index) => index === count
128
129
  ? domain[1]
129
- : normalizeBoundary(domain[0] + index * step)
130
+ : cleanNumericValue(domain[0] + index * step, step)
130
131
  );
131
132
  }
132
133
 
@@ -144,17 +145,16 @@ function stepBins(values, step, domain, zero) {
144
145
  throw new Error("Cannot infer histogram bins from no values.");
145
146
  }
146
147
 
147
- let minimum = Math.min(...values);
148
- let maximum = Math.max(...values);
148
+ let [minimum, maximum] = numericExtent(values);
149
149
  if (zero) {
150
150
  minimum = Math.min(0, minimum);
151
151
  maximum = Math.max(0, maximum);
152
152
  }
153
- let start = normalizeBoundary(Math.floor(minimum / step) * step);
154
- let stop = normalizeBoundary(Math.ceil(maximum / step) * step);
153
+ let start = cleanNumericValue(Math.floor(minimum / step) * step, step);
154
+ let stop = cleanNumericValue(Math.ceil(maximum / step) * step, step);
155
155
  if (start === stop) {
156
- if (stop <= 0) start = normalizeBoundary(start - step);
157
- else stop = normalizeBoundary(stop + step);
156
+ if (stop <= 0) start = cleanNumericValue(start - step, step);
157
+ else stop = cleanNumericValue(stop + step, step);
158
158
  }
159
159
  const resolvedDomain = [start, stop];
160
160
  return {
@@ -183,41 +183,120 @@ function boundaryBins(values, boundaries, domain) {
183
183
  }
184
184
 
185
185
  function equalBins(domain, maxBins) {
186
- const step = (domain[1] - domain[0]) / maxBins;
187
- const boundaries = Array.from(
188
- { length: maxBins + 1 },
189
- (_, index) =>
190
- index === maxBins
191
- ? domain[1]
192
- : normalizeBoundary(domain[0] + index * step)
193
- );
194
- return { domain, step, boundaries };
186
+ const normalized = normalizeNumericRange(domain[0], domain[1]);
187
+ const candidateStep = normalized.span / maxBins * normalized.scale;
188
+ const boundaries = [];
189
+ for (let index = 0; index <= maxBins; index += 1) {
190
+ const value = index === maxBins
191
+ ? domain[1]
192
+ : cleanNumericValue(
193
+ interpolateNumber(domain[0], domain[1], index / maxBins),
194
+ candidateStep
195
+ );
196
+ if (boundaries.length === 0 || value > boundaries.at(-1)) {
197
+ boundaries.push(value);
198
+ }
199
+ }
200
+ const step = Number.isFinite(candidateStep) && candidateStep > 0
201
+ ? candidateStep
202
+ : firstFiniteBoundaryDifference(boundaries) ?? Number.MAX_VALUE;
203
+ return {
204
+ domain: [boundaries[0], boundaries.at(-1)],
205
+ step,
206
+ boundaries
207
+ };
208
+ }
209
+
210
+ function firstFiniteBoundaryDifference(boundaries) {
211
+ for (let index = 1; index < boundaries.length; index += 1) {
212
+ const difference = boundaries[index] - boundaries[index - 1];
213
+ if (Number.isFinite(difference) && difference > 0) return difference;
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ function constantBins(value) {
219
+ const delta = Math.max(0.5, Math.abs(value) * Number.EPSILON);
220
+ let lower = value - delta;
221
+ let upper = value + delta;
222
+ if (!Number.isFinite(lower)) lower = value;
223
+ if (!Number.isFinite(upper)) upper = value;
224
+ if (lower === value) upper = value + delta;
225
+ if (upper === value) lower = value - delta;
226
+ const step = upper - lower;
227
+ return {
228
+ domain: [lower, upper],
229
+ step,
230
+ boundaries: [lower, upper]
231
+ };
195
232
  }
196
233
 
197
234
  function niceBins(extent, maxBins) {
198
- const span = extent[1] - extent[0];
199
- let step = firstNiceStep(span / maxBins);
235
+ const normalized = normalizeNumericRange(extent[0], extent[1]);
236
+ let normalizedStep = niceNumericStep(
237
+ normalized.span,
238
+ maxBins,
239
+ NICE_FACTORS
240
+ );
200
241
 
201
- while (true) {
202
- const start = normalizeBoundary(Math.floor(extent[0] / step) * step);
203
- const stop = normalizeBoundary(Math.ceil(extent[1] / step) * step);
204
- const count = Math.round((stop - start) / step);
242
+ for (let attempt = 0; attempt < 64; attempt += 1) {
243
+ const start = alignNumericStep(
244
+ normalized.start,
245
+ normalizedStep,
246
+ "floor"
247
+ );
248
+ const stop = alignNumericStep(
249
+ normalized.end,
250
+ normalizedStep,
251
+ "ceil"
252
+ );
253
+ const count = Math.round((stop - start) / normalizedStep);
205
254
 
206
255
  if (count > 0 && count <= maxBins) {
207
- return {
208
- domain: [start, stop],
209
- step,
210
- boundaries: Array.from(
211
- { length: count + 1 },
212
- (_, index) =>
213
- index === count
214
- ? stop
215
- : normalizeBoundary(start + index * step)
216
- )
217
- };
256
+ const resolution = normalizedStep * normalized.scale;
257
+ const boundaries = [];
258
+ for (let index = 0; index <= count; index += 1) {
259
+ let value = (index === count
260
+ ? stop
261
+ : start + index * normalizedStep) * normalized.scale;
262
+ if (!Number.isFinite(value)) {
263
+ value = index === 0
264
+ ? extent[0]
265
+ : index === count ? extent[1] : value;
266
+ }
267
+ value = cleanNumericValue(value, resolution);
268
+ if (index === 0 && value > extent[0]) value = extent[0];
269
+ if (index === count && value < extent[1]) value = extent[1];
270
+ if (
271
+ Number.isFinite(value) &&
272
+ (boundaries.length === 0 || value > boundaries.at(-1))
273
+ ) {
274
+ boundaries.push(value);
275
+ }
276
+ }
277
+ if (
278
+ boundaries.length >= 2 &&
279
+ boundaries[0] <= extent[0] &&
280
+ boundaries.at(-1) >= extent[1]
281
+ ) {
282
+ const step = Number.isFinite(resolution) && resolution > 0
283
+ ? resolution
284
+ : firstFiniteBoundaryDifference(boundaries) ?? Number.MAX_VALUE;
285
+ return {
286
+ domain: [boundaries[0], boundaries.at(-1)],
287
+ step,
288
+ boundaries
289
+ };
290
+ }
218
291
  }
219
- step = nextNiceStep(step);
292
+ normalizedStep = niceNumericStep(
293
+ normalizedStep * (1 + 1e-12),
294
+ 1,
295
+ NICE_FACTORS
296
+ );
220
297
  }
298
+
299
+ return equalBins(extent, maxBins);
221
300
  }
222
301
 
223
302
  export function resolveHistogramBins({
@@ -254,7 +333,7 @@ export function resolveHistogramBins({
254
333
  );
255
334
  }
256
335
 
257
- maxBins = normalizedBin.maxBins;
336
+ maxBins = Math.min(normalizedBin.maxBins, MAX_GENERATED_ITEMS);
258
337
 
259
338
  if (domain !== "auto") {
260
339
  return cloneAndFreeze(equalBins(validateDomain(domain), maxBins));
@@ -263,24 +342,14 @@ export function resolveHistogramBins({
263
342
  throw new Error("Cannot infer histogram bins from no values.");
264
343
  }
265
344
 
266
- let minimum = values[0];
267
- let maximum = values[0];
268
-
269
- for (const value of values.slice(1)) {
270
- minimum = Math.min(minimum, value);
271
- maximum = Math.max(maximum, value);
272
- }
345
+ let [minimum, maximum] = numericExtent(values);
273
346
 
274
347
  if (zero) {
275
348
  minimum = Math.min(0, minimum);
276
349
  maximum = Math.max(0, maximum);
277
350
  }
278
351
  if (minimum === maximum) {
279
- return cloneAndFreeze({
280
- domain: [minimum - 0.5, maximum + 0.5],
281
- step: 1,
282
- boundaries: [minimum - 0.5, maximum + 0.5]
283
- });
352
+ return cloneAndFreeze(constantBins(minimum));
284
353
  }
285
354
 
286
355
  const resolved = nice
@@ -292,14 +361,7 @@ export function resolveHistogramBins({
292
361
  export function countHistogramBins(values, boundaries) {
293
362
  validateValues(values);
294
363
 
295
- if (
296
- !Array.isArray(boundaries) ||
297
- boundaries.length < 2 ||
298
- !boundaries.every(Number.isFinite) ||
299
- boundaries.some(
300
- (value, index) => index > 0 && value <= boundaries[index - 1]
301
- )
302
- ) {
364
+ if (!validBoundaries(boundaries)) {
303
365
  throw new TypeError(
304
366
  "Histogram boundaries must be ascending finite numbers."
305
367
  );
@@ -1,5 +1,11 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import {
3
+ validateGeneratedItemLimit,
4
+ validateNonEmptyString as requireField
5
+ } from "../core/validation.js";
2
6
  import { normalizePalette } from "./palettes.js";
7
+ import { interpolateNumber, inverseLerp } from "./numeric.js";
8
+ import { requireFiniteResult } from "./numeric.js";
3
9
  import {
4
10
  isNominalValue,
5
11
  normalizeTemporalValue
@@ -14,13 +20,6 @@ const OUTPUT_PROPERTIES = Object.freeze([
14
20
  "x", "lower", "upper", "group", "color", "sign", "band", "segment"
15
21
  ]);
16
22
 
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
23
  function validateEncoding(value, role) {
25
24
  if (!isPlainObject(value)) {
26
25
  throw new TypeError(`Horizon ${role} encoding must be a plain object.`);
@@ -32,13 +31,14 @@ function validateEncoding(value, role) {
32
31
  throw new Error(`Unknown Horizon ${role} encoding property "${unknown}".`);
33
32
  }
34
33
  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)) {
34
+ const fieldType = value.fieldType ?? "quantitative";
35
+ const supportsTemporal = role === "x";
36
+ if (
37
+ fieldType !== "quantitative" &&
38
+ (!supportsTemporal || fieldType !== "temporal")
39
+ ) {
40
40
  throw new Error(
41
- `Horizon ${role} fieldType must be ${supported.join(" or ")}.`
41
+ `Horizon ${role} fieldType must be quantitative${supportsTemporal ? " or temporal" : ""}.`
42
42
  );
43
43
  }
44
44
  return { field, fieldType };
@@ -140,6 +140,7 @@ export function validateHorizonTransform(transform) {
140
140
  if (!Number.isInteger(transform.bands) || transform.bands <= 0) {
141
141
  throw new RangeError("Horizon bands must be a positive integer.");
142
142
  }
143
+ validateGeneratedItemLimit(transform.bands, "Horizon bands");
143
144
  if (!Number.isFinite(transform.baseline)) {
144
145
  throw new TypeError("Horizon baseline must be finite.");
145
146
  }
@@ -254,7 +255,14 @@ function splitSegments(rows, baseline) {
254
255
  flush();
255
256
  continue;
256
257
  }
257
- current.push({ ...row, signed: row.y - baseline, interpolated: false });
258
+ current.push({
259
+ ...row,
260
+ signed: requireFiniteResult(
261
+ row.y - baseline,
262
+ "Horizon signed deviation"
263
+ ),
264
+ interpolated: false
265
+ });
258
266
  }
259
267
  flush();
260
268
  return segments;
@@ -267,10 +275,10 @@ function insertCrossings(points, baseline) {
267
275
  const left = points[index - 1];
268
276
  const right = points[index];
269
277
  if (left.signed * right.signed < 0) {
270
- const fraction = (baseline - left.y) / (right.y - left.y);
278
+ const fraction = inverseLerp(baseline, left.y, right.y);
271
279
  output.push({
272
280
  sourceRowIndex: undefined,
273
- x: left.x + (right.x - left.x) * fraction,
281
+ x: interpolateNumber(left.x, right.x, fraction),
274
282
  y: baseline,
275
283
  missing: false,
276
284
  signed: 0,
@@ -323,23 +331,51 @@ function runsForSign(points, desired) {
323
331
  }
324
332
 
325
333
  function maximumMagnitude(group) {
326
- return Math.max(
327
- 0,
328
- ...group.segments.flatMap(segment =>
329
- segment.map(point => Math.abs(point.signed))
330
- )
331
- );
334
+ let maximum = 0;
335
+ for (const segment of group.segments ?? [group]) {
336
+ for (const point of segment) {
337
+ maximum = Math.max(maximum, Math.abs(point.signed));
338
+ }
339
+ }
340
+ return maximum;
332
341
  }
333
342
 
334
343
  function resolvedExtents(groups, transform) {
335
344
  if (transform.extent !== "auto") return groups.map(() => transform.extent);
336
345
  if (transform.resolve === "shared") {
337
- const shared = Math.max(0, ...groups.map(maximumMagnitude));
346
+ let shared = 0;
347
+ for (const group of groups) shared = Math.max(shared, maximumMagnitude(group));
338
348
  return groups.map(() => shared);
339
349
  }
340
350
  return groups.map(maximumMagnitude);
341
351
  }
342
352
 
353
+ function validateOutputBudget(groups, extents, transform) {
354
+ let count = 0;
355
+ for (const [groupIndex, group] of groups.entries()) {
356
+ const extent = extents[groupIndex];
357
+ if (extent === 0) continue;
358
+ const bandHeight = extent / transform.bands;
359
+ if (!(bandHeight > 0) || !Number.isFinite(bandHeight)) {
360
+ throw new RangeError(
361
+ "Horizon band height is outside the positive finite numeric range."
362
+ );
363
+ }
364
+ for (const sign of ["negative", "positive"]) {
365
+ for (const segment of group.segments) {
366
+ for (const run of runsForSign(segment, sign)) {
367
+ const magnitude = maximumMagnitude(run);
368
+ count += Math.min(
369
+ transform.bands,
370
+ Math.ceil(Math.min(magnitude, extent) / bandHeight)
371
+ ) * run.length;
372
+ validateGeneratedItemLimit(count, "Horizon generated row count");
373
+ }
374
+ }
375
+ }
376
+ }
377
+ }
378
+
343
379
  function seriesForGroup(group, extent, transform) {
344
380
  if (extent === 0) return [];
345
381
  const bandHeight = extent / transform.bands;
@@ -437,6 +473,7 @@ export function deriveHorizon(rows, requested) {
437
473
  throw new Error("Horizon requires at least one finite y value.");
438
474
  }
439
475
  const extents = resolvedExtents(grouped, transform);
476
+ validateOutputBudget(grouped, extents, transform);
440
477
  const groups = grouped.map((group, index) => ({
441
478
  ...(transform.groupBy === undefined ? {} : { group: group.group }),
442
479
  extent: extents[index],
@@ -1,10 +1,11 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
2
  import { aggregateScalarValues } from "./aggregate.js";
3
+ import { requireFiniteResult, stableDecimal } from "./numeric.js";
3
4
  import { studentTCriticalValue } from "./statistics/studentT.js";
4
5
 
5
- const CENTER_VALUES = Object.freeze(["mean", "median"]);
6
- const EXTENT_VALUES = Object.freeze(["stderr", "stdev", "ci", "iqr"]);
7
- const TRANSFORM_KEYS = Object.freeze([
6
+ const CENTER_VALUES = ["mean", "median"];
7
+ const EXTENT_VALUES = ["stderr", "stdev", "ci", "iqr"];
8
+ const TRANSFORM_KEYS = [
8
9
  "type",
9
10
  "field",
10
11
  "groupBy",
@@ -12,8 +13,8 @@ const TRANSFORM_KEYS = Object.freeze([
12
13
  "extent",
13
14
  "level",
14
15
  "as"
15
- ]);
16
- const OUTPUT_KEYS = Object.freeze(["center", "lower", "upper"]);
16
+ ];
17
+ const OUTPUT_KEYS = ["center", "lower", "upper"];
17
18
 
18
19
  function nonEmptyString(value, label) {
19
20
  if (typeof value !== "string" || value.length === 0) {
@@ -154,10 +155,6 @@ function isGroupValue(value) {
154
155
  (typeof value === "number" && Number.isFinite(value));
155
156
  }
156
157
 
157
- function stableNumber(value) {
158
- return Number(value.toFixed(12));
159
- }
160
-
161
158
  function deriveGroup(values, transform) {
162
159
  if (transform.center === "median") {
163
160
  if (values.length === 0) return undefined;
@@ -175,7 +172,11 @@ function deriveGroup(values, transform) {
175
172
  ? aggregateScalarValues(values, "stderr")
176
173
  : studentTCritical(values.length - 1, transform.level) *
177
174
  aggregateScalarValues(values, "stderr");
178
- return { center, lower: center - spread, upper: center + spread };
175
+ return {
176
+ center,
177
+ lower: requireFiniteResult(center - spread, "Interval lower endpoint"),
178
+ upper: requireFiniteResult(center + spread, "Interval upper endpoint")
179
+ };
179
180
  }
180
181
 
181
182
  export function deriveInterval(rows, transform) {
@@ -218,9 +219,9 @@ export function deriveInterval(rows, transform) {
218
219
  if (result === undefined) continue;
219
220
  output.push({
220
221
  ...group.fields,
221
- [transform.as.center]: stableNumber(result.center),
222
- [transform.as.lower]: stableNumber(result.lower),
223
- [transform.as.upper]: stableNumber(result.upper)
222
+ [transform.as.center]: stableDecimal(result.center),
223
+ [transform.as.lower]: stableDecimal(result.lower),
224
+ [transform.as.upper]: stableDecimal(result.upper)
224
225
  });
225
226
  }
226
227
  return cloneAndFreeze(output);