ggaction 0.0.11 → 0.0.13

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 (289) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +3 -2
  3. package/knowledge/action-card.schema.json +1 -1
  4. package/knowledge/action-cards.json +1 -1
  5. package/knowledge/action-cards.schema.json +1 -1
  6. package/knowledge/extension-authoring.md +2 -1
  7. package/knowledge/intent-taxonomy.json +1 -1
  8. package/knowledge/mcp-resources.json +1 -1
  9. package/knowledge/task-resolver.js +1 -1
  10. package/package.json +4 -2
  11. package/src/actions/basic.js +2 -0
  12. package/src/actions/boxPlots/materialize.js +2 -1
  13. package/src/actions/canvas/actions.js +3 -1
  14. package/src/actions/canvas/fitting.js +211 -0
  15. package/src/actions/canvas/index.js +2 -0
  16. package/src/actions/categoryOrder/index.js +3 -2
  17. package/src/actions/charts/area.js +47 -0
  18. package/src/actions/charts/bar.js +11 -5
  19. package/src/actions/charts/beeswarm.js +70 -0
  20. package/src/actions/charts/density.js +44 -0
  21. package/src/actions/charts/ecdf.js +183 -0
  22. package/src/actions/charts/endpoints.js +451 -0
  23. package/src/actions/charts/heatmap.js +2 -2
  24. package/src/actions/charts/histogram.js +1 -1
  25. package/src/actions/charts/horizon.js +71 -0
  26. package/src/actions/charts/index.js +37 -0
  27. package/src/actions/charts/interval-regression.js +169 -0
  28. package/src/actions/charts/line.js +5 -5
  29. package/src/actions/charts/parallel.js +1 -1
  30. package/src/actions/charts/pie.js +44 -0
  31. package/src/actions/charts/polar.js +123 -0
  32. package/src/actions/charts/radar.js +299 -0
  33. package/src/actions/charts/radial.js +42 -0
  34. package/src/actions/charts/raincloud.js +422 -0
  35. package/src/actions/charts/rug-strip.js +279 -0
  36. package/src/actions/charts/scatter.js +9 -3
  37. package/src/actions/charts/shared.js +69 -10
  38. package/src/actions/composition/actions.js +118 -1
  39. package/src/actions/data/bin.js +29 -0
  40. package/src/actions/data/computed.js +24 -0
  41. package/src/actions/data/derived.js +66 -0
  42. package/src/actions/data/ecdf.js +38 -0
  43. package/src/actions/data/filter.js +212 -28
  44. package/src/actions/data/fold.js +24 -0
  45. package/src/actions/data/index.js +24 -0
  46. package/src/actions/data/interval.js +2 -0
  47. package/src/actions/data/intervalEdit.js +86 -4
  48. package/src/actions/data/regression.js +5 -2
  49. package/src/actions/data/stack.js +26 -0
  50. package/src/actions/data/summary.js +26 -0
  51. package/src/actions/data/timeUnit.js +1 -1
  52. package/src/actions/encodings/appearance.js +32 -13
  53. package/src/actions/encodings/barWidth.js +8 -0
  54. package/src/actions/encodings/basic.js +2 -0
  55. package/src/actions/encodings/color/continuous.js +19 -59
  56. package/src/actions/encodings/color/index.js +28 -22
  57. package/src/actions/encodings/color/layout.js +3 -97
  58. package/src/actions/encodings/color/policy.js +5 -8
  59. package/src/actions/encodings/density/resolve.js +37 -5
  60. package/src/actions/encodings/density.js +120 -33
  61. package/src/actions/encodings/horizon/resolve.js +8 -4
  62. package/src/actions/encodings/horizon.js +18 -8
  63. package/src/actions/encodings/index.js +2 -0
  64. package/src/actions/encodings/offset.js +33 -27
  65. package/src/actions/encodings/parallel.js +2 -8
  66. package/src/actions/encodings/position/apply.js +40 -4
  67. package/src/actions/encodings/position/index.js +43 -5
  68. package/src/actions/encodings/position/policies/arc.js +37 -2
  69. package/src/actions/encodings/position/policies/area.js +4 -42
  70. package/src/actions/encodings/position/policies/bar.js +48 -13
  71. package/src/actions/encodings/position/resolve.js +79 -24
  72. package/src/actions/encodings/ranged.js +123 -97
  73. package/src/actions/encodings/remove.js +37 -32
  74. package/src/actions/encodings/ruleAppearance.js +23 -9
  75. package/src/actions/encodings/seriesLayout.js +104 -0
  76. package/src/actions/encodings/shared.js +25 -23
  77. package/src/actions/encodings/strokeDash.js +9 -6
  78. package/src/actions/encodings/temporal.js +7 -0
  79. package/src/actions/encodings/text.js +30 -15
  80. package/src/actions/errorBands/create.js +18 -5
  81. package/src/actions/errorBands/edit.js +341 -21
  82. package/src/actions/errorBars/create.js +153 -11
  83. package/src/actions/errorBars/edit.js +365 -19
  84. package/src/actions/facets/actions.js +410 -12
  85. package/src/actions/facets/derive.js +57 -8
  86. package/src/actions/gradientPlots/components.js +33 -11
  87. package/src/actions/gradientPlots/materialize.js +5 -7
  88. package/src/actions/guides/applicability.js +20 -17
  89. package/src/actions/guides/axes/axes.js +4 -3
  90. package/src/actions/guides/axes/axis.js +18 -18
  91. package/src/actions/guides/axes/components.js +43 -0
  92. package/src/actions/guides/axes/edit.js +16 -51
  93. package/src/actions/guides/axes/labels.js +129 -19
  94. package/src/actions/guides/axes/lines.js +7 -5
  95. package/src/actions/guides/axes/parallel/lifecycle.js +117 -0
  96. package/src/actions/guides/axes/parallel/policy.js +137 -0
  97. package/src/actions/guides/axes/parallel/resolve.js +44 -23
  98. package/src/actions/guides/axes/parallel.js +93 -217
  99. package/src/actions/guides/axes/policy.js +21 -67
  100. package/src/actions/guides/axes/tickGroups.js +14 -8
  101. package/src/actions/guides/axes/ticks.js +5 -4
  102. package/src/actions/guides/axes/titles.js +21 -8
  103. package/src/actions/guides/basic.js +2 -0
  104. package/src/actions/guides/facade.js +106 -0
  105. package/src/actions/guides/facadeAxes.js +200 -0
  106. package/src/actions/guides/facadeLegend.js +63 -0
  107. package/src/actions/guides/grids/grid.js +37 -33
  108. package/src/actions/guides/grids/resolve.js +10 -2
  109. package/src/actions/guides/guides.js +35 -28
  110. package/src/actions/guides/legends/categorical/actions.js +193 -249
  111. package/src/actions/guides/legends/categorical/components.js +4 -5
  112. package/src/actions/guides/legends/categorical/layout.js +36 -349
  113. package/src/actions/guides/legends/categorical/options.js +30 -14
  114. package/src/actions/guides/legends/categorical/recipes.js +14 -0
  115. package/src/actions/guides/legends/categorical/resolve.js +21 -10
  116. package/src/actions/guides/legends/categorical/symbols.js +2 -3
  117. package/src/actions/guides/legends/continuous/common.js +69 -14
  118. package/src/actions/guides/legends/continuous/gradient.js +111 -83
  119. package/src/actions/guides/legends/continuous/index.js +6 -2
  120. package/src/actions/guides/legends/continuous/interval.js +71 -92
  121. package/src/actions/guides/legends/continuous/opacity.js +105 -84
  122. package/src/actions/guides/legends/creation.js +27 -0
  123. package/src/actions/guides/legends/edit.js +198 -66
  124. package/src/actions/guides/legends/focused.js +2 -2
  125. package/src/actions/guides/legends/lane.js +98 -86
  126. package/src/actions/guides/legends/lifecycle.js +132 -0
  127. package/src/actions/guides/legends/remove.js +31 -76
  128. package/src/actions/guides/legends/size.js +149 -105
  129. package/src/actions/guides/legends/strokeWidth.js +106 -64
  130. package/src/actions/guides/legends/target.js +12 -0
  131. package/src/actions/guides/legends/transition.js +43 -0
  132. package/src/actions/guides/polar/axes/facade.js +77 -62
  133. package/src/actions/guides/polar/axes/labels.js +5 -4
  134. package/src/actions/guides/polar/axes/lines.js +2 -1
  135. package/src/actions/guides/polar/axes/shared.js +6 -0
  136. package/src/actions/guides/polar/axes/ticks.js +5 -4
  137. package/src/actions/guides/polar/axes/titles.js +5 -4
  138. package/src/actions/guides/polar/grids.js +7 -7
  139. package/src/actions/guides/reuse.js +44 -0
  140. package/src/actions/guides/tickValues.js +19 -21
  141. package/src/actions/index.js +2 -0
  142. package/src/actions/intervals/resolve.js +35 -15
  143. package/src/actions/marks/arc/actions.js +1 -1
  144. package/src/actions/marks/area/actions.js +18 -4
  145. package/src/actions/marks/area/materialize.js +5 -2
  146. package/src/actions/marks/bar/materialize.js +11 -7
  147. package/src/actions/marks/index.js +5 -0
  148. package/src/actions/marks/line/actions.js +43 -108
  149. package/src/actions/marks/line/materialize.js +34 -52
  150. package/src/actions/marks/point/edit.js +11 -15
  151. package/src/actions/marks/point/index.js +1 -0
  152. package/src/actions/marks/point/jitter.js +3 -0
  153. package/src/actions/marks/point/materialize.js +138 -14
  154. package/src/actions/marks/point/packing.js +68 -0
  155. package/src/actions/marks/references.js +87 -0
  156. package/src/actions/marks/remove.js +17 -4
  157. package/src/actions/marks/rule/actions.js +60 -4
  158. package/src/actions/marks/rule/appearance.js +26 -0
  159. package/src/actions/marks/shared.js +12 -12
  160. package/src/actions/marks/text/actions.js +224 -6
  161. package/src/actions/marks/text/layout.js +2 -2
  162. package/src/actions/primitives/createGraphics.js +3 -1
  163. package/src/actions/primitives/semantic.js +1 -1
  164. package/src/actions/primitives/semanticAction.js +39 -2
  165. package/src/actions/primitives/semanticValidation/guide.js +10 -0
  166. package/src/actions/primitives/semanticValidation/layer.js +18 -1
  167. package/src/actions/primitives/semanticValidation/scale.js +10 -1
  168. package/src/actions/regression/create.js +1 -1
  169. package/src/actions/regression/edit.js +19 -6
  170. package/src/actions/regression/resolve.js +1 -1
  171. package/src/actions/scales/consumers/common.js +20 -53
  172. package/src/actions/scales/consumers/families.js +27 -14
  173. package/src/actions/scales/consumers/index.js +23 -2
  174. package/src/actions/scales/consumers/seriesLayout.js +33 -2
  175. package/src/actions/scales/create.js +4 -4
  176. package/src/actions/scales/definitions.js +19 -3
  177. package/src/actions/scales/edit.js +46 -34
  178. package/src/actions/scales/editPolicy.js +21 -31
  179. package/src/actions/scales/materialize.js +10 -46
  180. package/src/actions/scales/patch.js +1 -1
  181. package/src/actions/scales/preview.js +39 -0
  182. package/src/actions/scales/quantitativeColor.js +9 -5
  183. package/src/actions/theme/actions.js +43 -0
  184. package/src/actions/theme/index.js +9 -0
  185. package/src/actions/theme/reconcile.js +882 -0
  186. package/src/actions/titles/actions.js +11 -10
  187. package/src/actions/titles/resolve.js +1 -40
  188. package/src/actions/violinPlots/create.js +86 -42
  189. package/src/actions/violinPlots/edit.js +191 -0
  190. package/src/actions/violinPlots/index.js +2 -0
  191. package/src/core/ChartProgram.js +7 -0
  192. package/src/core/action.js +22 -2
  193. package/src/core/compositionState.js +136 -8
  194. package/src/core/vocabulary.js +2 -2
  195. package/src/grammar/aggregate.js +66 -23
  196. package/src/grammar/arcs.js +213 -65
  197. package/src/grammar/areaEndpoints.js +46 -0
  198. package/src/grammar/areaSeries.js +87 -41
  199. package/src/grammar/bars/aggregate.js +33 -12
  200. package/src/grammar/bars/policy.js +9 -7
  201. package/src/grammar/bin.js +181 -0
  202. package/src/grammar/categoryOrder.js +34 -0
  203. package/src/grammar/computed.js +150 -0
  204. package/src/grammar/ecdf.js +198 -0
  205. package/src/grammar/facets/index.js +117 -6
  206. package/src/grammar/fold.js +123 -0
  207. package/src/grammar/horizon.js +6 -4
  208. package/src/grammar/interval.js +37 -20
  209. package/src/grammar/lineSeries.js +20 -63
  210. package/src/grammar/markFilter.js +22 -4
  211. package/src/grammar/markLabels.js +93 -0
  212. package/src/grammar/parallelAxisTitles.js +16 -0
  213. package/src/grammar/pathSeries.js +91 -0
  214. package/src/grammar/pointPacking.js +183 -0
  215. package/src/grammar/positionCompatibility.js +1 -1
  216. package/src/grammar/positionDatum.js +12 -0
  217. package/src/grammar/rects.js +9 -1
  218. package/src/grammar/regression/derive.js +4 -0
  219. package/src/grammar/regression/index.js +7 -2
  220. package/src/grammar/regression/models.js +22 -7
  221. package/src/grammar/regression/parameters.js +40 -13
  222. package/src/grammar/rotation.js +29 -0
  223. package/src/grammar/rules.js +9 -21
  224. package/src/grammar/scales/color.js +23 -1
  225. package/src/grammar/scales/colorConsumers.js +38 -0
  226. package/src/grammar/scales/definition.js +15 -1
  227. package/src/grammar/scales/discretized.js +11 -5
  228. package/src/grammar/scales/fields.js +38 -8
  229. package/src/grammar/scales/mapping.js +2 -0
  230. package/src/grammar/scales/ordinal.js +23 -0
  231. package/src/grammar/scales/radial.js +55 -0
  232. package/src/grammar/scales/types.js +1 -0
  233. package/src/grammar/schemas/semanticPath.js +17 -3
  234. package/src/grammar/stack.js +170 -0
  235. package/src/grammar/statistics/confidenceInterval.js +101 -0
  236. package/src/grammar/summary.js +183 -0
  237. package/src/grammar/text.js +22 -16
  238. package/src/grammar/timeUnit.js +21 -11
  239. package/src/grammar/transformTopology.js +6 -0
  240. package/src/grammar/transforms.js +36 -0
  241. package/src/grammar/valueFormat.js +112 -0
  242. package/src/grammar/window.js +13 -7
  243. package/src/layout/facets.js +86 -23
  244. package/src/layout/guideCollisions.js +12 -0
  245. package/src/layout/legend.js +2 -10
  246. package/src/layout/legendItems.js +102 -0
  247. package/src/layout/legendLane.js +100 -11
  248. package/src/materialization/bars/aggregate.js +38 -152
  249. package/src/materialization/bars/histogram.js +24 -0
  250. package/src/materialization/categorySlotOffset.js +40 -0
  251. package/src/materialization/compositionSnapshot.js +1 -1
  252. package/src/materialization/dependencies.js +57 -16
  253. package/src/materialization/encodings.js +1 -1
  254. package/src/materialization/facetGuides/legacyCategorical.js +7 -1
  255. package/src/materialization/facetGuides/placement.js +16 -1
  256. package/src/materialization/facetGuides/preparation.js +26 -1
  257. package/src/materialization/facets.js +11 -2
  258. package/src/materialization/guides/layout.js +57 -0
  259. package/src/materialization/guides/resources.js +2 -2
  260. package/src/materialization/legends.js +10 -1
  261. package/src/materialization/marks/capabilities.js +30 -7
  262. package/src/materialization/marks/grouping.js +52 -0
  263. package/src/materialization/marks/index.js +63 -25
  264. package/src/materialization/marks/policies.js +2 -0
  265. package/src/materialization/rect.js +33 -33
  266. package/src/materialization/rowEncoding.js +70 -14
  267. package/src/materialization/scaleGuideDependencies.js +8 -5
  268. package/src/materialization/scales/map.js +1 -0
  269. package/src/materialization/scales/policies/arc.js +42 -0
  270. package/src/materialization/scales/policies/bar.js +0 -43
  271. package/src/materialization/scales/policies/binnedPosition.js +7 -4
  272. package/src/materialization/scales/policies/offset.js +54 -0
  273. package/src/materialization/scales/policies/series.js +2 -1
  274. package/src/materialization/scales/resolve.js +48 -18
  275. package/src/materialization/selection/compatibility.js +11 -0
  276. package/src/materialization/selection/items/bar.js +12 -68
  277. package/src/materialization/selection/items/common.js +8 -2
  278. package/src/materialization/selection/items/path.js +3 -13
  279. package/src/materialization/selection/items/rect.js +7 -4
  280. package/src/materialization/text.js +64 -5
  281. package/src/mcp/adapter.js +2 -2
  282. package/src/selectors/datasets.js +13 -0
  283. package/src/selectors/layers.js +3 -2
  284. package/src/selectors/scales.js +6 -0
  285. package/src/theme/defaults.js +39 -0
  286. package/types/basic.d.ts +11 -2
  287. package/types/index.d.ts +126 -0
  288. package/types/program.d.ts +1327 -124
  289. package/src/materialization/bars/grouped.js +0 -144
