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
@@ -85,18 +85,16 @@ export function deriveCategoricalDensitySeries(rows, layer, transform) {
85
85
  if (placement.split !== undefined && resolvedSplitDomain === undefined) {
86
86
  throw new Error("Categorical density split requires a resolved two-value domain.");
87
87
  }
88
+ const resolvedPlacement = {
89
+ ...placement,
90
+ ...(resolvedSplitDomain === undefined ? {} : { resolvedSplitDomain })
91
+ };
88
92
  return cloneAndFreeze({
89
93
  mode: placement.channel === "x" ? "category-x" : "category-y",
90
- placement: {
91
- ...placement,
92
- ...(resolvedSplitDomain === undefined ? {} : { resolvedSplitDomain })
93
- },
94
+ placement: resolvedPlacement,
94
95
  series: series.map(item => ({
95
96
  ...item,
96
- side: seriesSide({
97
- ...placement,
98
- ...(resolvedSplitDomain === undefined ? {} : { resolvedSplitDomain })
99
- }, item.split),
97
+ side: seriesSide(resolvedPlacement, item.split),
100
98
  values: item.values.sort((left, right) => left.value - right.value)
101
99
  }))
102
100
  });
@@ -109,9 +107,12 @@ function maximumFor(derived, series) {
109
107
  candidate.category,
110
108
  series.category
111
109
  ));
112
- const maximum = Math.max(...candidates.flatMap(candidate =>
113
- candidate.values.map(value => value.density)
114
- ));
110
+ let maximum = -Infinity;
111
+ for (const candidate of candidates) {
112
+ for (const value of candidate.values) {
113
+ maximum = Math.max(maximum, value.density);
114
+ }
115
+ }
115
116
  if (!Number.isFinite(maximum) || maximum <= 0) {
116
117
  throw new Error("Categorical density width requires a positive maximum.");
117
118
  }
@@ -163,7 +164,7 @@ export function buildCategoricalDensityPaths(
163
164
  derived.series.map(series => series.category),
164
165
  categoryScale
165
166
  );
166
- const commands = derived.series.map((series, index) => {
167
+ return cloneAndFreeze(derived.series.map((series, index) => {
167
168
  const boundaries = pathBoundaries(
168
169
  series,
169
170
  centers[index],
@@ -187,6 +188,5 @@ export function buildCategoricalDensityPaths(
187
188
  ],
188
189
  { close: true }
189
190
  );
190
- });
191
- return cloneAndFreeze(commands);
191
+ }));
192
192
  }
@@ -1,8 +1,9 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
2
  import { isNominalValue, readNominalField } from "./scales/fields.js";
3
+ import { aggregateScalarValues } from "./aggregate.js";
3
4
 
4
- const DIRECTIONS = Object.freeze(["ascending", "descending"]);
5
- const AGGREGATES = Object.freeze(["sum", "mean", "min", "max"]);
5
+ const DIRECTIONS = ["ascending", "descending"];
6
+ const AGGREGATES = ["sum", "mean", "min", "max"];
6
7
 
