ggaction 0.0.9 → 0.0.11

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 (222) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +5 -4
  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/extension-authoring.md +248 -0
  7. package/knowledge/intent-taxonomy.json +1 -183
  8. package/knowledge/intent-taxonomy.schema.json +1 -0
  9. package/knowledge/mcp-resources.json +1 -95
  10. package/knowledge/mcp-resources.schema.json +1 -0
  11. package/knowledge/task-packet.schema.json +1 -159
  12. package/knowledge/task-resolver.js +1 -1230
  13. package/package.json +19 -2
  14. package/src/actions/boxPlots/edit.js +45 -298
  15. package/src/actions/boxPlots/options.js +46 -77
  16. package/src/actions/boxPlots/resolve.js +1 -1
  17. package/src/actions/charts/bar.js +3 -0
  18. package/src/actions/charts/heatmap.js +73 -83
  19. package/src/actions/charts/index.js +1 -1
  20. package/src/actions/charts/parallel.js +35 -1
  21. package/src/actions/charts/shared.js +20 -0
  22. package/src/actions/composition/actions.js +46 -49
  23. package/src/actions/data/bin2d.js +105 -2
  24. package/src/actions/data/box.js +17 -17
  25. package/src/actions/data/density.js +42 -78
  26. package/src/actions/data/gradientProfile.js +22 -52
  27. package/src/actions/data/horizon.js +16 -41
  28. package/src/actions/data/index.js +10 -10
  29. package/src/actions/data/interval.js +16 -38
  30. package/src/actions/data/intervalEdit.js +55 -0
  31. package/src/actions/data/regression.js +7 -17
  32. package/src/actions/data/shared.js +47 -0
  33. package/src/actions/data/timeUnit.js +14 -38
  34. package/src/actions/data/window.js +14 -38
  35. package/src/actions/distributions/revision.js +154 -34
  36. package/src/actions/encodings/angle.js +9 -10
  37. package/src/actions/encodings/appearance.js +17 -51
  38. package/src/actions/encodings/density/resolve.js +3 -1
  39. package/src/actions/encodings/density.js +1 -15
  40. package/src/actions/encodings/horizon/resolve.js +13 -3
  41. package/src/actions/encodings/horizon.js +8 -7
  42. package/src/actions/encodings/index.js +9 -9
  43. package/src/actions/encodings/offset.js +8 -24
  44. package/src/actions/encodings/parallel.js +9 -10
  45. package/src/actions/encodings/pathOrder.js +6 -13
  46. package/src/actions/encodings/position/policies/index.js +4 -4
  47. package/src/actions/encodings/position/policies/line.js +47 -17
  48. package/src/actions/encodings/position/resolve.js +24 -5
  49. package/src/actions/encodings/ranged.js +18 -23
  50. package/src/actions/encodings/remove.js +15 -25
  51. package/src/actions/encodings/ruleAppearance.js +9 -24
  52. package/src/actions/encodings/shared.js +37 -0
  53. package/src/actions/encodings/strokeDash.js +8 -23
  54. package/src/actions/encodings/text.js +9 -10
  55. package/src/actions/errorBands/create.js +105 -39
  56. package/src/actions/errorBands/edit.js +89 -93
  57. package/src/actions/errorBands/index.js +1 -2
  58. package/src/actions/errorBars/create.js +78 -27
  59. package/src/actions/errorBars/edit.js +81 -48
  60. package/src/actions/errorBars/index.js +1 -2
  61. package/src/actions/facets/actions.js +45 -69
  62. package/src/actions/facets/derive.js +38 -53
  63. package/src/actions/facets/replay.js +0 -1
  64. package/src/actions/gradientPlots/components.js +59 -48
  65. package/src/actions/gradientPlots/edit.js +35 -216
  66. package/src/actions/gradientPlots/index.js +11 -10
  67. package/src/actions/gradientPlots/materialize.js +21 -35
  68. package/src/actions/gradientPlots/options.js +5 -7
  69. package/src/actions/gradientPlots/paint.js +9 -6
  70. package/src/actions/gradientPlots/resolve.js +1 -1
  71. package/src/actions/guides/axes/index.js +3 -3
  72. package/src/actions/guides/axes/labels.js +68 -26
  73. package/src/actions/guides/axes/lines.js +22 -2
  74. package/src/actions/guides/axes/policy.js +28 -8
  75. package/src/actions/guides/axes/ticks.js +27 -9
  76. package/src/actions/guides/axes/titles.js +113 -65
  77. package/src/actions/guides/grids/resolve.js +14 -0
  78. package/src/actions/guides/guides.js +2 -4
  79. package/src/actions/guides/index.js +2 -2
  80. package/src/actions/guides/legends/categorical/actions.js +44 -68
  81. package/src/actions/guides/legends/categorical/components.js +6 -1
  82. package/src/actions/guides/legends/categorical/index.js +2 -2
  83. package/src/actions/guides/legends/categorical/layout.js +148 -175
  84. package/src/actions/guides/legends/categorical/recipes.js +5 -0
  85. package/src/actions/guides/legends/categorical/resolve.js +8 -1
  86. package/src/actions/guides/legends/continuous/common.js +117 -66
  87. package/src/actions/guides/legends/continuous/gradient.js +38 -37
  88. package/src/actions/guides/legends/continuous/interval.js +97 -76
  89. package/src/actions/guides/legends/continuous/opacity.js +28 -38
  90. package/src/actions/guides/legends/edit.js +14 -1
  91. package/src/actions/guides/legends/index.js +1 -1
  92. package/src/actions/guides/legends/lane.js +2 -1
  93. package/src/actions/guides/legends/remove.js +8 -0
  94. package/src/actions/guides/legends/size.js +36 -43
  95. package/src/actions/guides/legends/strokeWidth.js +20 -26
  96. package/src/actions/guides/polar/axes/labels.js +17 -1
  97. package/src/actions/guides/polar/axes/shared.js +7 -1
  98. package/src/actions/guides/polar/axes/titles.js +19 -2
  99. package/src/actions/guides/polar/grids.js +4 -0
  100. package/src/actions/guides/polar/resolve.js +6 -0
  101. package/src/actions/index.js +13 -13
  102. package/src/actions/intervals/resolve.js +87 -117
  103. package/src/actions/marks/arc/actions.js +31 -36
  104. package/src/actions/marks/area/actions.js +23 -31
  105. package/src/actions/marks/index.js +6 -6
  106. package/src/actions/marks/line/actions.js +33 -52
  107. package/src/actions/marks/line/materialize.js +7 -5
  108. package/src/actions/marks/point/materialize.js +5 -3
  109. package/src/actions/marks/remove.js +21 -0
  110. package/src/actions/marks/rule/actions.js +53 -53
  111. package/src/actions/marks/shared.js +8 -4
  112. package/src/actions/marks/text/actions.js +14 -6
  113. package/src/actions/marks/text/layout.js +21 -45
  114. package/src/actions/marks/tick/actions.js +22 -21
  115. package/src/actions/primitives/createGraphics.js +4 -0
  116. package/src/actions/primitives/editGraphics.js +4 -0
  117. package/src/actions/primitives/index.js +3 -3
  118. package/src/actions/primitives/semantic.js +1 -1
  119. package/src/actions/primitives/semanticValidation/index.js +2 -2
  120. package/src/actions/primitives/semanticValidation/layer.js +1 -1
  121. package/src/actions/regression/edit.js +3 -43
  122. package/src/actions/scales/consumers/families.js +24 -4
  123. package/src/actions/scales/consumers/index.js +1 -1
  124. package/src/actions/scales/consumers/seriesLayout.js +17 -0
  125. package/src/actions/scales/definitions.js +100 -103
  126. package/src/actions/scales/materialize.js +5 -0
  127. package/src/actions/selection/actions.js +79 -121
  128. package/src/actions/titles/actions.js +48 -94
  129. package/src/actions/titles/resolve.js +14 -27
  130. package/src/actions/violinPlots/create.js +37 -44
  131. package/src/core/action.js +20 -5
  132. package/src/core/extensionRegistry.js +104 -0
  133. package/src/core/textMetrics.js +74 -37
  134. package/src/core/validation.js +18 -0
  135. package/src/extension.js +8 -1
  136. package/src/grammar/aggregate.js +48 -28
  137. package/src/grammar/arcs.js +58 -11
  138. package/src/grammar/areaSeries.js +14 -16
  139. package/src/grammar/bin2d.js +37 -23
  140. package/src/grammar/boxPlot.js +14 -13
  141. package/src/grammar/categoricalDensity.js +14 -14
  142. package/src/grammar/categoryOrder.js +8 -14
  143. package/src/grammar/curveCommands.js +87 -20
  144. package/src/grammar/density.js +162 -141
  145. package/src/grammar/facets/index.js +14 -0
  146. package/src/grammar/facets/scales.js +7 -4
  147. package/src/grammar/gradientProfile.js +12 -15
  148. package/src/grammar/histogram.js +145 -83
  149. package/src/grammar/horizon.js +60 -23
  150. package/src/grammar/interval.js +14 -13
  151. package/src/grammar/lineSeries.js +91 -61
  152. package/src/grammar/numeric.js +290 -0
  153. package/src/grammar/paint.js +3 -4
  154. package/src/grammar/palettes.js +21 -32
  155. package/src/grammar/pathCommands.js +6 -0
  156. package/src/grammar/polarPaths.js +5 -5
  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/scales/color.js +35 -26
  161. package/src/grammar/scales/continuous.js +71 -24
  162. package/src/grammar/scales/definition.js +1 -1
  163. package/src/grammar/scales/discretized.js +42 -12
  164. package/src/grammar/scales/fields.js +21 -4
  165. package/src/grammar/scales/ordinal.js +117 -61
  166. package/src/grammar/scales/temporal.js +40 -11
  167. package/src/grammar/scales/transformed.js +199 -52
  168. package/src/grammar/schemas/graphicBounds.js +270 -79
  169. package/src/grammar/seriesLayout.js +50 -18
  170. package/src/grammar/ticks.js +123 -53
  171. package/src/grammar/timeUnit.js +18 -8
  172. package/src/grammar/transforms.js +4 -1
  173. package/src/grammar/window.js +70 -57
  174. package/src/index.js +1 -1
  175. package/src/layout/canvas.js +4 -8
  176. package/src/layout/composition.js +39 -40
  177. package/src/layout/facets.js +10 -14
  178. package/src/layout/labels.js +91 -15
  179. package/src/layout/legend.js +22 -20
  180. package/src/layout/legendLane.js +54 -40
  181. package/src/layout/title.js +7 -7
  182. package/src/materialization/bars/aggregate.js +14 -40
  183. package/src/materialization/bars/grouped.js +7 -18
  184. package/src/materialization/bars/histogram.js +2 -19
  185. package/src/materialization/bars/ranged.js +20 -15
  186. package/src/materialization/bars/resolve.js +21 -0
  187. package/src/materialization/facetGuides/legacyCategorical.js +2 -1
  188. package/src/materialization/facetGuides/placement.js +34 -19
  189. package/src/materialization/facetGuides/preparation.js +9 -5
  190. package/src/materialization/facets.js +39 -27
  191. package/src/materialization/guides/resources.js +2 -1
  192. package/src/materialization/marks/capabilities.js +5 -2
  193. package/src/materialization/marks/index.js +24 -4
  194. package/src/materialization/scales/policies/bar.js +61 -59
  195. package/src/materialization/scales/policies/binnedPosition.js +34 -0
  196. package/src/materialization/scales/policies/series.js +13 -7
  197. package/src/materialization/scales/resolve.js +4 -5
  198. package/src/materialization/selection/items/arc.js +16 -2
  199. package/src/materialization/selection/items/bar.js +4 -2
  200. package/src/materialization/selection/items/common.js +21 -1
  201. package/src/materialization/selection/items/path.js +19 -2
  202. package/src/materialization/selection/items/point.js +4 -16
  203. package/src/materialization/selection/items/tick.js +13 -21
  204. package/src/materialization/selection/point.js +11 -4
  205. package/src/materialization/selection/styles.js +19 -27
  206. package/src/materialization/text.js +4 -1
  207. package/src/mcp/adapter.js +8 -3
  208. package/src/renderers/canvas/fill.js +2 -2
  209. package/src/renderers/canvas/index.js +35 -2
  210. package/src/renderers/canvas/native.js +160 -0
  211. package/src/renderers/pdf.js +14 -0
  212. package/src/renderers/svg.js +37 -4
  213. package/types/extension.d.ts +19 -0
  214. package/types/index.d.ts +3 -0
  215. package/types/program.d.ts +509 -131
  216. package/types/svg.d.ts +1 -0
  217. package/src/actions/errorBands/components.js +0 -81
  218. package/src/actions/errorBands/options.js +0 -39
  219. package/src/actions/errorBands/resolve.js +0 -28
  220. package/src/actions/errorBars/components.js +0 -62
  221. package/src/actions/errorBars/options.js +0 -58
  222. package/src/actions/errorBars/resolve.js +0 -25