@@ -11,7 +11,7 @@ export const BAR_ORIENTATIONS = Object.freeze({
11
11
  horizontal: "horizontal"
12
12
  });
13
13
 
14
- function isCategory(encoding) {
14
+ export function isBarCategoryEncoding(encoding) {
15
15
  return ["nominal", "ordinal", "temporal"].includes(encoding?.fieldType);
16
16
  }
17
17
 
@@ -27,14 +27,14 @@ export function resolveBarOrientation(layer) {
27
27
  if (x?.bin !== undefined && y?.aggregate === "count") {
28
28
  return BAR_ORIENTATIONS.vertical;
29
29
  }
30
- if (isCategory(x) && y?.fieldType === "quantitative" && layer.encoding?.y2?.fieldType === "quantitative") {
30
+ if (isBarCategoryEncoding(x) && y?.fieldType === "quantitative" && layer.encoding?.y2?.fieldType === "quantitative") {
31
31
  return BAR_ORIENTATIONS.vertical;
32
32
  }
33
- if (isCategory(y) && x?.fieldType === "quantitative" && layer.encoding?.x2?.fieldType === "quantitative") {
33
+ if (isBarCategoryEncoding(y) && x?.fieldType === "quantitative" && layer.encoding?.x2?.fieldType === "quantitative") {
34
34
  return BAR_ORIENTATIONS.horizontal;
35
35
  }
36
- if (isCategory(x) && isMeasure(y)) return BAR_ORIENTATIONS.vertical;
37
- if (isMeasure(x) && isCategory(y)) return BAR_ORIENTATIONS.horizontal;
36
+ if (isBarCategoryEncoding(x) && isMeasure(y)) return BAR_ORIENTATIONS.vertical;
37
+ if (isMeasure(x) && isBarCategoryEncoding(y)) return BAR_ORIENTATIONS.horizontal;
38
38
  return undefined;
39
39
  }
40
40
 
@@ -63,8 +63,8 @@ export function resolveBarGrain(layer) {
63
63
  return BAR_GRAINS.histogram;
64
64
  }
65
65
  if (
66
- (isCategory(x) && y?.fieldType === "quantitative" && layer.encoding?.y2?.fieldType === "quantitative") ||
67
- (isCategory(y) && x?.fieldType === "quantitative" && layer.encoding?.x2?.fieldType === "quantitative")
66
+ (isBarCategoryEncoding(x) && y?.fieldType === "quantitative" && layer.encoding?.y2?.fieldType === "quantitative") ||
67
+ (isBarCategoryEncoding(y) && x?.fieldType === "quantitative" && layer.encoding?.x2?.fieldType === "quantitative")
68
68
  ) return BAR_GRAINS.ranged;
69
69
  if (resolveBarOrientation(layer) !== undefined) {
70
70
  return BAR_GRAINS.aggregate;
@@ -73,6 +73,7 @@ export function resolveBarGrain(layer) {
73
73
  }
74
74
 
75
75
  export function inferBarColorLayout(layer) {
76
+ if (layer?.layout?.mode !== undefined) return layer.layout.mode;
76
77
  if (layer?.encoding?.color?.layout !== undefined) {
77
78
  return layer.encoding.color.layout;
78
79
  }
@@ -84,6 +85,7 @@ export function inferBarColorLayout(layer) {
84
85
  }
85
86
 
86
87
  export function resolveBarColorLayout(layer) {
88
+ if (layer?.layout?.mode !== undefined) return layer.layout.mode;
87
89
  if (layer?.encoding?.color?.layout !== undefined) {
88
90
  return layer.encoding.color.layout;
89
91
  }
@@ -0,0 +1,181 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import {
3
+ findHistogramBinIndex,
4
+ normalizeHistogramBin,
5
+ resolveHistogramBins
6
+ } from "./histogram.js";
7
+
8
+ const TRANSFORM_KEYS = Object.freeze([
9
+ "type", "field", "bin", "extent", "nice", "zero", "includeEmpty",
10
+ "members", "as", "resolved"
11
+ ]);
12
+ const AS_KEYS = Object.freeze(["lower", "upper", "count", "members"]);
13
+ const RESOLVED_KEYS = Object.freeze(["domain", "step", "boundaries"]);
14
+
15
+ function rejectUnknownKeys(value, supported, label) {
16
+ const unknown = Object.keys(value).find(key => !supported.includes(key));
17
+ if (unknown !== undefined) {
18
+ throw new Error(`Unknown ${label} property "${unknown}".`);
19
+ }
20
+ }
21
+
22
+ function requireField(value, label) {
23
+ if (typeof value !== "string" || value.length === 0) {
24
+ throw new TypeError(`${label} must be a non-empty string.`);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ function normalizeAs(field, value, members) {
30
+ const requested = value ?? {};
31
+ if (!isPlainObject(requested)) return requested;
32
+ const result = {
33
+ ...requested,
34
+ lower: requested.lower ?? `${field}_start`,
35
+ upper: requested.upper ?? `${field}_end`,
36
+ count: requested.count ?? "count"
37
+ };
38
+ if (members) result.members = requested.members ?? "members";
39
+ else if (Object.hasOwn(requested, "members")) result.members = requested.members;
40
+ return result;
41
+ }
42
+
43
+ export function normalizeBinTransform({
44
+ field,
45
+ maxBins,
46
+ step,
47
+ boundaries,
48
+ extent,
49
+ nice,
50
+ zero,
51
+ includeEmpty,
52
+ members,
53
+ as
54
+ } = {}) {
55
+ const selected = [maxBins, step, boundaries].filter(value => value !== undefined);
56
+ if (selected.length > 1) {
57
+ throw new Error("Bin data accepts only one of maxBins, step, or boundaries.");
58
+ }
59
+ const bin = normalizeHistogramBin(
60
+ boundaries !== undefined ? { boundaries }
61
+ : step !== undefined ? { step }
62
+ : { maxBins: maxBins ?? 10 }
63
+ );
64
+ const resolvedMembers = members ?? false;
65
+ const transform = {
66
+ type: "bin",
67
+ field,
68
+ bin,
69
+ extent: extent ?? "auto",
70
+ nice: nice ?? true,
71
+ zero: zero ?? false,
72
+ includeEmpty: includeEmpty ?? true,
73
+ members: resolvedMembers,
74
+ as: normalizeAs(field, as, resolvedMembers)
75
+ };
76
+ validateBinTransform(transform);
77
+ return cloneAndFreeze(transform);
78
+ }
79
+
80
+ export function validateBinTransform(transform) {
81
+ if (!isPlainObject(transform)) {
82
+ throw new TypeError("Bin transform must be a plain object.");
83
+ }
84
+ rejectUnknownKeys(transform, TRANSFORM_KEYS, "bin transform");
85
+ if (transform.type !== "bin") {
86
+ throw new Error(`Unsupported bin transform "${transform.type}".`);
87
+ }
88
+ requireField(transform.field, "Bin field");
89
+ normalizeHistogramBin(transform.bin);
90
+ if (
91
+ transform.extent !== "auto" &&
92
+ (!Array.isArray(transform.extent) || transform.extent.length !== 2 ||
93
+ !transform.extent.every(Number.isFinite) || transform.extent[0] >= transform.extent[1])
94
+ ) {
95
+ throw new TypeError("Bin extent must be auto or two ascending finite numbers.");
96
+ }
97
+ for (const property of ["nice", "zero", "includeEmpty", "members"]) {
98
+ if (typeof transform[property] !== "boolean") {
99
+ throw new TypeError(`Bin ${property} must be a boolean.`);
100
+ }
101
+ }
102
+ if (!isPlainObject(transform.as)) {
103
+ throw new TypeError("Bin as must be a plain object.");
104
+ }
105
+ rejectUnknownKeys(transform.as, AS_KEYS, "bin as");
106
+ const required = ["lower", "upper", "count", ...(transform.members ? ["members"] : [])];
107
+ if (!transform.members && Object.hasOwn(transform.as, "members")) {
108
+ throw new Error("Bin as.members requires members: true.");
109
+ }
110
+ const outputs = required.map(key => requireField(transform.as[key], `Bin as.${key}`));
111
+ if (new Set(outputs).size !== outputs.length) {
112
+ throw new Error("Bin output fields must be unique.");
113
+ }
114
+ if (transform.resolved !== undefined) {
115
+ if (!isPlainObject(transform.resolved)) {
116
+ throw new TypeError("Bin resolved must be a plain object.");
117
+ }
118
+ rejectUnknownKeys(transform.resolved, RESOLVED_KEYS, "bin resolved");
119
+ resolveHistogramBins({
120
+ values: transform.resolved.boundaries ?? [],
121
+ bin: { boundaries: transform.resolved.boundaries },
122
+ domain: transform.resolved.domain,
123
+ nice: transform.nice,
124
+ zero: transform.zero
125
+ });
126
+ }
127
+ return transform;
128
+ }
129
+
130
+ function readValues(rows, field) {
131
+ return rows.map((row, index) => {
132
+ const value = row[field];
133
+ if (!Number.isFinite(value)) {
134
+ throw new TypeError(`Bin field "${field}" must contain a finite number at row ${index}.`);
135
+ }
136
+ return value;
137
+ });
138
+ }
139
+
140
+ export function deriveBinRows(rows, transform) {
141
+ validateBinTransform(transform);
142
+ const values = readValues(rows, transform.field);
143
+ if (
144
+ transform.extent !== "auto" &&
145
+ values.some(value => value < transform.extent[0] || value > transform.extent[1])
146
+ ) {
147
+ throw new RangeError("Bin extent must contain the histogram data extent.");
148
+ }
149
+ const resolved = resolveHistogramBins({
150
+ values,
151
+ bin: transform.bin,
152
+ domain: transform.extent,
153
+ nice: transform.nice,
154
+ zero: transform.zero
155
+ });
156
+ const members = resolved.boundaries.slice(0, -1).map(() => []);
157
+ values.forEach((value, index) => {
158
+ const bin = findHistogramBinIndex(value, resolved.boundaries);
159
+ if (bin !== -1) members[bin].push(rows[index]);
160
+ });
161
+ const output = members.flatMap((group, index) =>
162
+ !transform.includeEmpty && group.length === 0 ? [] : [{
163
+ [transform.as.lower]: resolved.boundaries[index],
164
+ [transform.as.upper]: resolved.boundaries[index + 1],
165
+ [transform.as.count]: group.length,
166
+ ...(transform.members ? { [transform.as.members]: group } : {})
167
+ }]
168
+ );
169
+ return {
170
+ values: output,
171
+ resolved: {
172
+ domain: resolved.domain,
173
+ ...(resolved.step === undefined ? {} : { step: resolved.step }),
174
+ boundaries: resolved.boundaries
175
+ }
176
+ };
177
+ }
178
+
179
+ export function resolveBinTransform(result, transform) {
180
+ return cloneAndFreeze([{ ...transform, resolved: result.resolved }]);
181
+ }
@@ -2,6 +2,8 @@ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
2
  import { isNominalValue, readNominalField } from "./scales/fields.js";
3
3
  import { aggregateScalarValues } from "./aggregate.js";
4
4
 
5
+ export const CATEGORY_ORDER_CHANNELS = Object.freeze(["x", "y", "theta"]);
6
+
5
7
  const DIRECTIONS = ["ascending", "descending"];
6
8
  const AGGREGATES = ["sum", "mean", "min", "max"];
7
9
 
@@ -130,3 +132,35 @@ export function resolveCategoryOrder(rows, categoryField, order) {
130
132
  return direction * comparison || first.get(left) - first.get(right);
131
133
  }));
132
134
  }
135
+
136
+
137
+ export function normalizeLegendOrder(order) {
138
+ if (order === "scale") return order;
139
+ if (!isPlainObject(order)) {
140
+ throw new TypeError('Legend order must be "scale", { values }, or { channel }.');
141
+ }
142
+ const keys = Object.keys(order);
143
+ if (keys.length !== 1 || !["values", "channel"].includes(keys[0])) {
144
+ throw new Error("Legend order requires exactly one of values or channel.");
145
+ }
146
+ if (keys[0] === "values") return normalizeCategoryOrder({ values: order.values });
147
+ if (!CATEGORY_ORDER_CHANNELS.includes(order.channel)) {
148
+ throw new Error("Legend order channel must be x, y, or theta.");
149
+ }
150
+ return cloneAndFreeze({ channel: order.channel });
151
+ }
152
+
153
+ export function resolveLegendOrderDomain(domain, order, linked, observed = domain) {
154
+ if (order === undefined || order === "scale") return domain;
155
+ const normalized = normalizeLegendOrder(order);
156
+ if (normalized.values !== undefined) {
157
+ const seen = [...new Set(observed)].filter(value => domain.some(item => Object.is(item, value)));
158
+ const available = [...seen, ...domain.filter(value => !seen.some(item => Object.is(item, value)))];
159
+ return resolveCategoryOrder(available.map(value => ({ value })), "value", normalized);
160
+ }
161
+ if (!Array.isArray(linked) || linked.length !== domain.length ||
162
+ !domain.every(value => linked.some(other => Object.is(value, other)))) {
163
+ throw new Error("Linked legend order requires the same categorical domain.");
164
+ }
165
+ return cloneAndFreeze(linked);
166
+ }
@@ -0,0 +1,150 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+
3
+ const TRANSFORM_KEYS = Object.freeze(["type", "as", "expression"]);
4
+ const BINARY_OPERATIONS = new Set(["add", "subtract", "multiply", "divide"]);
5
+ const UNARY_OPERATIONS = new Set(["negate", "absolute"]);
6
+ const MAX_DEPTH = 16;
7
+ const MAX_NODES = 128;
8
+ const MAX_WORK = 10_000_000;
9
+
10
+ function rejectUnknownKeys(value, supported, label) {
11
+ const unknown = Object.keys(value).find(key => !supported.includes(key));
12
+ if (unknown !== undefined) {
13
+ throw new Error(`Unknown ${label} property "${unknown}".`);
14
+ }
15
+ }
16
+
17
+ function requireField(value, label) {
18
+ if (typeof value !== "string" || value.length === 0) {
19
+ throw new TypeError(`${label} must be a non-empty string.`);
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function validateExpression(expression, depth = 1, state = { nodes: 0 }) {
25
+ if (!isPlainObject(expression)) {
26
+ throw new TypeError("Computed expression nodes must be plain objects.");
27
+ }
28
+ state.nodes += 1;
29
+ if (state.nodes > MAX_NODES) {
30
+ throw new RangeError(`Computed expression cannot exceed ${MAX_NODES} nodes.`);
31
+ }
32
+ if (depth > MAX_DEPTH) {
33
+ throw new RangeError(`Computed expression cannot exceed depth ${MAX_DEPTH}.`);
34
+ }
35
+ const kinds = ["field", "constant", "op"].filter(key => Object.hasOwn(expression, key));
36
+ if (kinds.length !== 1) {
37
+ throw new Error("Computed expression requires exactly one of field, constant, or op.");
38
+ }
39
+ if (kinds[0] === "field") {
40
+ rejectUnknownKeys(expression, ["field"], "computed field expression");
41
+ requireField(expression.field, "Computed expression field");
42
+ } else if (kinds[0] === "constant") {
43
+ rejectUnknownKeys(expression, ["constant"], "computed constant expression");
44
+ if (!Number.isFinite(expression.constant)) {
45
+ throw new TypeError("Computed expression constant must be a finite number.");
46
+ }
47
+ } else if (BINARY_OPERATIONS.has(expression.op)) {
48
+ rejectUnknownKeys(expression, ["op", "left", "right"], "computed binary expression");
49
+ if (!Object.hasOwn(expression, "left") || !Object.hasOwn(expression, "right")) {
50
+ throw new TypeError(`Computed ${expression.op} requires left and right expressions.`);
51
+ }
52
+ validateExpression(expression.left, depth + 1, state);
53
+ validateExpression(expression.right, depth + 1, state);
54
+ } else if (UNARY_OPERATIONS.has(expression.op)) {
55
+ rejectUnknownKeys(expression, ["op", "operand"], "computed unary expression");
56
+ if (!Object.hasOwn(expression, "operand")) {
57
+ throw new TypeError(`Computed ${expression.op} requires an operand expression.`);
58
+ }
59
+ validateExpression(expression.operand, depth + 1, state);
60
+ } else {
61
+ throw new Error(`Unsupported computed operation "${expression.op}".`);
62
+ }
63
+ return state.nodes;
64
+ }
65
+
66
+ function normalizeExpression(expression) {
67
+ if (!isPlainObject(expression)) return expression;
68
+ if (Object.hasOwn(expression, "left") || Object.hasOwn(expression, "right")) {
69
+ return {
70
+ ...expression,
71
+ left: normalizeExpression(expression.left),
72
+ right: normalizeExpression(expression.right)
73
+ };
74
+ }
75
+ if (Object.hasOwn(expression, "operand")) {
76
+ return { ...expression, operand: normalizeExpression(expression.operand) };
77
+ }
78
+ return { ...expression };
79
+ }
80
+
81
+ export function normalizeComputedTransform({ as, expression } = {}) {
82
+ const transform = {
83
+ type: "computed",
84
+ as,
85
+ expression: normalizeExpression(expression)
86
+ };
87
+ validateComputedTransform(transform);
88
+ return cloneAndFreeze(transform);
89
+ }
90
+
91
+ export function validateComputedTransform(transform) {
92
+ if (!isPlainObject(transform)) {
93
+ throw new TypeError("Computed transform must be a plain object.");
94
+ }
95
+ rejectUnknownKeys(transform, TRANSFORM_KEYS, "computed transform");
96
+ if (transform.type !== "computed") {
97
+ throw new Error(`Unsupported computed transform "${transform.type}".`);
98
+ }
99
+ requireField(transform.as, "Computed output field");
100
+ return validateExpression(transform.expression);
101
+ }
102
+
103
+ function evaluate(expression, row, rowIndex) {
104
+ if (Object.hasOwn(expression, "field")) {
105
+ if (!Object.hasOwn(row, expression.field)) {
106
+ throw new Error(
107
+ `Computed source does not contain field "${expression.field}" at row ${rowIndex}.`
108
+ );
109
+ }
110
+ const value = row[expression.field];
111
+ if (!Number.isFinite(value)) {
112
+ throw new TypeError(
113
+ `Computed field "${expression.field}" must contain a finite number at row ${rowIndex}.`
114
+ );
115
+ }
116
+ return value;
117
+ }
118
+ if (Object.hasOwn(expression, "constant")) return expression.constant;
119
+ if (UNARY_OPERATIONS.has(expression.op)) {
120
+ const value = evaluate(expression.operand, row, rowIndex);
121
+ return expression.op === "negate" ? -value : Math.abs(value);
122
+ }
123
+ const left = evaluate(expression.left, row, rowIndex);
124
+ const right = evaluate(expression.right, row, rowIndex);
125
+ if (expression.op === "divide" && right === 0) {
126
+ throw new RangeError(`Computed divide by zero at row ${rowIndex}.`);
127
+ }
128
+ const result = expression.op === "add" ? left + right
129
+ : expression.op === "subtract" ? left - right
130
+ : expression.op === "multiply" ? left * right
131
+ : left / right;
132
+ if (!Number.isFinite(result)) {
133
+ throw new RangeError(`Computed ${expression.op} result is not finite at row ${rowIndex}.`);
134
+ }
135
+ return result;
136
+ }
137
+
138
+ export function deriveComputedRows(rows, transform) {
139
+ const nodes = validateComputedTransform(transform);
140
+ if (rows.length * nodes > MAX_WORK) {
141
+ throw new RangeError(`Computed work cannot exceed ${MAX_WORK} row-nodes.`);
142
+ }
143
+ if (rows.some(row => Object.hasOwn(row, transform.as))) {
144
+ throw new Error(`Computed output field "${transform.as}" already exists.`);
145
+ }
146
+ return rows.map((row, index) => ({
147
+ ...row,
148
+ [transform.as]: evaluate(transform.expression, row, index)
149
+ }));
150
+ }
@@ -0,0 +1,198 @@
1
+ import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
+ import { stableDecimal } from "./numeric.js";
3
+
4
+ const TRANSFORM_KEYS = Object.freeze([
5
+ "type", "field", "groupBy", "weight", "missing", "as", "resolved"
6
+ ]);
7
+ const OUTPUT_KEYS = Object.freeze(["value", "cumulative", "probability"]);
8
+
9
+ function nonEmptyString(value, label) {
10
+ if (typeof value !== "string" || value.length === 0) {
11
+ throw new TypeError(`${label} must be a non-empty string.`);
12
+ }
13
+ }
14
+
15
+ function normalizeGroupBy(groupBy) {
16
+ const fields = groupBy === undefined
17
+ ? []
18
+ : Array.isArray(groupBy) ? [...groupBy] : [groupBy];
19
+ fields.forEach(field => nonEmptyString(field, "ECDF groupBy field"));
20
+ if (new Set(fields).size !== fields.length) {
21
+ throw new Error("ECDF groupBy fields must be distinct.");
22
+ }
23
+ return fields;
24
+ }
25
+
26
+ function validateOutputs(as, occupied) {
27
+ if (!isPlainObject(as)) throw new TypeError("ECDF as must be a plain object.");
28
+ const keys = Object.keys(as);
29
+ if (keys.length !== OUTPUT_KEYS.length ||
30
+ !OUTPUT_KEYS.every(key => Object.hasOwn(as, key))) {
31
+ throw new Error("ECDF as requires exactly value, cumulative, and probability.");
32
+ }
33
+ const values = OUTPUT_KEYS.map(key => as[key]);
34
+ values.forEach((field, index) => nonEmptyString(field, `ECDF as.${OUTPUT_KEYS[index]}`));
35
+ if (new Set(values).size !== values.length) {
36
+ throw new Error("ECDF output fields must be distinct.");
37
+ }
38
+ if (values.some(field => occupied.has(field))) {
39
+ throw new Error("ECDF output fields must not collide with input or grouping fields.");
40
+ }
41
+ }
42
+
43
+ function validateResolved(resolved, groupBy) {
44
+ if (resolved === undefined) return;
45
+ if (!isPlainObject(resolved) || !Array.isArray(resolved.groups)) {
46
+ throw new TypeError("ECDF resolved provenance must contain a groups array.");
47
+ }
48
+ for (const group of resolved.groups) {
49
+ if (!isPlainObject(group) || !isPlainObject(group.keys) ||
50
+ !Number.isFinite(group.denominator) || group.denominator <= 0 ||
51
+ !Number.isInteger(group.validCount) || group.validCount <= 0 ||
52
+ Object.keys(group.keys).length !== groupBy.length ||
53
+ !groupBy.every(field => Object.hasOwn(group.keys, field))) {
54
+ throw new TypeError("ECDF resolved group provenance is invalid.");
55
+ }
56
+ }
57
+ }
58
+
59
+ export function validateECDFTransform(transform) {
60
+ if (!isPlainObject(transform)) {
61
+ throw new TypeError("ECDF transform must be a plain object.");
62
+ }
63
+ const unknown = Object.keys(transform).find(key => !TRANSFORM_KEYS.includes(key));
64
+ if (unknown !== undefined) {
65
+ throw new Error(`Unknown ECDF transform property "${unknown}".`);
66
+ }
67
+ if (transform.type !== "ecdf") {
68
+ throw new Error(`Unsupported ECDF transform "${transform.type}".`);
69
+ }
70
+ nonEmptyString(transform.field, "ECDF field");
71
+ const groupBy = normalizeGroupBy(transform.groupBy);
72
+ if (groupBy.includes(transform.field)) {
73
+ throw new Error("ECDF field must not also be a grouping field.");
74
+ }
75
+ if (transform.weight !== undefined) {
76
+ nonEmptyString(transform.weight, "ECDF weight field");
77
+ if (transform.weight === transform.field || groupBy.includes(transform.weight)) {
78
+ throw new Error("ECDF weight field must be distinct from value and grouping fields.");
79
+ }
80
+ }
81
+ if (!["drop", "error"].includes(transform.missing)) {
82
+ throw new Error('ECDF missing must be "drop" or "error".');
83
+ }
84
+ validateOutputs(
85
+ transform.as,
86
+ new Set([transform.field, transform.weight, ...groupBy].filter(Boolean))
87
+ );
88
+ validateResolved(transform.resolved, groupBy);
89
+ }
90
+
91
+ export function normalizeECDFTransform({ field, groupBy, weight, missing = "drop", as }) {
92
+ const transform = {
93
+ type: "ecdf",
94
+ field,
95
+ groupBy: normalizeGroupBy(groupBy),
96
+ ...(weight === undefined ? {} : { weight }),
97
+ missing,
98
+ as
99
+ };
100
+ validateECDFTransform(transform);
101
+ return cloneAndFreeze(transform);
102
+ }
103
+
104
+ function groupValue(value) {
105
+ return typeof value === "string" || typeof value === "boolean" ||
106
+ (typeof value === "number" && Number.isFinite(value));
107
+ }
108
+
109
+ function rejectOrDrop(transform, index, reason) {
110
+ if (transform.missing === "error") {
111
+ throw new TypeError(`ECDF row ${index} ${reason}.`);
112
+ }
113
+ return false;
114
+ }
115
+
116
+ export function deriveECDF(rows, transform) {
117
+ if (!Array.isArray(rows)) throw new TypeError("ECDF rows must be an array.");
118
+ validateECDFTransform(transform);
119
+ const groups = new Map();
120
+
121
+ rows.forEach((row, index) => {
122
+ if (!isPlainObject(row)) {
123
+ rejectOrDrop(transform, index, "must be a plain object");
124
+ return;
125
+ }
126
+ const value = row[transform.field];
127
+ if (!Number.isFinite(value)) {
128
+ rejectOrDrop(transform, index, `has an invalid value in "${transform.field}"`);
129
+ return;
130
+ }
131
+ const keys = transform.groupBy.map(field => row[field]);
132
+ if (!keys.every(groupValue)) {
133
+ rejectOrDrop(transform, index, "has an invalid grouping value");
134
+ return;
135
+ }
136
+ const weight = transform.weight === undefined ? 1 : row[transform.weight];
137
+ if (!Number.isFinite(weight)) {
138
+ rejectOrDrop(transform, index, `has an invalid weight in "${transform.weight}"`);
139
+ return;
140
+ }
141
+ if (weight < 0) {
142
+ throw new RangeError(`ECDF row ${index} has a negative weight.`);
143
+ }
144
+ const key = JSON.stringify(keys);
145
+ if (!groups.has(key)) {
146
+ groups.set(key, {
147
+ keys: Object.fromEntries(transform.groupBy.map((field, offset) => [field, keys[offset]])),
148
+ values: new Map(),
149
+ denominator: 0,
150
+ validCount: 0
151
+ });
152
+ }
153
+ const group = groups.get(key);
154
+ if (weight === 0) return;
155
+ group.values.set(value, (group.values.get(value) ?? 0) + weight);
156
+ group.denominator += weight;
157
+ group.validCount += 1;
158
+ });
159
+
160
+ if (groups.size === 0) {
161
+ throw new RangeError("ECDF denominator must be greater than zero.");
162
+ }
163
+
164
+ const values = [];
165
+ const provenance = [];
166
+ for (const group of groups.values()) {
167
+ if (!(group.denominator > 0) || !Number.isFinite(group.denominator)) {
168
+ throw new RangeError("ECDF denominator must be finite and greater than zero.");
169
+ }
170
+ const support = [...group.values.keys()].sort((left, right) => left - right);
171
+ values.push({
172
+ ...group.keys,
173
+ [transform.as.value]: support[0],
174
+ [transform.as.cumulative]: 0,
175
+ [transform.as.probability]: 0
176
+ });
177
+ let cumulative = 0;
178
+ for (const value of support) {
179
+ cumulative += group.values.get(value);
180
+ values.push({
181
+ ...group.keys,
182
+ [transform.as.value]: value,
183
+ [transform.as.cumulative]: stableDecimal(cumulative),
184
+ [transform.as.probability]: stableDecimal(cumulative / group.denominator)
185
+ });
186
+ }
187
+ provenance.push({
188
+ keys: group.keys,
189
+ denominator: stableDecimal(group.denominator),
190
+ validCount: group.validCount
191
+ });
192
+ }
193
+ return cloneAndFreeze({ values, resolved: { groups: provenance } });
194
+ }
195
+
196
+ export function resolveECDFTransform(result, transform) {
197
+ return cloneAndFreeze([{ ...transform, resolved: result.resolved }]);
198
+ }