7
8
  function nonEmptyField(value, label) {
8
9
  if (typeof value !== "string" || value.length === 0) {
@@ -11,9 +12,7 @@ function nonEmptyField(value, label) {
11
12
  return value;
12
13
  }
13
14
 
14
- function sameValue(left, right) {
15
- return Object.is(left, right);
16
- }
15
+ const sameValue = Object.is;
17
16
 
18
17
  export function normalizeCategoryOrder({ values, by, direction } = {}) {
19
18
  const hasValues = values !== undefined;
@@ -94,15 +93,10 @@ function summaryValues(rows, categoryField, summary) {
94
93
  values.push(value);
95
94
  groups.set(category, values);
96
95
  }
97
- return new Map([...groups].map(([category, values]) => {
98
- let value;
99
- if (summary.aggregate === "sum") value = values.reduce((sum, item) => sum + item, 0);
100
- else if (summary.aggregate === "mean") {
101
- value = values.reduce((sum, item) => sum + item, 0) / values.length;
102
- } else if (summary.aggregate === "min") value = Math.min(...values);
103
- else value = Math.max(...values);
104
- return [category, value];
105
- }));
96
+ return new Map([...groups].map(([category, values]) => [
97
+ category,
98
+ aggregateScalarValues(values, summary.aggregate)
99
+ ]));
106
100
  }
107
101
 
108
102
  export function resolveCategoryOrder(rows, categoryField, order) {
@@ -1,4 +1,5 @@
1
1
  import { freezeOwned, isPlainObject } from "../core/immutable.js";
2
+ import { validateGeneratedItemLimit } from "../core/validation.js";
2
3
  import { buildLinearPathCommands } from "./pathCommands.js";
3
4
 
4
5
  export const CURVE_INTERPOLATIONS = Object.freeze([
@@ -154,11 +155,12 @@ function buildCardinalCommands(points) {
154
155
  }
155
156
 
156
157
  function monotoneTangents(points) {
158
+ const direction = Math.sign(points[1].x - points[0].x);
157
159
  const intervals = points.slice(1).map((point, index) => {
158
160
  const previous = points[index];
159
161
  const width = point.x - previous.x;
160
- if (!(width > 0)) {
161
- throw new Error("Monotone curve requires strictly increasing x values.");
162
+ if (width === 0 || Math.sign(width) !== direction) {
163
+ throw new Error("Monotone curve requires strictly monotonic x values.");
162
164
  }
163
165
  return { width, slope: (point.y - previous.y) / width };
164
166
  });
@@ -263,26 +265,83 @@ function buildNaturalCommands(points) {
263
265
  return ownCommands(commands);
264
266
  }
265
267
 
266
- export function buildCurvePathCommands(points, curve = "linear") {
267
- const validatedPoints = validatePoints(points);
268
- const validatedCurve = validateCurveInterpolation(curve);
269
- if (validatedCurve === "linear" ||
270
- (SMOOTH_CURVES.has(validatedCurve) &&
271
- validatedCurve !== "monotone" &&
268
+ function buildValidatedCurvePathCommands(points, curve) {
269
+ if (curve === "linear" ||
270
+ (SMOOTH_CURVES.has(curve) &&
271
+ curve !== "monotone" &&
272
272
  points.length < 3)) {
273
- return buildLinearPathCommands(validatedPoints);
273
+ return buildLinearPathCommands(points);
274
274
  }
275
- if (validatedCurve.startsWith("step")) {
276
- return buildStepCommands(validatedPoints, validatedCurve);
275
+ if (curve.startsWith("step")) {
276
+ return buildStepCommands(points, curve);
277
277
  }
278
- if (validatedCurve === "basis") return buildBasisCommands(validatedPoints);
279
- if (validatedCurve === "cardinal") {
280
- return buildCardinalCommands(validatedPoints);
278
+ if (curve === "basis") return buildBasisCommands(points);
279
+ if (curve === "cardinal") {
280
+ return buildCardinalCommands(points);
281
281
  }
282
- if (validatedCurve === "monotone") {
283
- return buildMonotoneCommands(validatedPoints);
282
+ if (curve === "monotone") {
283
+ return buildMonotoneCommands(points);
284
+ }
285
+ return buildNaturalCommands(points);
286
+ }
287
+
288
+ function finiteCommands(commands) {
289
+ return commands.every(command => Object.values(command).every(value =>
290
+ typeof value !== "number" || Number.isFinite(value)
291
+ ));
292
+ }
293
+
294
+ function curveCommandCount(pointCount, curve) {
295
+ if (curve === "step") return pointCount * 3 - 2;
296
+ if (curve === "step-before" || curve === "step-after") {
297
+ return pointCount * 2 - 1;
284
298
  }
285
- return buildNaturalCommands(validatedPoints);
299
+ return curve === "basis" && pointCount > 2
300
+ ? pointCount + 2
301
+ : pointCount;
302
+ }
303
+
304
+ export function buildCurvePathCommands(points, curve = "linear") {
305
+ const validatedPoints = validatePoints(points);
306
+ const validatedCurve = validateCurveInterpolation(curve);
307
+ validateGeneratedItemLimit(
308
+ curveCommandCount(validatedPoints.length, validatedCurve),
309
+ "Curve path command count"
310
+ );
311
+ const direct = buildValidatedCurvePathCommands(
312
+ validatedPoints,
313
+ validatedCurve
314
+ );
315
+ if (finiteCommands(direct)) return direct;
316
+
317
+ let xScale = 0;
318
+ let yScale = 0;
319
+ for (const point of validatedPoints) {
320
+ xScale = Math.max(xScale, Math.abs(point.x));
321
+ yScale = Math.max(yScale, Math.abs(point.y));
322
+ }
323
+ xScale ||= 1;
324
+ yScale ||= 1;
325
+ const normalized = buildValidatedCurvePathCommands(
326
+ validatedPoints.map(point => ({
327
+ x: point.x / xScale,
328
+ y: point.y / yScale
329
+ })),
330
+ validatedCurve
331
+ );
332
+ const restored = normalized.map(command => {
333
+ const copy = {};
334
+ for (const [key, value] of Object.entries(command)) {
335
+ copy[key] = typeof value === "number"
336
+ ? value * (key[0] === "x" ? xScale : yScale)
337
+ : value;
338
+ }
339
+ return copy;
340
+ });
341
+ if (!finiteCommands(restored)) {
342
+ throw new RangeError("Curve interpolation exceeds the finite numeric range.");
343
+ }
344
+ return ownCommands(restored);
286
345
  }
287
346
 
288
347
  function reverseOpenPathCommands(commands) {
@@ -341,13 +400,21 @@ export function buildAreaCurvePathCommands(
341
400
  curve = "linear",
342
401
  { independentAxis = "x" } = {}
343
402
  ) {
403
+ const validatedCurve = validateCurveInterpolation(curve);
404
+ const validatedLower = validatePoints(lowerPoints);
405
+ const validatedUpper = validatePoints(upperPoints);
406
+ validateGeneratedItemLimit(
407
+ curveCommandCount(validatedLower.length, validatedCurve) +
408
+ curveCommandCount(validatedUpper.length, validatedCurve) + 1,
409
+ "Area path command count"
410
+ );
344
411
  const lower = buildOrientedCurvePathCommands(
345
- lowerPoints,
346
- curve,
412
+ validatedLower,
413
+ validatedCurve,
347
414
  independentAxis
348
415
  );
349
416
  const upper = reverseOpenPathCommands(
350
- buildOrientedCurvePathCommands(upperPoints, curve, independentAxis)
417
+ buildOrientedCurvePathCommands(validatedUpper, validatedCurve, independentAxis)
351
418
  );
352
419
  return ownCommands([
353
420
  ...lower,
@@ -1,14 +1,34 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import {
3
+ validateGeneratedItemLimit,
4
+ validateNonEmptyString as requireField,
5
+ validateWorkLimit
6
+ } from "../core/validation.js";
2
7
  import { isNominalValue } from "./scales/index.js";
8
+ import { interpolateNumber } from "./numeric.js";
9
+ import {
10
+ requireFiniteResult,
11
+ stableFiniteDeviation
12
+ } from "./numeric.js";
3
13
 
4
14
  const SQRT_TWO_PI = Math.sqrt(2 * Math.PI);
5
15
 
6
- export const DENSITY_KERNELS = Object.freeze([
7
- "gaussian",
8
- "epanechnikov",
9
- "uniform",
10
- "triangular"
11
- ]);
16
+ const KERNEL_FUNCTIONS = Object.freeze({
17
+ gaussian(value) {
18
+ return Math.exp(-0.5 * value ** 2) / SQRT_TWO_PI;
19
+ },
20
+ epanechnikov(value) {
21
+ return Math.abs(value) <= 1 ? 0.75 * (1 - value ** 2) : 0;
22
+ },
23
+ uniform(value) {
24
+ return Math.abs(value) <= 1 ? 0.5 : 0;
25
+ },
26
+ triangular(value) {
27
+ return Math.abs(value) <= 1 ? 1 - Math.abs(value) : 0;
28
+ }
29
+ });
30
+
31
+ export const DENSITY_KERNELS = Object.freeze(Object.keys(KERNEL_FUNCTIONS));
12
32
  export const DENSITY_NORMALIZATIONS = Object.freeze(["unit", "count"]);
13
33
  export const DENSITY_PLACEMENT_SIDES = Object.freeze([
14
34
  "both", "left", "right", "top", "bottom"
@@ -17,6 +37,13 @@ export const DENSITY_WIDTH_RESOLUTIONS = Object.freeze([
17
37
  "shared", "independent"
18
38
  ]);
19
39
 
40
+ export function validateDensitySteps(value, label = "Density steps") {
41
+ if (!Number.isInteger(value) || value < 2) {
42
+ throw new RangeError(`${label} must be an integer of at least 2.`);
43
+ }
44
+ return validateGeneratedItemLimit(value, label);
45
+ }
46
+
20
47
  export function validateDensityKernel(value) {
21
48
  if (!DENSITY_KERNELS.includes(value)) {
22
49
  throw new Error(`Unsupported density kernel "${value}".`);
@@ -31,9 +58,26 @@ export function validateDensityNormalization(value) {
31
58
  return value;
32
59
  }
33
60
 
34
- function requireField(value, label) {
35
- if (typeof value !== "string" || value.length === 0) {
36
- throw new TypeError(`${label} must be a non-empty string.`);
61
+ function validateDensityBandwidth(value) {
62
+ if (value !== "auto" && (!Number.isFinite(value) || value <= 0)) {
63
+ throw new RangeError(
64
+ "Density bandwidth must be a positive finite number or auto."
65
+ );
66
+ }
67
+ return value;
68
+ }
69
+
70
+ function validateDensityExtent(value) {
71
+ if (
72
+ value !== "auto" &&
73
+ (!Array.isArray(value) ||
74
+ value.length !== 2 ||
75
+ !value.every(Number.isFinite) ||
76
+ value[0] >= value[1])
77
+ ) {
78
+ throw new RangeError(
79
+ "Density extent must be an ascending pair of finite numbers or auto."
80
+ );
37
81
  }
38
82
  return value;
39
83
  }
@@ -52,6 +96,27 @@ function validateSplitDomain(value) {
52
96
  return [...value];
53
97
  }
54
98
 
99
+ function validateOutputFields(value, field, groupBy, placement) {
100
+ if (
101
+ !Array.isArray(value) ||
102
+ value.length !== 2 ||
103
+ !value.every(field => typeof field === "string" && field.length > 0) ||
104
+ value[0] === value[1]
105
+ ) {
106
+ throw new TypeError("Density as must contain two distinct non-empty fields.");
107
+ }
108
+ const collisions = new Set([
109
+ field,
110
+ groupBy,
111
+ placement?.categoryField,
112
+ placement?.split?.field
113
+ ].filter(Boolean));
114
+ if (value.some(field => collisions.has(field))) {
115
+ throw new Error("Density output fields must not collide with source or group fields.");
116
+ }
117
+ return value;
118
+ }
119
+
55
120
  function validatePlacementWidth(value = {}) {
56
121
  if (!isPlainObject(value)) {
57
122
  throw new TypeError("Density placement width must be a plain object.");
@@ -183,10 +248,9 @@ function validateStoredDensityPlacement(value, groupBy) {
183
248
  )
184
249
  );
185
250
  if (
186
- normalized.type !== value.type ||
187
- normalized.channel !== value.channel ||
188
- normalized.categoryField !== value.categoryField ||
189
- normalized.side !== value.side ||
251
+ ["type", "channel", "categoryField", "side"].some(
252
+ property => normalized[property] !== value[property]
253
+ ) ||
190
254
  value.width === undefined ||
191
255
  normalized.width.band !== value.width.band ||
192
256
  normalized.width.resolve !== value.width.resolve ||
@@ -218,47 +282,15 @@ export function validateDensityTransform(transform) {
218
282
  }
219
283
  validateDensityKernel(transform.kernel ?? "gaussian");
220
284
  validateDensityNormalization(transform.normalization ?? "unit");
221
- if (
222
- transform.bandwidth !== "auto" &&
223
- (!Number.isFinite(transform.bandwidth) || transform.bandwidth <= 0)
224
- ) {
225
- throw new RangeError(
226
- "Density bandwidth must be a positive finite number or auto."
227
- );
228
- }
229
- if (
230
- transform.extent !== "auto" &&
231
- (!Array.isArray(transform.extent) ||
232
- transform.extent.length !== 2 ||
233
- !transform.extent.every(Number.isFinite) ||
234
- transform.extent[0] >= transform.extent[1])
235
- ) {
236
- throw new RangeError(
237
- "Density extent must be an ascending pair of finite numbers or auto."
238
- );
239
- }
240
- if (!Number.isInteger(transform.steps) || transform.steps < 2) {
241
- throw new RangeError("Density steps must be an integer of at least 2.");
242
- }
243
- if (
244
- !Array.isArray(transform.as) ||
245
- transform.as.length !== 2 ||
246
- !transform.as.every(value => typeof value === "string" && value.length > 0) ||
247
- transform.as[0] === transform.as[1]
248
- ) {
249
- throw new TypeError("Density as must contain two distinct non-empty fields.");
250
- }
251
- const collisions = new Set([
285
+ validateDensityBandwidth(transform.bandwidth);
286
+ validateDensityExtent(transform.extent);
287
+ validateDensitySteps(transform.steps);
288
+ validateOutputFields(
289
+ transform.as,
252
290
  transform.field,
253
291
  transform.groupBy,
254
- transform.placement?.categoryField,
255
- transform.placement?.split?.field
256
- ].filter(Boolean));
257
- if (transform.as.some(value => collisions.has(value))) {
258
- throw new Error(
259
- "Density output fields must not collide with source or group fields."
260
- );
261
- }
292
+ transform.placement
293
+ );
262
294
  if (transform.resolve !== "shared") {
263
295
  throw new Error(`Unsupported density resolve "${transform.resolve}".`);
264
296
  }
@@ -300,17 +332,11 @@ function quantile(sortedValues, probability) {
300
332
  const upper = Math.ceil(index);
301
333
  if (lower === upper) return sortedValues[lower];
302
334
  const ratio = index - lower;
303
- return sortedValues[lower] * (1 - ratio) + sortedValues[upper] * ratio;
304
- }
305
-
306
- function sampleDeviation(values) {
307
- if (values.length < 2) return 0;
308
- const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
309
- const sumSquares = values.reduce(
310
- (sum, value) => sum + (value - mean) ** 2,
311
- 0
312
- );
313
- return Math.sqrt(sumSquares / (values.length - 1));
335
+ const ordinary = sortedValues[lower] * (1 - ratio) +
336
+ sortedValues[upper] * ratio;
337
+ return Number.isFinite(ordinary)
338
+ ? ordinary
339
+ : interpolateNumber(sortedValues[lower], sortedValues[upper], ratio);
314
340
  }
315
341
 
316
342
  export function estimateDensityBandwidth(values) {
@@ -321,13 +347,16 @@ export function estimateDensityBandwidth(values) {
321
347
  throw new Error("Density auto bandwidth requires at least two values.");
322
348
  }
323
349
  const sorted = [...values].sort((left, right) => left - right);
324
- const deviation = sampleDeviation(sorted);
350
+ const deviation = stableFiniteDeviation(sorted, {
351
+ sample: true,
352
+ label: "Density sample deviation"
353
+ }).deviation;
325
354
  const interquartileRange = quantile(sorted, 0.75) - quantile(sorted, 0.25);
326
355
  const robustDeviation = interquartileRange / 1.34;
327
356
  const spread = robustDeviation > 0
328
357
  ? Math.min(deviation, robustDeviation)
329
358
  : deviation;
330
- const bandwidth = 1.06 * spread * sorted.length ** -0.2;
359
+ const bandwidth = spread * (1.06 * sorted.length ** -0.2);
331
360
  if (!Number.isFinite(bandwidth) || bandwidth <= 0) {
332
361
  throw new Error("Density auto bandwidth requires varying finite values.");
333
362
  }
@@ -338,72 +367,26 @@ function resolveBandwidth(value, sourceValues) {
338
367
  if (value === undefined || value === "auto") {
339
368
  return estimateDensityBandwidth(sourceValues);
340
369
  }
341
- if (!Number.isFinite(value) || value <= 0) {
342
- throw new RangeError(
343
- "Density bandwidth must be a positive finite number or auto."
344
- );
345
- }
346
- return value;
370
+ return validateDensityBandwidth(value);
347
371
  }
348
372
 
349
373
  function resolveExtent(value, sourceValues) {
350
374
  if (value === undefined || value === "auto") {
351
- const extent = [Math.min(...sourceValues), Math.max(...sourceValues)];
375
+ let lower = Infinity;
376
+ let upper = -Infinity;
377
+ for (const sourceValue of sourceValues) {
378
+ lower = Math.min(lower, sourceValue);
379
+ upper = Math.max(upper, sourceValue);
380
+ }
381
+ const extent = [lower, upper];
352
382
  if (extent[0] === extent[1]) {
353
383
  throw new Error("Density observed extent requires varying finite values.");
354
384
  }
355
385
  return extent;
356
386
  }
357
- if (
358
- !Array.isArray(value) ||
359
- value.length !== 2 ||
360
- !value.every(Number.isFinite) ||
361
- value[0] >= value[1]
362
- ) {
363
- throw new RangeError(
364
- "Density extent must be an ascending pair of finite numbers or auto."
365
- );
366
- }
367
- return [...value];
387
+ return [...validateDensityExtent(value)];
368
388
  }
369
389
 
370
- function resolveOutputFields(as, field, groupBy, placement) {
371
- const resolved = as ?? [`${field}_value`, `${field}_density`];
372
- if (
373
- !Array.isArray(resolved) ||
374
- resolved.length !== 2 ||
375
- !resolved.every(value => typeof value === "string" && value.length > 0) ||
376
- resolved[0] === resolved[1]
377
- ) {
378
- throw new TypeError("Density as must contain two distinct non-empty fields.");
379
- }
380
- const collisions = new Set([
381
- field,
382
- groupBy,
383
- placement?.categoryField,
384
- placement?.split?.field
385
- ].filter(Boolean));
386
- if (resolved.some(value => collisions.has(value))) {
387
- throw new Error("Density output fields must not collide with source or group fields.");
388
- }
389
- return [...resolved];
390
- }
391
-
392
- const KERNEL_FUNCTIONS = Object.freeze({
393
- gaussian(value) {
394
- return Math.exp(-0.5 * value ** 2) / SQRT_TWO_PI;
395
- },
396
- epanechnikov(value) {
397
- return Math.abs(value) <= 1 ? 0.75 * (1 - value ** 2) : 0;
398
- },
399
- uniform(value) {
400
- return Math.abs(value) <= 1 ? 0.5 : 0;
401
- },
402
- triangular(value) {
403
- return Math.abs(value) <= 1 ? 1 - Math.abs(value) : 0;
404
- }
405
- });
406
-
407
390
  function estimateAt(sample, values, bandwidth, kernel, normalization) {
408
391
  const sum = values.reduce(
409
392
  (total, value) => total + KERNEL_FUNCTIONS[kernel](
@@ -414,7 +397,14 @@ function estimateAt(sample, values, bandwidth, kernel, normalization) {
414
397
  const denominator = normalization === "unit"
415
398
  ? values.length * bandwidth
416
399
  : bandwidth;
417
- return sum / denominator;
400
+ const ordinary = sum / denominator;
401
+ if (Number.isFinite(denominator) && Number.isFinite(ordinary)) {
402
+ return ordinary;
403
+ }
404
+ const stable = normalization === "unit"
405
+ ? (sum / values.length) / bandwidth
406
+ : sum / bandwidth;
407
+ return requireFiniteResult(stable, "Density estimate");
418
408
  }
419
409
 
420
410
  export function deriveKernelDensity(values, {
@@ -435,13 +425,16 @@ export function deriveKernelDensity(values, {
435
425
  const groupField = groupBy === undefined
436
426
  ? undefined
437
427
  : requireField(groupBy, "Density groupBy");
438
- if (!Number.isInteger(steps) || steps < 2) {
439
- throw new RangeError("Density steps must be an integer of at least 2.");
440
- }
428
+ validateDensitySteps(steps);
441
429
  if (placement !== undefined) {
442
430
  validateStoredDensityPlacement(placement, groupField);
443
431
  }
444
- const outputFields = resolveOutputFields(as, sourceField, groupField, placement);
432
+ const outputFields = [...validateOutputFields(
433
+ as ?? [`${sourceField}_value`, `${sourceField}_density`],
434
+ sourceField,
435
+ groupField,
436
+ placement
437
+ )];
445
438
  const resolvedKernel = validateDensityKernel(kernel);
446
439
  const resolvedNormalization = validateDensityNormalization(normalization);
447
440
  const validRows = values.filter(row =>
@@ -454,12 +447,23 @@ export function deriveKernelDensity(values, {
454
447
  if (validRows.length === 0) {
455
448
  throw new Error("Density requires at least one valid field/group row.");
456
449
  }
450
+ validateWorkLimit(validRows.length * steps, "Density computation");
457
451
  const sourceValues = validRows.map(row => row[sourceField]);
458
452
  const resolvedBandwidth = resolveBandwidth(bandwidth, sourceValues);
459
453
  const resolvedExtent = resolveExtent(extent, sourceValues);
460
- const groups = groupField === undefined
461
- ? [undefined]
462
- : [...new Set(validRows.map(row => row[groupField]))];
454
+ const groupedValues = new Map();
455
+ for (const row of validRows) {
456
+ const group = groupField === undefined ? undefined : row[groupField];
457
+ const split = placement?.split === undefined
458
+ ? undefined
459
+ : row[placement.split.field];
460
+ const bySplit = groupedValues.get(group) ?? new Map();
461
+ const groupValues = bySplit.get(split) ?? [];
462
+ groupValues.push(row[sourceField]);
463
+ bySplit.set(split, groupValues);
464
+ groupedValues.set(group, bySplit);
465
+ }
466
+ const groups = [...groupedValues.keys()];
463
467
  const observedSplits = placement?.split === undefined
464
468
  ? []
465
469
  : [...new Set(validRows.map(row => row[placement.split.field]))];
@@ -477,27 +481,44 @@ export function deriveKernelDensity(values, {
477
481
  throw new Error("Density split domain must include every observed split value.");
478
482
  }
479
483
  }
480
- const sampleStep = (resolvedExtent[1] - resolvedExtent[0]) / (steps - 1);
484
+ const splits = splitDomain ?? [undefined];
485
+ let outputGroups = 0;
486
+ for (const group of groups) {
487
+ for (const split of splits) {
488
+ if (groupedValues.get(group).has(split)) outputGroups += 1;
489
+ }
490
+ }
491
+ validateGeneratedItemLimit(
492
+ outputGroups * steps,
493
+ "Density generated row count"
494
+ );
495
+ const extentSpan = resolvedExtent[1] - resolvedExtent[0];
496
+ const sampleStep = extentSpan / (steps - 1);
481
497
  const samples = Array.from(
482
498
  { length: steps },
483
499
  (_, index) => index === steps - 1
484
500
  ? resolvedExtent[1]
485
- : placement === undefined
486
- ? resolvedExtent[0] + sampleStep * index
487
- : resolvedExtent[0] +
488
- (resolvedExtent[1] - resolvedExtent[0]) * index / (steps - 1)
501
+ : Number.isFinite(extentSpan)
502
+ ? placement === undefined
503
+ ? resolvedExtent[0] + sampleStep * index
504
+ : resolvedExtent[0] + extentSpan * index / (steps - 1)
505
+ : interpolateNumber(
506
+ resolvedExtent[0],
507
+ resolvedExtent[1],
508
+ index / (steps - 1)
509
+ )
489
510
  );
511
+ if (samples.some((sample, index) =>
512
+ !Number.isFinite(sample) ||
513
+ (index > 0 && sample <= samples[index - 1]))) {
514
+ throw new RangeError(
515
+ "Density extent cannot represent the requested finite sample grid."
516
+ );
517
+ }
490
518
  const rows = [];
491
519
  for (const group of groups) {
492
- const splits = splitDomain ?? [undefined];
493
520
  for (const split of splits) {
494
- const groupValues = validRows
495
- .filter(row =>
496
- (groupField === undefined || Object.is(row[groupField], group)) &&
497
- (placement?.split === undefined ||
498
- Object.is(row[placement.split.field], split))
499
- )
500
- .map(row => row[sourceField]);
521
+ const groupValues = groupedValues.get(group).get(split) ?? [];
501
522
  if (groupValues.length === 0) continue;
502
523
  for (const sample of samples) {
503
524
  rows.push({