@@ -1,38 +1,69 @@
1
1
  function codePointWidth(codePoint) {
2
2
  if (/\s/u.test(codePoint)) return 0.28;
3
- if (/[iIl1.,:;!'|]/u.test(codePoint)) return 0.27;
3
+ if (/[iIl.,:;!'|]/u.test(codePoint)) return 0.27;
4
4
  if (/[mwMW@#%&]/u.test(codePoint)) return 0.82;
5
- if (/[A-Z]/u.test(codePoint)) return 0.61;
6
- if (/[0-9]/u.test(codePoint)) return 0.53;
5
+ if (/[A-Z0-9]/u.test(codePoint)) return 0.61;
7
6
  if (/[-_]/u.test(codePoint)) return 0.34;
8
7
  if (codePoint.codePointAt(0) > 0x7f) return 1;
9
8
  return 0.47;
10
9
  }
11
10
 
12
- export function measureTextWidth(text, { fontSize } = {}) {
11
+ export function formatVisibleText(value) {
12
+ return String(value) || "(empty)";
13
+ }
14
+
15
+ export function textBoundsIntersect(first, second) {
16
+ return first.left < second.right && first.right > second.left &&
17
+ first.top < second.bottom && first.bottom > second.top;
18
+ }
19
+
20
+ export function textBoundsFitCanvas(bounds, canvas) {
21
+ const epsilon = 1e-9;
22
+ return bounds.left >= -epsilon && bounds.right <= canvas.width + epsilon &&
23
+ bounds.top >= -epsilon && bounds.bottom <= canvas.height + epsilon;
24
+ }
25
+
26
+ export function measureTextWidth(
27
+ text,
28
+ { fontSize, fontFamily, fontWeight } = {}
29
+ ) {
13
30
  if (typeof text !== "string") {
14
31
  throw new TypeError("Text measurement requires a string.");
15
32
  }
16
33
  if (!Number.isFinite(fontSize) || fontSize <= 0) {
17
34
  throw new RangeError("Text measurement requires a positive fontSize.");
18
35
  }
19
- return [...text].reduce(
20
- (width, codePoint) => width + codePointWidth(codePoint) * fontSize,
21
- 0
22
- );
23
- }
24
-
25
- function horizontalExtents(width, align) {
26
- if (align === "center") return [-width / 2, width / 2];
27
- if (["right", "end"].includes(align)) return [-width, 0];
28
- return [0, width];
36
+ let joined = false;
37
+ let width = 0;
38
+ for (const codePoint of text) {
39
+ if (codePoint === "\u200d") {
40
+ joined = true;
41
+ } else if (!/\p{Mark}|\p{Format}|\p{Emoji_Modifier}/u.test(codePoint)) {
42
+ if (joined) joined = false;
43
+ else width += codePointWidth(codePoint) * fontSize;
44
+ }
45
+ }
46
+ const family = typeof fontFamily === "string" ? fontFamily.toLowerCase() : "";
47
+ const familyFactor = /mono/u.test(family)
48
+ ? 1.08
49
+ : /serif/u.test(family) && !/sans-serif/u.test(family) ? 1.03 : 1;
50
+ const weight = Number(fontWeight);
51
+ width *= familyFactor * ((
52
+ Number.isFinite(weight)
53
+ ? weight >= 600
54
+ : /bold|black|heavy/iu.test(fontWeight ?? "")
55
+ ) ? 1.04 : 1);
56
+ if (!Number.isFinite(width)) {
57
+ throw new RangeError("Text measurement exceeds the finite numeric range.");
58
+ }
59
+ return width;
29
60
  }
30
61
 
31
- function verticalExtents(fontSize, baseline) {
32
- if (baseline === "middle") return [-fontSize / 2, fontSize / 2];
33
- if (["top", "hanging"].includes(baseline)) return [0, fontSize];
34
- if (["bottom", "ideographic"].includes(baseline)) return [-fontSize, 0];
35
- return [-fontSize * 0.8, fontSize * 0.2];
62
+ export function requireFiniteBounds(bounds, label = "Graphic") {
63
+ if (bounds !== undefined && !Object.values(bounds).every(Number.isFinite)) {
64
+ throw new RangeError(`${label} bounds exceed the finite numeric range.`);
65
+ }
66
+ return bounds;
36
67
  }
37
68
 
38
69
  export function resolveTextBounds({
@@ -40,6 +71,8 @@ export function resolveTextBounds({
40
71
  y,
41
72
  text,
42
73
  fontSize,
74
+ fontFamily,
75
+ fontWeight,
43
76
  textAlign = "left",
44
77
  textBaseline = "alphabetic",
45
78
  rotation = 0
@@ -47,24 +80,28 @@ export function resolveTextBounds({
47
80
  if (![x, y, rotation].every(Number.isFinite)) {
48
81
  throw new TypeError("Text bounds require finite x, y, and rotation values.");
49
82
  }
50
- const width = measureTextWidth(text, { fontSize });
51
- const [left, right] = horizontalExtents(width, textAlign);
52
- const [top, bottom] = verticalExtents(fontSize, textBaseline);
83
+ const width = measureTextWidth(text, { fontSize, fontFamily, fontWeight });
84
+ const [left, right] = textAlign === "center"
85
+ ? [-width / 2, width / 2]
86
+ : ["right", "end"].includes(textAlign) ? [-width, 0] : [0, width];
87
+ const [top, bottom] = textBaseline === "middle"
88
+ ? [-fontSize / 2, fontSize / 2]
89
+ : ["top", "hanging"].includes(textBaseline)
90
+ ? [0, fontSize]
91
+ : ["bottom", "ideographic"].includes(textBaseline)
92
+ ? [-fontSize, 0]
93
+ : [-fontSize * 0.8, fontSize * 0.2];
53
94
  const cosine = Math.cos(rotation);
54
95
  const sine = Math.sin(rotation);
55
- const corners = [
56
- [left, top],
57
- [right, top],
58
- [right, bottom],
59
- [left, bottom]
60
- ].map(([localX, localY]) => ({
61
- x: x + localX * cosine - localY * sine,
62
- y: y + localX * sine + localY * cosine
63
- }));
64
- return Object.freeze({
65
- left: Math.min(...corners.map(point => point.x)),
66
- right: Math.max(...corners.map(point => point.x)),
67
- top: Math.min(...corners.map(point => point.y)),
68
- bottom: Math.max(...corners.map(point => point.y))
69
- });
96
+ const bounds = {
97
+ left: x + Math.min(left * cosine, right * cosine) -
98
+ Math.max(top * sine, bottom * sine),
99
+ right: x + Math.max(left * cosine, right * cosine) -
100
+ Math.min(top * sine, bottom * sine),
101
+ top: y + Math.min(left * sine, right * sine) +
102
+ Math.min(top * cosine, bottom * cosine),
103
+ bottom: y + Math.max(left * sine, right * sine) +
104
+ Math.max(top * cosine, bottom * cosine)
105
+ };
106
+ return Object.freeze(requireFiniteBounds(bounds, "Text"));
70
107
  }
@@ -1,5 +1,23 @@
1
1
  import { isPlainObject } from "./immutable.js";
2
2
 
3
+ export const MAX_GENERATED_ITEMS = 10_000;
4
+ export const MAX_WORK_ITEMS = 10_000_000;
5
+
6
+ export function validateGeneratedItemLimit(
7
+ value,
8
+ label,
9
+ maximum = MAX_GENERATED_ITEMS
10
+ ) {
11
+ if (value > maximum) {
12
+ throw new RangeError(`${label} must not exceed ${maximum}.`);
13
+ }
14
+ return value;
15
+ }
16
+
17
+ export function validateWorkLimit(value, label) {
18
+ return validateGeneratedItemLimit(value, label, MAX_WORK_ITEMS);
19
+ }
20
+
3
21
  export function validateKeys(value, supported, label) {
4
22
  for (const key of Object.keys(value)) {
5
23
  if (!supported.includes(key)) {
package/src/extension.js CHANGED
@@ -1,2 +1,9 @@
1
1
  export { action } from "./core/action.js";
2
- export { ChartProgram } from "./ChartProgram.js";
2
+ import { ChartProgram } from "./ChartProgram.js";
3
+ import { registerProgramExtension } from "./core/extensionRegistry.js";
4
+
5
+ export { ChartProgram };
6
+
7
+ export function registerExtension(definition) {
8
+ registerProgramExtension(ChartProgram, definition);
9
+ }
@@ -1,4 +1,12 @@
1
1
  import { isPlainObject } from "../core/immutable.js";
2
+ import {
3
+ interpolateNumber,
4
+ numericExtent,
5
+ requireFiniteResult,
6
+ stableFiniteDeviation,
7
+ stableFiniteMean,
8
+ stableFiniteSum
9
+ } from "./numeric.js";
2
10
 
3
11
  export const SCALAR_AGGREGATE_OPERATIONS = Object.freeze([
4
12
  "count", "sum", "mean", "median", "min", "max",
@@ -8,8 +16,8 @@ export const SCALAR_AGGREGATE_OPERATIONS = Object.freeze([
8
16
  ]);
9
17
 
10
18
  const SCALAR_OPERATIONS = new Set(SCALAR_AGGREGATE_OPERATIONS);
11
- const NOMINAL_OPERATIONS = new Set(["count", "distinct", "valid", "missing"]);
12
- const PARAMETERIZED_OPERATIONS = new Set(["quantile", "first", "last"]);
19
+ const NOMINAL_OPERATIONS = ["count", "distinct", "valid", "missing"];
20
+ const PARAMETERIZED_OPERATIONS = ["quantile", "first", "last"];
13
21
 
14
22
  function nonEmptyString(value, label) {
15
23
  if (typeof value !== "string" || value.length === 0) {
@@ -22,7 +30,7 @@ export function isScalarAggregate(value) {
22
30
  }
23
31
 
24
32
  export function isParameterizedAggregate(value) {
25
- return isPlainObject(value) && PARAMETERIZED_OPERATIONS.has(value.op);
33
+ return isPlainObject(value) && PARAMETERIZED_OPERATIONS.includes(value.op);
26
34
  }
27
35
 
28
36
  export function isAggregate(value) {
@@ -95,7 +103,7 @@ export function validateScalarAggregateFieldType(operation, fieldType) {
95
103
  throw new Error("Scalar aggregate calculation requires a scalar operation.");
96
104
  }
97
105
  if (fieldType === "quantitative") return fieldType;
98
- if (fieldType === "nominal" && NOMINAL_OPERATIONS.has(operation)) {
106
+ if (fieldType === "nominal" && NOMINAL_OPERATIONS.includes(operation)) {
99
107
  return fieldType;
100
108
  }
101
109
  throw new Error(
@@ -139,8 +147,11 @@ function quantile(values, probability) {
139
147
  const position = (ordered.length - 1) * probability;
140
148
  const lower = Math.floor(position);
141
149
  const upper = Math.ceil(position);
142
- return ordered[lower] +
143
- (ordered[upper] - ordered[lower]) * (position - lower);
150
+ return interpolateNumber(
151
+ ordered[lower],
152
+ ordered[upper],
153
+ position - lower
154
+ );
144
155
  }
145
156
 
146
157
  function comparableOrderKey(value) {
@@ -179,15 +190,6 @@ function aggregateOrderedRows(rows, field, aggregate) {
179
190
  : candidates.at(-1).value;
180
191
  }
181
192
 
182
- function moments(values) {
183
- const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
184
- const squared = values.reduce(
185
- (sum, value) => sum + (value - mean) ** 2,
186
- 0
187
- );
188
- return { mean, squared };
189
- }
190
-
191
193
  export function aggregateScalarValues(values, operation) {
192
194
  if (!Array.isArray(values)) {
193
195
  throw new TypeError("Aggregate values must be an array.");
@@ -206,27 +208,45 @@ export function aggregateScalarValues(values, operation) {
206
208
  const finite = finiteValues(values);
207
209
  if (finite.length === 0) return undefined;
208
210
  if (operation === "sum") {
209
- return finite.reduce((sum, value) => sum + value, 0);
211
+ return stableFiniteSum(finite, "Aggregate sum");
210
212
  }
211
- if (operation === "min") return Math.min(...finite);
212
- if (operation === "max") return Math.max(...finite);
213
+ if (operation === "min") return numericExtent(finite)[0];
214
+ if (operation === "max") return numericExtent(finite)[1];
213
215
  if (operation === "median") return quantile(finite, 0.5);
214
216
  if (operation === "q1") return quantile(finite, 0.25);
215
217
  if (operation === "q3") return quantile(finite, 0.75);
216
218
 
217
- const { mean, squared } = moments(finite);
219
+ const mean = stableFiniteMean(finite, "Aggregate mean");
218
220
  if (operation === "mean") return mean;
219
- if (operation === "varianceP") return squared / finite.length;
220
- if (operation === "stdevP") return Math.sqrt(squared / finite.length);
221
+ if (["variance", "varianceP", "stdev", "stdevP"].includes(operation)) {
222
+ const population = operation.endsWith("P");
223
+ if (!population && finite.length < 2) return undefined;
224
+ const kind = population ? "population" : "sample";
225
+ const deviation = stableFiniteDeviation(finite, {
226
+ sample: !population,
227
+ label: `Aggregate ${kind} deviation`
228
+ });
229
+ if (operation.startsWith("stdev")) return deviation.deviation;
230
+ return deviation.squared === undefined
231
+ ? requireFiniteResult(
232
+ deviation.deviation ** 2,
233
+ `Aggregate ${kind} variance`
234
+ )
235
+ : deviation.squared / (finite.length - (population ? 0 : 1));
236
+ }
221
237
  if (finite.length < 2) return undefined;
222
-
223
- const variance = squared / (finite.length - 1);
224
- if (operation === "variance") return variance;
225
- if (operation === "stdev") return Math.sqrt(variance);
226
- const stderr = Math.sqrt(variance) / Math.sqrt(finite.length);
238
+ const stderr = stableFiniteDeviation(finite, {
239
+ sample: true,
240
+ divisor: Math.sqrt(finite.length),
241
+ label: "Aggregate standard error"
242
+ }).deviation;
227
243
  if (operation === "stderr") return stderr;
228
- if (operation === "ciLower") return mean - 1.96 * stderr;
229
- if (operation === "ciUpper") return mean + 1.96 * stderr;
244
+ if (operation === "ciLower") {
245
+ return requireFiniteResult(mean - 1.96 * stderr, "Aggregate lower CI");
246
+ }
247
+ if (operation === "ciUpper") {
248
+ return requireFiniteResult(mean + 1.96 * stderr, "Aggregate upper CI");
249
+ }
230
250
  throw new Error(`Unsupported scalar aggregate "${operation}".`);
231
251
  }
232
252
 
@@ -5,6 +5,12 @@ import {
5
5
  readNominalField,
6
6
  readQuantitativeField
7
7
  } from "./scales/index.js";
8
+ import {
9
+ interpolateNumber,
10
+ normalizedFiniteSum,
11
+ requireFiniteResult,
12
+ stableFiniteSum
13
+ } from "./numeric.js";
8
14
 
9
15
  function requireArcLayer(layer) {
10
16
  if (layer?.mark?.type !== "arc") {
@@ -22,6 +28,9 @@ function requireBandScale(scale, label) {
22
28
  scale?.type !== "band" ||
23
29
  !Array.isArray(scale.domain) ||
24
30
  !Array.isArray(scale.range) ||
31
+ scale.range.length !== 2 ||
32
+ !scale.range.every(Number.isFinite) ||
33
+ scale.range[0] === scale.range[1] ||
25
34
  !Number.isFinite(scale.bandwidth) ||
26
35
  scale.bandwidth <= 0
27
36
  ) {
@@ -73,31 +82,69 @@ function proportionalSectors(rows, layer, thetaScale, frame, innerRadiusRatio) {
73
82
  throw new Error(`Arc theta value "${values[index]}" is outside the scale domain.`);
74
83
  }
75
84
  group.push({
76
- row: rows[index],
77
85
  color: colors[index],
78
86
  sourceIndex: index,
79
87
  weight: weights[index]
80
88
  });
81
89
  }
82
- const total = [...groups.values()].reduce(
83
- (sum, group) => sum + group.reduce((groupSum, item) => groupSum + item.weight, 0),
84
- 0
90
+ if (!Number.isFinite(frame?.availableRadius) || frame.availableRadius < 0) {
91
+ throw new RangeError(`Arc mark "${layer.id}" requires a finite available radius.`);
92
+ }
93
+ const innerRadius = requireFiniteResult(
94
+ frame.availableRadius * innerRadiusRatio,
95
+ `Arc mark "${layer.id}" inner radius`
85
96
  );
86
- if (total === 0) throw new Error(`Arc mark "${layer.id}" has no positive theta weight.`);
87
- const start = thetaScale.range[0];
88
- const span = thetaScale.range[1] - thetaScale.range[0];
89
- const innerRadius = frame.availableRadius * innerRadiusRatio;
90
- let cursor = start;
97
+ let cursor = thetaScale.range[0];
91
98
  const sectors = [];
92
99
  const positiveValues = thetaScale.domain.filter(value =>
93
100
  groups.get(value).some(item => item.weight > 0)
94
101
  );
102
+ const aggregateValues = positiveValues.map(value => stableFiniteSum(
103
+ groups.get(value).map(item => item.weight),
104
+ `Arc theta group "${String(value)}" aggregate`
105
+ ));
106
+ const total = aggregateValues.reduce((sum, value) => sum + value, 0);
107
+ const normalizedTotal = Number.isFinite(total)
108
+ ? undefined
109
+ : normalizedFiniteSum(
110
+ aggregateValues,
111
+ `Arc mark "${layer.id}" theta aggregates`
112
+ );
113
+ if (total === 0 || normalizedTotal?.total <= 0) {
114
+ throw new Error(`Arc mark "${layer.id}" has no positive theta weight.`);
115
+ }
116
+ const span = thetaScale.range[1] - thetaScale.range[0];
117
+ let cumulative = 0;
118
+ let cumulativeCorrection = 0;
95
119
  for (const [positiveIndex, value] of positiveValues.entries()) {
96
120
  const group = groups.get(value);
97
- const aggregateValue = group.reduce((sum, item) => sum + item.weight, 0);
121
+ const aggregateValue = aggregateValues[positiveIndex];
122
+ const normalizedValue = normalizedTotal === undefined
123
+ ? aggregateValue
124
+ : aggregateValue / normalizedTotal.scale;
125
+ const next = cumulative + normalizedValue;
126
+ if (normalizedTotal !== undefined) {
127
+ cumulativeCorrection += Math.abs(cumulative) >= Math.abs(normalizedValue)
128
+ ? cumulative - next + normalizedValue
129
+ : normalizedValue - next + cumulative;
130
+ }
131
+ cumulative = next;
132
+ const directEnd = cursor + aggregateValue / total * span;
98
133
  const endTheta = positiveIndex === positiveValues.length - 1
99
134
  ? thetaScale.range[1]
100
- : cursor + aggregateValue / total * span;
135
+ : normalizedTotal === undefined && Number.isFinite(directEnd)
136
+ ? directEnd
137
+ : interpolateNumber(
138
+ thetaScale.range[0],
139
+ thetaScale.range[1],
140
+ (cumulative + cumulativeCorrection) /
141
+ (normalizedTotal?.total ?? total)
142
+ );
143
+ if (!Number.isFinite(endTheta) || endTheta === cursor) {
144
+ throw new RangeError(
145
+ `Arc theta group "${String(value)}" cannot resolve a distinct finite sector.`
146
+ );
147
+ }
101
148
  const distinctColors = [...new Set(group.map(item => item.color))];
102
149
  if (distinctColors.length > 1) {
103
150
  throw new Error(
@@ -11,6 +11,12 @@ import {
11
11
  import { stableOrderPathValues } from "./pathOrder.js";
12
12
  import { deriveCategoricalDensitySeries } from "./categoricalDensity.js";
13
13
 
14
+ function indexSegments(segments) {
15
+ const indexed = [];
16
+ for (const segment of segments) indexed[segment.index] = segment;
17
+ return indexed;
18
+ }
19
+
14
20
  export function deriveAreaSeries(rows, layer) {
15
21
  if (layer?.mark?.type !== "area") {
16
22
  throw new Error("Area series derivation requires a semantic area mark.");
@@ -187,15 +193,11 @@ export function deriveCenteredAreaSeries(rows, layer) {
187
193
  const valuesBySeries = groupOrder.map(() => []);
188
194
  for (const position of orderedPositions) {
189
195
  const partition = groupOrder.map(key => byGroup.get(key).get(position));
190
- const segments = new Map(
191
- layoutSeriesPartition(partition, "center").map(segment => [
192
- segment.index,
193
- segment
194
- ])
195
- );
196
- let endpoint = -partition.reduce((sum, value) => sum + value, 0) / 2;
196
+ const resolvedSegments = layoutSeriesPartition(partition, "center");
197
+ const segments = indexSegments(resolvedSegments);
198
+ let endpoint = resolvedSegments[0]?.start ?? 0;
197
199
  for (let index = 0; index < groupOrder.length; index += 1) {
198
- const segment = segments.get(index);
200
+ const segment = segments[index];
199
201
  valuesBySeries[index].push({
200
202
  x: position,
201
203
  y: partition[index],
@@ -331,17 +333,13 @@ export function layoutDensityAreaSeries(derived, layout = "overlay") {
331
333
  }
332
334
  return series.values[sample].y;
333
335
  });
334
- const segments = new Map(
335
- layoutSeriesPartition(densities, layout).map(segment => [
336
- segment.index,
337
- segment
338
- ])
339
- );
336
+ const resolvedSegments = layoutSeriesPartition(densities, layout);
337
+ const segments = indexSegments(resolvedSegments);
340
338
  let zeroThicknessEndpoint = layout === "center"
341
- ? -densities.reduce((sum, value) => sum + value, 0) / 2
339
+ ? resolvedSegments[0]?.start ?? 0
342
340
  : 0;
343
341
  for (let index = 0; index < derived.series.length; index += 1) {
344
- const segment = segments.get(index);
342
+ const segment = segments[index];
345
343
  valuesBySeries[index].push({
346
344
  x,
347
345
  lower: segment?.start ?? zeroThicknessEndpoint,
@@ -2,25 +2,22 @@ import {
2
2
  cloneAndFreeze,
3
3
  isPlainObject
4
4
  } from "../core/immutable.js";
5
+ import { validateNonEmptyString as requireField } from "../core/validation.js";
6
+ import { interpolateNumber, numericExtent } from "./numeric.js";
7
+
8
+ const MAX_AXIS_BINS = 10_000;
9
+ const MAX_CELLS = 1_000_000;
5
10
 
6
11
  const TRANSFORM_KEYS = Object.freeze([
7
12
  "type", "x", "y", "bins", "extent", "includeEmpty", "members", "as",
8
13
  "resolved"
9
14
  ]);
10
- const BIN_KEYS = Object.freeze(["x", "y"]);
11
- const EXTENT_KEYS = Object.freeze(["x", "y"]);
15
+ const AXIS_KEYS = Object.freeze(["x", "y"]);
12
16
  const FIELD_KEYS = Object.freeze(["x0", "x1", "y0", "y1", "count", "members"]);
13
17
  const RESOLVED_KEYS = Object.freeze([
14
18
  "extent", "edges", "eligibleCount", "occupiedCount"
15
19
  ]);
16
20
 
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
21
  function rejectUnknownKeys(value, supported, label) {
25
22
  const unknown = Object.keys(value).find(key => !supported.includes(key));
26
23
  if (unknown !== undefined) {
@@ -43,7 +40,7 @@ function normalizeBins(value) {
43
40
  "2D bin bins must be a positive integer or an object with x and y."
44
41
  );
45
42
  }
46
- rejectUnknownKeys(value, BIN_KEYS, "2D bin bins");
43
+ rejectUnknownKeys(value, AXIS_KEYS, "2D bin bins");
47
44
  return { x: value.x, y: value.y };
48
45
  }
49
46
 
@@ -51,9 +48,15 @@ function validateBins(value) {
51
48
  if (!isPlainObject(value)) {
52
49
  throw new TypeError("2D bin bins must be an object.");
53
50
  }
54
- rejectUnknownKeys(value, BIN_KEYS, "2D bin bins");
51
+ rejectUnknownKeys(value, AXIS_KEYS, "2D bin bins");
55
52
  requirePositiveInteger(value.x, "2D bin x bins");
56
53
  requirePositiveInteger(value.y, "2D bin y bins");
54
+ if (value.x > MAX_AXIS_BINS || value.y > MAX_AXIS_BINS) {
55
+ throw new RangeError(`2D bin axes support at most ${MAX_AXIS_BINS} bins.`);
56
+ }
57
+ if (value.x * value.y > MAX_CELLS) {
58
+ throw new RangeError(`2D bin grids support at most ${MAX_CELLS} cells.`);
59
+ }
57
60
  }
58
61
 
59
62
  function normalizeRequestedExtent(value) {
@@ -61,7 +64,7 @@ function normalizeRequestedExtent(value) {
61
64
  if (!isPlainObject(value)) {
62
65
  throw new TypeError("2D bin extent must be an object.");
63
66
  }
64
- rejectUnknownKeys(value, EXTENT_KEYS, "2D bin extent");
67
+ rejectUnknownKeys(value, AXIS_KEYS, "2D bin extent");
65
68
  return {
66
69
  x: value.x ?? "auto",
67
70
  y: value.y ?? "auto"
@@ -84,7 +87,7 @@ function validateRequestedExtent(value) {
84
87
  if (!isPlainObject(value)) {
85
88
  throw new TypeError("2D bin extent must be an object.");
86
89
  }
87
- rejectUnknownKeys(value, EXTENT_KEYS, "2D bin extent");
90
+ rejectUnknownKeys(value, AXIS_KEYS, "2D bin extent");
88
91
  validateExtentValue(value.x, "2D bin x extent");
89
92
  validateExtentValue(value.y, "2D bin y extent");
90
93
  }
@@ -152,9 +155,9 @@ function validateResolved(value, bins) {
152
155
  if (!isPlainObject(value.extent) || !isPlainObject(value.edges)) {
153
156
  throw new TypeError("Resolved 2D bin extent and edges must be objects.");
154
157
  }
155
- rejectUnknownKeys(value.extent, EXTENT_KEYS, "resolved 2D bin extent");
156
- rejectUnknownKeys(value.edges, EXTENT_KEYS, "resolved 2D bin edges");
157
- for (const axis of ["x", "y"]) {
158
+ rejectUnknownKeys(value.extent, AXIS_KEYS, "resolved 2D bin extent");
159
+ rejectUnknownKeys(value.edges, AXIS_KEYS, "resolved 2D bin edges");
160
+ for (const axis of AXIS_KEYS) {
158
161
  validateExtentValue(
159
162
  value.extent[axis],
160
163
  `Resolved 2D bin ${axis} extent`,
@@ -223,8 +226,7 @@ export function normalizeBin2DTransform({
223
226
  }
224
227
 
225
228
  function resolveExtent(values, requested, axis) {
226
- const minimum = Math.min(...values);
227
- const maximum = Math.max(...values);
229
+ const [minimum, maximum] = numericExtent(values);
228
230
  const extent = requested === "auto" ? [minimum, maximum] : requested;
229
231
  if (extent[0] > minimum || extent[1] < maximum) {
230
232
  throw new RangeError(
@@ -237,12 +239,24 @@ function resolveExtent(values, requested, axis) {
237
239
  return [...extent];
238
240
  }
239
241
 
240
- function createEdges(extent, count) {
242
+ function createEdges(extent, count, axis) {
241
243
  const [minimum, maximum] = extent;
242
244
  const step = (maximum - minimum) / count;
243
- return Array.from({ length: count + 1 }, (_, index) =>
244
- index === count ? maximum : minimum + step * index
245
+ const edges = Array.from({ length: count + 1 }, (_, index) =>
246
+ index === count
247
+ ? maximum
248
+ : Number.isFinite(step)
249
+ ? minimum + step * index
250
+ : interpolateNumber(minimum, maximum, index / count)
245
251
  );
252
+ if (edges.some((edge, index) =>
253
+ !Number.isFinite(edge) || index > 0 && edge <= edges[index - 1]
254
+ )) {
255
+ throw new RangeError(
256
+ `2D bin ${axis} extent cannot represent ${count} distinct bins.`
257
+ );
258
+ }
259
+ return edges;
246
260
  }
247
261
 
248
262
  function findBinIndex(value, edges) {
@@ -287,8 +301,8 @@ export function deriveBin2DRows(rows, transform) {
287
301
  )
288
302
  };
289
303
  const edges = {
290
- x: createEdges(extent.x, transform.bins.x),
291
- y: createEdges(extent.y, transform.bins.y)
304
+ x: createEdges(extent.x, transform.bins.x, "x"),
305
+ y: createEdges(extent.y, transform.bins.y, "y")
292
306
  };
293
307
  const buckets = Array.from(
294
308
  { length: transform.bins.x * transform.bins.y },
@@ -1,4 +1,5 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import { interpolateNumber, stableDecimal } from "./numeric.js";
2
3
 
3
4
  export const BOX_FIELDS = Object.freeze({
4
5
  q1: "__boxPlot_q1", median: "__boxPlot_median", q3: "__boxPlot_q3",
@@ -7,10 +8,10 @@ export const BOX_FIELDS = Object.freeze({
7
8
  count: "__boxPlot_count"
8
9
  });
9
10
 
10
- const TRANSFORM_KEYS = Object.freeze([
11
+ const TRANSFORM_KEYS = [
11
12
  "type", "category", "field", "method", "whisker", "factor", "as"
12
- ]);
13
- const OUTPUT_KEYS = Object.freeze(Object.keys(BOX_FIELDS));
13
+ ];
14
+ const OUTPUT_KEYS = Object.keys(BOX_FIELDS);
14
15
 
15
16
  function field(value, label) {
16
17
  if (typeof value !== "string" || value.length === 0) throw new TypeError(`${label} must be a non-empty string.`);
@@ -82,11 +83,7 @@ function quantile(sorted, p) {
82
83
  const position = (sorted.length - 1) * p;
83
84
  const lower = Math.floor(position);
84
85
  const upper = Math.ceil(position);
85
- return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
86
- }
87
-
88
- function stableNumber(value) {
89
- return Number(value.toFixed(12));
86
+ return interpolateNumber(sorted[lower], sorted[upper], position - lower);
90
87
  }
91
88
 
92
89
  export function deriveBoxData(rows, transform) {
@@ -113,15 +110,19 @@ export function deriveBoxData(rows, transform) {
113
110
  const outlierSet = new Set();
114
111
  for (const group of groups) {
115
112
  const sorted = group.rows.map(row => row[measure]).sort((a, b) => a - b);
116
- const q1 = stableNumber(quantile(sorted, 0.25));
117
- const median = stableNumber(quantile(sorted, 0.5));
118
- const q3 = stableNumber(quantile(sorted, 0.75));
113
+ const [q1, median, q3] = [0.25, 0.5, 0.75].map(probability =>
114
+ stableDecimal(quantile(sorted, probability))
115
+ );
116
+ const spread = q3 - q1;
119
117
  const lowerFence = whisker === "minmax"
120
118
  ? sorted[0]
121
- : stableNumber(q1 - factor * (q3 - q1));
119
+ : stableDecimal(q1 - factor * spread);
122
120
  const upperFence = whisker === "minmax"
123
121
  ? sorted.at(-1)
124
- : stableNumber(q3 + factor * (q3 - q1));
122
+ : stableDecimal(q3 + factor * spread);
123
+ if (![lowerFence, upperFence].every(Number.isFinite)) {
124
+ throw new RangeError("Box Tukey fences are outside the finite numeric range.");
125
+ }
125
126
  const inliers = sorted.filter(value => value >= lowerFence && value <= upperFence);
126
127
  const lowerWhisker = inliers[0];
127
128
  const upperWhisker = inliers.at(-1);