ggaction 0.0.17 → 0.0.18

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 (263) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +2 -2
  3. package/knowledge/action-card.schema.json +1 -1
  4. package/knowledge/action-cards.json +1 -1
  5. package/knowledge/intent-taxonomy.json +1 -1
  6. package/knowledge/mcp-resources.json +1 -1
  7. package/knowledge/task-resolver.js +1 -1
  8. package/package.json +3 -3
  9. package/src/BasicChartProgram.js +1 -1
  10. package/src/accessibility.js +3 -2
  11. package/src/actions/boxPlots/create.js +14 -4
  12. package/src/actions/boxPlots/edit.js +39 -33
  13. package/src/actions/boxPlots/materialize.js +7 -3
  14. package/src/actions/boxPlots/options.js +37 -11
  15. package/src/actions/canvas/actions.js +70 -95
  16. package/src/actions/canvas/fitting.js +16 -1
  17. package/src/actions/canvas/index.js +3 -0
  18. package/src/actions/canvas/plotBorder.js +28 -0
  19. package/src/actions/charts/area.js +2 -2
  20. package/src/actions/charts/bar.js +34 -7
  21. package/src/actions/charts/ecdf.js +4 -18
  22. package/src/actions/charts/endpoints.js +4 -18
  23. package/src/actions/charts/heatmap.js +4 -4
  24. package/src/actions/charts/index.js +2 -0
  25. package/src/actions/charts/interval-regression.js +2 -2
  26. package/src/actions/charts/line.js +1 -1
  27. package/src/actions/charts/pie.js +3 -47
  28. package/src/actions/charts/radial.js +34 -12
  29. package/src/actions/charts/scatter.js +16 -2
  30. package/src/actions/charts/shared.js +2 -2
  31. package/src/actions/charts/text.js +35 -0
  32. package/src/actions/composition/actions.js +6 -2
  33. package/src/actions/data/box.js +2 -1
  34. package/src/actions/data/edit.js +1 -1
  35. package/src/actions/data/regression.js +2 -1
  36. package/src/actions/data/shared.js +3 -2
  37. package/src/actions/data/stack.js +3 -1
  38. package/src/actions/encodings/appearance.js +37 -29
  39. package/src/actions/encodings/barWidth.js +36 -11
  40. package/src/actions/encodings/channels.js +0 -1
  41. package/src/actions/encodings/color/continuous.js +1 -1
  42. package/src/actions/encodings/color/index.js +1 -1
  43. package/src/actions/encodings/color/policy.js +10 -3
  44. package/src/actions/encodings/density/resolve.js +1 -6
  45. package/src/actions/encodings/position/policies/arc.js +12 -10
  46. package/src/actions/encodings/position/policies/area.js +3 -3
  47. package/src/actions/encodings/position/policies/bar.js +15 -6
  48. package/src/actions/encodings/position/policies/line.js +17 -6
  49. package/src/actions/encodings/position/resolve.js +3 -2
  50. package/src/actions/encodings/ranged.js +17 -0
  51. package/src/actions/encodings/remove.js +3 -2
  52. package/src/actions/encodings/seriesLayout.js +1 -0
  53. package/src/actions/encodings/text.js +6 -3
  54. package/src/actions/errorBands/create.js +2 -2
  55. package/src/actions/errorBands/edit.js +2 -1
  56. package/src/actions/facets/actions.js +158 -240
  57. package/src/actions/gradientPlots/resolve.js +2 -17
  58. package/src/actions/guides/applicability.js +1 -1
  59. package/src/actions/guides/axes/axis.js +16 -4
  60. package/src/actions/guides/axes/edit.js +4 -4
  61. package/src/actions/guides/axes/labels.js +120 -46
  62. package/src/actions/guides/axes/lines.js +6 -5
  63. package/src/actions/guides/axes/parallel/lifecycle.js +1 -1
  64. package/src/actions/guides/axes/parallel/policy.js +8 -7
  65. package/src/actions/guides/axes/remove.js +3 -2
  66. package/src/actions/guides/axes/tickGroups.js +5 -2
  67. package/src/actions/guides/axes/ticks.js +6 -3
  68. package/src/actions/guides/axes/titles.js +71 -26
  69. package/src/actions/guides/basic.js +2 -0
  70. package/src/actions/guides/facadeLegend.js +10 -2
  71. package/src/actions/guides/grids/grid.js +7 -1
  72. package/src/actions/guides/grids/resolve.js +59 -12
  73. package/src/actions/guides/legends/blocks.js +3 -1
  74. package/src/actions/guides/legends/categorical/actions.js +70 -17
  75. package/src/actions/guides/legends/categorical/components.js +12 -8
  76. package/src/actions/guides/legends/categorical/index.js +6 -0
  77. package/src/actions/guides/legends/categorical/layout.js +37 -8
  78. package/src/actions/guides/legends/categorical/options.js +31 -20
  79. package/src/actions/guides/legends/categorical/resolve.js +2 -2
  80. package/src/actions/guides/legends/categorical/symbols.js +18 -12
  81. package/src/actions/guides/legends/categorical/validation.js +1 -6
  82. package/src/actions/guides/legends/continuous/colorSymbols.js +1 -0
  83. package/src/actions/guides/legends/continuous/common.js +1 -447
  84. package/src/actions/guides/legends/continuous/gradient.js +47 -34
  85. package/src/actions/guides/legends/continuous/interval.js +4 -4
  86. package/src/actions/guides/legends/continuous/opacity.js +19 -12
  87. package/src/actions/guides/legends/edit.js +1 -604
  88. package/src/actions/guides/legends/focused.js +2 -2
  89. package/src/actions/guides/legends/group.js +1 -0
  90. package/src/actions/guides/legends/lane.js +1 -516
  91. package/src/actions/guides/legends/remove.js +3 -0
  92. package/src/actions/guides/legends/sampling.js +1 -1
  93. package/src/actions/guides/legends/size.js +51 -43
  94. package/src/actions/guides/legends/strokeWidth.js +11 -29
  95. package/src/actions/guides/legends/target.js +3 -3
  96. package/src/actions/guides/legends/transition.js +21 -7
  97. package/src/actions/guides/polar/axes/labels.js +4 -2
  98. package/src/actions/guides/polar/axes/titles.js +7 -5
  99. package/src/actions/guides/polar/resolve.js +6 -14
  100. package/src/actions/index.js +16 -16
  101. package/src/actions/marks/arc/actions.js +3 -10
  102. package/src/actions/marks/area/actions.js +8 -5
  103. package/src/actions/marks/area/materialize.js +8 -9
  104. package/src/actions/marks/bar/create.js +10 -4
  105. package/src/actions/marks/bar/edit.js +8 -4
  106. package/src/actions/marks/bar/materialize.js +9 -6
  107. package/src/actions/marks/line/actions.js +11 -4
  108. package/src/actions/marks/line/materialize.js +9 -5
  109. package/src/actions/marks/point/edit.js +1 -1
  110. package/src/actions/marks/rect/actions.js +3 -3
  111. package/src/actions/marks/remove.js +2 -0
  112. package/src/actions/marks/rule/actions.js +7 -4
  113. package/src/actions/marks/text/actions.js +17 -5
  114. package/src/actions/marks/text/layout.js +3 -0
  115. package/src/actions/primitives/semanticValidation/layer.js +4 -0
  116. package/src/actions/regression/create.js +1 -1
  117. package/src/actions/regression/edit.js +3 -3
  118. package/src/actions/regression/resolve.js +2 -6
  119. package/src/actions/scales/channels.js +9 -4
  120. package/src/actions/scales/consumers/families.js +16 -5
  121. package/src/actions/scales/create.js +8 -3
  122. package/src/actions/scales/definitions.js +1 -425
  123. package/src/actions/scales/edit.js +2 -2
  124. package/src/actions/scales/editPolicy.js +9 -3
  125. package/src/actions/scales/materialize.js +1 -1
  126. package/src/actions/scales/preview.js +21 -1
  127. package/src/actions/scales/quantitativeColor.js +3 -3
  128. package/src/actions/selection/actions.js +1 -13
  129. package/src/actions/theme/reconcile.js +81 -27
  130. package/src/actions/theme/state.js +12 -0
  131. package/src/actions/titles/actions.js +5 -18
  132. package/src/actions/titles/resolve.js +41 -19
  133. package/src/actions/violinPlots/edit.js +3 -20
  134. package/src/core/compositionState.js +4 -1
  135. package/src/core/font.js +9 -2
  136. package/src/core/optionRequirements.js +1 -0
  137. package/src/core/resourceReferences.js +3 -0
  138. package/src/core/textMetricProfile.js +3 -2
  139. package/src/core/textMetrics.js +41 -4
  140. package/src/core/validation.js +26 -0
  141. package/src/core/vocabulary.js +4 -0
  142. package/src/grammar/aggregate.js +4 -18
  143. package/src/grammar/arcs.js +1 -386
  144. package/src/grammar/areaPaint.js +10 -0
  145. package/src/grammar/areaSeries.js +18 -10
  146. package/src/grammar/bars/geometry.js +30 -3
  147. package/src/grammar/bars/policy.js +12 -1
  148. package/src/grammar/bin.js +2 -12
  149. package/src/grammar/bin2d.js +1 -6
  150. package/src/grammar/boxPlot.js +56 -9
  151. package/src/grammar/categoryOrder.js +1 -6
  152. package/src/grammar/complete.js +15 -19
  153. package/src/grammar/computed.js +92 -25
  154. package/src/grammar/curveCommands.js +29 -16
  155. package/src/grammar/datasetSchema.js +3 -7
  156. package/src/grammar/displayLabels.js +28 -0
  157. package/src/grammar/ecdf.js +2 -9
  158. package/src/grammar/facets/dependencies.js +6 -2
  159. package/src/grammar/facets/guides.js +5 -0
  160. package/src/grammar/facets/index.js +2 -5
  161. package/src/grammar/fold.js +2 -12
  162. package/src/grammar/gradientProfile.js +1 -6
  163. package/src/grammar/impute.js +21 -48
  164. package/src/grammar/interval.js +2 -9
  165. package/src/grammar/legendGradient.js +18 -0
  166. package/src/grammar/legendGroups.js +1 -0
  167. package/src/grammar/legendTitleStyle.js +22 -0
  168. package/src/grammar/lineSeries.js +32 -17
  169. package/src/grammar/markLabels.js +5 -2
  170. package/src/grammar/normalize.js +4 -54
  171. package/src/grammar/numeric.js +1 -290
  172. package/src/grammar/parallelCoordinates.js +1 -6
  173. package/src/grammar/parseTime.js +41 -0
  174. package/src/grammar/pathCommands.js +11 -4
  175. package/src/grammar/polar.js +1 -184
  176. package/src/grammar/positionCompatibility.js +3 -3
  177. package/src/grammar/regression/derive.js +7 -1
  178. package/src/grammar/regression/models.js +83 -55
  179. package/src/grammar/regression/parameters.js +14 -3
  180. package/src/grammar/roundedRect.js +42 -54
  181. package/src/grammar/scales/appearance.js +1 -206
  182. package/src/grammar/scales/color.js +1 -382
  183. package/src/grammar/scales/colorConsumers.js +5 -2
  184. package/src/grammar/scales/definition.js +1 -197
  185. package/src/grammar/scales/discretized.js +1 -195
  186. package/src/grammar/scales/size.js +1 -456
  187. package/src/grammar/scales/temporal.js +2 -13
  188. package/src/grammar/scales/transformed.js +1 -383
  189. package/src/grammar/scales/types.js +1 -1
  190. package/src/grammar/scales/validation.js +1 -66
  191. package/src/grammar/schemas/concreteGraphic.js +11 -0
  192. package/src/grammar/schemas/graphic.js +3 -0
  193. package/src/grammar/schemas/semanticPath.js +2 -1
  194. package/src/grammar/stack.js +2 -10
  195. package/src/grammar/statisticalReference.js +1 -6
  196. package/src/grammar/summary.js +3 -26
  197. package/src/grammar/text.js +19 -0
  198. package/src/grammar/timeUnit.js +16 -7
  199. package/src/grammar/transformKeys.js +47 -0
  200. package/src/grammar/transforms.js +4 -1
  201. package/src/grammar/valueFormat.js +17 -5
  202. package/src/grammar/weightedStatistics.js +1 -6
  203. package/src/grammar/window.js +4 -58
  204. package/src/inspectionDescriptors.js +1 -1
  205. package/src/layout/canvas.js +1 -110
  206. package/src/layout/facets.js +1 -244
  207. package/src/layout/labels.js +1 -0
  208. package/src/layout/legend.js +13 -0
  209. package/src/layout/legendGroup.js +1 -0
  210. package/src/layout/legendItems.js +59 -15
  211. package/src/layout/legendLane.js +1 -441
  212. package/src/layout/legendText.js +1 -0
  213. package/src/layout/title.js +35 -13
  214. package/src/materialization/bars/aggregate.js +11 -5
  215. package/src/materialization/bars/histogram.js +4 -1
  216. package/src/materialization/bars/ranged.js +19 -6
  217. package/src/materialization/bars/resolve.js +22 -1
  218. package/src/materialization/coordinateBounds.js +8 -0
  219. package/src/materialization/dependencies.js +4 -44
  220. package/src/materialization/facetGuides/layout.js +1 -0
  221. package/src/materialization/facetGuides/placement.js +19 -8
  222. package/src/materialization/facetGuides/preparation.js +15 -7
  223. package/src/materialization/facetHeaders.js +1 -347
  224. package/src/materialization/facets.js +1 -185
  225. package/src/materialization/graphicHierarchy.js +1 -1
  226. package/src/materialization/guides/layout.js +1 -57
  227. package/src/materialization/guides/resources.js +1 -123
  228. package/src/materialization/layout.js +53 -0
  229. package/src/materialization/legendGroups.js +1 -0
  230. package/src/materialization/legendText.js +1 -0
  231. package/src/materialization/legends.js +1 -1
  232. package/src/materialization/marks/capabilities.js +5 -6
  233. package/src/materialization/rect.js +1 -1
  234. package/src/materialization/scaleGuideDependencies.js +1 -1
  235. package/src/materialization/scales/map.js +3 -0
  236. package/src/materialization/scales/policies/arc.js +8 -7
  237. package/src/materialization/scales/policies/bar.js +19 -7
  238. package/src/materialization/scales/policies/series.js +2 -1
  239. package/src/materialization/scales/resolve.js +5 -3
  240. package/src/materialization/selection/items/arc.js +1 -1
  241. package/src/materialization/selection/items/bar.js +7 -6
  242. package/src/materialization/selection/items/path.js +7 -6
  243. package/src/materialization/selection/items/rect.js +2 -2
  244. package/src/materialization/text.js +13 -4
  245. package/src/materialization/textLines.js +15 -0
  246. package/src/persistence/codec.js +37 -6
  247. package/src/persistence/validation.js +47 -0
  248. package/src/persistence.js +18 -5
  249. package/src/renderers/canvas/circle.js +6 -3
  250. package/src/renderers/canvas/fill.js +7 -1
  251. package/src/renderers/canvas/index.js +2 -1
  252. package/src/renderers/canvas/line.js +1 -0
  253. package/src/renderers/canvas/path.js +1 -1
  254. package/src/renderers/canvas/rect.js +11 -8
  255. package/src/renderers/canvas/stroke.js +2 -1
  256. package/src/renderers/canvas/text.js +5 -3
  257. package/src/renderers/svg.js +4 -1
  258. package/src/selectors/layers.js +19 -0
  259. package/src/theme/defaults.js +8 -2
  260. package/src/version.js +1 -1
  261. package/types/basic.d.ts +1 -0
  262. package/types/index.d.ts +7 -0
  263. package/types/program.d.ts +347 -85
@@ -13,30 +13,31 @@ function indexSegments(segments) {
13
13
  return indexed;
14
14
  }
15
15
 
16
- export function deriveAreaSeries(rows, layer) {
16
+ export function deriveAreaSeries(rows, layer, options = {}) {
17
17
  if (layer?.mark?.type !== "area") {
18
18
  throw new Error("Area series derivation requires a semantic area mark.");
19
19
  }
20
20
  const { x, y, x2, y2 } = layer.encoding ?? {};
21
21
  const vertical =
22
- ["quantitative", "temporal"].includes(x?.fieldType) &&
22
+ ["quantitative", "temporal", "nominal", "ordinal"].includes(x?.fieldType) &&
23
23
  y?.fieldType === "quantitative" &&
24
24
  y2?.fieldType === "quantitative" &&
25
25
  x2 === undefined;
26
26
  const horizontal =
27
- ["quantitative", "temporal"].includes(y?.fieldType) &&
27
+ ["quantitative", "temporal", "nominal", "ordinal"].includes(y?.fieldType) &&
28
28
  x?.fieldType === "quantitative" &&
29
29
  x2?.fieldType === "quantitative" &&
30
30
  y2 === undefined;
31
31
  if (vertical === horizontal) {
32
32
  throw new Error(
33
- `Area mark "${layer.id}" requires exactly one quantitative x/x2 or y/y2 range and one quantitative or temporal independent position.`
33
+ `Area mark "${layer.id}" requires exactly one quantitative x/x2 or y/y2 range and one quantitative, temporal, or categorical independent position.`
34
34
  );
35
35
  }
36
36
  const grouping = validatePathSeriesAppearance(rows, layer);
37
37
  const orientation = vertical ? "vertical" : "horizontal";
38
38
  const independent = vertical ? x : y;
39
- const independentValues = independent.fieldType === "temporal"
39
+ const categorical = ["nominal", "ordinal"].includes(independent.fieldType);
40
+ const independentValues = categorical ? readNominalField(rows, independent.field) : independent.fieldType === "temporal"
40
41
  ? readTemporalField(rows, independent.field, independent.temporalUnit)
41
42
  : readQuantitativeField(rows, independent.field);
42
43
  if (Object.hasOwn(independent, "datum")) throw new Error("Area independent position requires a field.");
@@ -46,6 +47,9 @@ export function deriveAreaSeries(rows, layer) {
46
47
  const missing = layer.mark.missing ?? "error";
47
48
  const lower = readAreaEndpoint(rows, primary, missing);
48
49
  const upper = readAreaEndpoint(rows, secondary, missing);
50
+ const categoryOrder = categorical ? options[vertical ? "xDomain" : "yDomain"] ?? [...new Set(independentValues)] : undefined;
51
+ const ranks = new Map(categoryOrder?.map((value, index) => [value, index]));
52
+ if (categorical && independentValues.some(value => !ranks.has(value))) throw new Error("Area categorical domain must contain every observed position.");
49
53
  const pathOrder = layer.encoding?.pathOrder;
50
54
  const orderValues = pathOrder === undefined
51
55
  ? undefined
@@ -83,7 +87,7 @@ export function deriveAreaSeries(rows, layer) {
83
87
  const series = [...groups.values()].flatMap(item => {
84
88
  const key = vertical ? "x" : "y";
85
89
  const values = pathOrder === undefined
86
- ? item.values.sort((left, right) => left[key] - right[key])
90
+ ? item.values.sort((left, right) => categorical ? ranks.get(left[key]) - ranks.get(right[key]) : left[key] - right[key])
87
91
  : stableOrderPathValues(
88
92
  item.values.map(({ pathOrder: _pathOrder, ...value }) => value),
89
93
  item.values.map(value => value.pathOrder),
@@ -125,13 +129,13 @@ export function deriveAreaSeries(rows, layer) {
125
129
  });
126
130
  }
127
131
 
128
- export function deriveCenteredAreaSeries(rows, layer) {
132
+ export function deriveCenteredAreaSeries(rows, layer, options = {}) {
129
133
  if (layer?.mark?.type !== "area") {
130
134
  throw new Error("Centered area series derivation requires an area mark.");
131
135
  }
132
136
  const { x, y, x2, y2, group, color } = layer.encoding ?? {};
133
137
  if (
134
- !["quantitative", "temporal"].includes(x?.fieldType) ||
138
+ !["quantitative", "temporal", "nominal", "ordinal"].includes(x?.fieldType) ||
135
139
  y?.fieldType !== "quantitative" ||
136
140
  y?.stack !== "center" ||
137
141
  x2 !== undefined ||
@@ -149,7 +153,8 @@ export function deriveCenteredAreaSeries(rows, layer) {
149
153
  `Centered area color on mark "${layer.id}" must match its group field.`
150
154
  );
151
155
  }
152
- const xValues = x.fieldType === "temporal"
156
+ const categorical = ["nominal", "ordinal"].includes(x.fieldType);
157
+ const xValues = categorical ? readNominalField(rows, x.field) : x.fieldType === "temporal"
153
158
  ? readTemporalField(rows, x.field, x.temporalUnit)
154
159
  : readQuantitativeField(rows, x.field);
155
160
  const yValues = readQuantitativeField(rows, y.field);
@@ -172,7 +177,10 @@ export function deriveCenteredAreaSeries(rows, layer) {
172
177
  values.set(xValues[index], yValues[index]);
173
178
  positions.add(xValues[index]);
174
179
  }
175
- const orderedPositions = [...positions].sort((left, right) => left - right);
180
+ const orderedPositions = categorical
181
+ ? (options.xDomain ?? [...positions]).filter(value => positions.has(value))
182
+ : [...positions].sort((left, right) => left - right);
183
+ if (orderedPositions.length !== positions.size) throw new Error("Area categorical domain must contain every observed position.");
176
184
  if (groupOrder.length === 0 || orderedPositions.length < 2) {
177
185
  throw new Error(
178
186
  `Centered area mark "${layer.id}" requires at least two aligned positions.`
@@ -1,3 +1,6 @@
1
+ import { validateOptionObject } from "../../core/validation.js";
2
+ import { floorUtcTimeUnit, offsetUtcTimeUnit, TIME_UNITS, validTimestamp } from "../timeUnit.js";
3
+ import { mapContinuousScaleValues } from "../scales/mapping.js";
1
4
  import { cloneAndFreeze } from "../../core/immutable.js";
2
5
 
3
6
  export const DEFAULT_BAR_WIDTH = Object.freeze({ band: 0.72 });
@@ -9,8 +12,16 @@ export const DEFAULT_OFFSET_PADDING = Object.freeze({
9
12
  export function normalizeBarWidth(options = {}, existing) {
10
13
  const hasBand = options.band !== undefined;
11
14
  const hasPixels = options.pixels !== undefined;
12
- if (hasBand && hasPixels) {
13
- throw new Error("encodeBarWidth band and pixels are mutually exclusive.");
15
+ const hasInterval = options.interval !== undefined;
16
+ if (Number(hasBand) + Number(hasPixels) + Number(hasInterval) > 1) {
17
+ throw new Error("encodeBarWidth band, pixels, and interval are mutually exclusive.");
18
+ }
19
+ if (hasInterval) {
20
+ validateOptionObject(options.interval, ["unit", "insetStart", "insetEnd"], "Bar width interval");
21
+ const { unit, insetStart = 0, insetEnd = 0 } = options.interval;
22
+ if (!TIME_UNITS.includes(unit) || ["week", "weekday"].includes(unit)) throw new Error("Bar width interval requires year, quarter, month, day, hour, minute, or second.");
23
+ if (![insetStart, insetEnd].every(v => Number.isFinite(v) && v >= 0)) throw new RangeError("Bar interval insets must be non-negative finite pixels.");
24
+ return cloneAndFreeze({ interval: { unit, insetStart, insetEnd } });
14
25
  }
15
26
  if (!hasBand && !hasPixels) {
16
27
  return cloneAndFreeze(existing ?? DEFAULT_BAR_WIDTH);
@@ -90,9 +101,25 @@ export function normalizeOffsetScalePolicy(
90
101
  }
91
102
 
92
103
  export function resolveBarWidth(config, slotBandwidth) {
104
+ const normalized = normalizeBarWidth(config);
105
+ if (normalized.pixels !== undefined) return normalized.pixels;
93
106
  if (!Number.isFinite(slotBandwidth) || slotBandwidth <= 0) {
94
107
  throw new Error("Bar width requires a positive resolved slot bandwidth.");
95
108
  }
96
- const normalized = normalizeBarWidth(config);
97
109
  return normalized.pixels ?? slotBandwidth * normalized.band;
98
110
  }
111
+
112
+
113
+ // Width intent never buckets or aggregates data: category values are interval starts.
114
+ export function resolveBarInterval(config, value, scale) {
115
+ if (scale.type !== "time") throw new Error("Bar interval width requires a temporal category scale.");
116
+ const { unit, insetStart, insetEnd } = normalizeBarWidth(config).interval;
117
+ const start = floorUtcTimeUnit(value, unit);
118
+ if (start !== value) throw new Error("Bar interval category values must be UTC interval starts.");
119
+ const end = offsetUtcTimeUnit(start, unit);
120
+ if (!validTimestamp(end)) throw new RangeError("Bar interval end exceeds the supported Date range.");
121
+ if (start < Math.min(...scale.domain) || end > Math.max(...scale.domain)) throw new RangeError("Bar interval endpoints must be inside the temporal scale domain; set an explicit covering domain.");
122
+ const [a, b] = mapContinuousScaleValues([start, end], scale), width = Math.abs(b - a) - insetStart - insetEnd;
123
+ if (width <= 0) throw new RangeError("Bar interval insets must leave a positive width.");
124
+ return { position: Math.min(a, b) + (b >= a ? insetStart : insetEnd), width };
125
+ }
@@ -3,6 +3,7 @@ import { isAggregate } from "../aggregate.js";
3
3
  export const BAR_GRAINS = Object.freeze({
4
4
  histogram: "histogram",
5
5
  aggregate: "aggregate",
6
+ centered: "centered",
6
7
  ranged: "ranged"
7
8
  });
8
9
 
@@ -20,10 +21,17 @@ function isMeasure(encoding) {
20
21
  isAggregate(encoding.aggregate);
21
22
  }
22
23
 
24
+ function hasNumericCenters(layer) {
25
+ return layer.mark.orientation !== undefined && [layer.encoding?.x, layer.encoding?.y].every(
26
+ encoding => encoding?.fieldType === "quantitative" && encoding.bin === undefined && encoding.aggregate === undefined
27
+ );
28
+ }
29
+
23
30
  export function resolveBarOrientation(layer) {
24
31
  if (layer?.mark?.type !== "bar") return undefined;
25
32
  const x = layer.encoding?.x;
26
33
  const y = layer.encoding?.y;
34
+ if (hasNumericCenters(layer)) return layer.mark.orientation;
27
35
  if (x?.bin !== undefined && y?.aggregate === "count") {
28
36
  return BAR_ORIENTATIONS.vertical;
29
37
  }
@@ -56,6 +64,8 @@ export function resolveBarGrain(layer) {
56
64
  const x = layer.encoding?.x;
57
65
  const y = layer.encoding?.y;
58
66
 
67
+ if (hasNumericCenters(layer)) return BAR_GRAINS.centered;
68
+
59
69
  if (
60
70
  x?.bin !== undefined &&
61
71
  y?.aggregate === "count"
@@ -80,7 +90,7 @@ export function inferBarColorLayout(layer) {
80
90
  const grain = resolveBarGrain(layer);
81
91
  if (grain === BAR_GRAINS.histogram) return "stack";
82
92
  if (grain === BAR_GRAINS.aggregate) return "group";
83
- if (grain === BAR_GRAINS.ranged) return "overlay";
93
+ if (grain === BAR_GRAINS.ranged || grain === BAR_GRAINS.centered) return "overlay";
84
94
  return undefined;
85
95
  }
86
96
 
@@ -89,6 +99,7 @@ export function resolveBarColorLayout(layer) {
89
99
  if (layer?.encoding?.color?.layout !== undefined) {
90
100
  return layer.encoding.color.layout;
91
101
  }
102
+ if (resolveBarGrain(layer) === BAR_GRAINS.centered) return "overlay";
92
103
  const channels = resolveBarChannels(layer);
93
104
  if (layer?.encoding?.[channels?.measure]?.stack === "normalize") return "fill";
94
105
  const offsetChannel = resolveBarOffsetChannel(layer);
@@ -1,3 +1,5 @@
1
+ import { rejectUnknownProperties as rejectUnknownKeys } from "../core/validation.js";
2
+ import { requireStringValue as requireField } from "../core/validation.js";
1
3
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
4
  import {
3
5
  findHistogramBinIndex,
@@ -20,19 +22,7 @@ const TRANSFORM_KEYS = Object.freeze([
20
22
  const AS_KEYS = Object.freeze(["lower", "upper", "count", "members"]);
21
23
  const RESOLVED_KEYS = Object.freeze(["domain", "step", "boundaries"]);
22
24
 
23
- function rejectUnknownKeys(value, supported, label) {
24
- const unknown = Object.keys(value).find(key => !supported.includes(key));
25
- if (unknown !== undefined) {
26
- throw new Error(`Unknown ${label} property "${unknown}".`);
27
- }
28
- }
29
25
 
30
- function requireField(value, label) {
31
- if (typeof value !== "string" || value.length === 0) {
32
- throw new TypeError(`${label} must be a non-empty string.`);
33
- }
34
- return value;
35
- }
36
26
 
37
27
  function normalizeAs(field, value, members) {
38
28
  const requested = value ?? {};
@@ -1,3 +1,4 @@
1
+ import { rejectUnknownProperties as rejectUnknownKeys } from "../core/validation.js";
1
2
  import {
2
3
  cloneAndFreeze,
3
4
  isPlainObject
@@ -18,12 +19,6 @@ const RESOLVED_KEYS = Object.freeze([
18
19
  "extent", "edges", "eligibleCount", "occupiedCount"
19
20
  ]);
20
21
 
21
- function rejectUnknownKeys(value, supported, label) {
22
- const unknown = Object.keys(value).find(key => !supported.includes(key));
23
- if (unknown !== undefined) {
24
- throw new Error(`Unknown ${label} property "${unknown}".`);
25
- }
26
- }
27
22
 
28
23
  function requirePositiveInteger(value, label) {
29
24
  if (!Number.isInteger(value) || value <= 0) {
@@ -1,5 +1,5 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
- import { interpolateNumber, stableDecimal } from "./numeric.js";
2
+ import { quantileSorted, stableDecimal } from "./numeric.js";
3
3
 
4
4
  export const BOX_FIELDS = Object.freeze({
5
5
  q1: "__boxPlot_q1", median: "__boxPlot_median", q3: "__boxPlot_q3",
@@ -9,7 +9,7 @@ export const BOX_FIELDS = Object.freeze({
9
9
  });
10
10
 
11
11
  const TRANSFORM_KEYS = [
12
- "type", "category", "field", "method", "whisker", "factor", "as"
12
+ "type", "category", "field", "method", "whisker", "factor", "as", "summary"
13
13
  ];
14
14
  const OUTPUT_KEYS = Object.keys(BOX_FIELDS);
15
15
 
@@ -18,6 +18,36 @@ function field(value, label) {
18
18
  return value;
19
19
  }
20
20
 
21
+ const SUMMARY_KEYS = Object.freeze(["min", "q1", "median", "q3", "max"]);
22
+
23
+ export function normalizeBoxSummary(value) {
24
+ if (value === undefined) return undefined;
25
+ if (!isPlainObject(value) || Object.keys(value).length !== SUMMARY_KEYS.length ||
26
+ !SUMMARY_KEYS.every(key => Object.hasOwn(value, key))) {
27
+ throw new TypeError("Box summary requires min, q1, median, q3 and max field names.");
28
+ }
29
+ return cloneAndFreeze(Object.fromEntries(SUMMARY_KEYS.map(key => [key, field(value[key], `Box summary.${key}`)])));
30
+ }
31
+
32
+ function derivePrecomputedBoxData(rows, transform) {
33
+ const { category, summary, as } = transform;
34
+ const summaries = [];
35
+ for (const row of rows) {
36
+ const key = row[category];
37
+ if (key === undefined || key === null || key === "") continue;
38
+ const values = SUMMARY_KEYS.map(role => row[summary[role]]);
39
+ if (!values.every(Number.isFinite)) throw new TypeError("Precomputed box summaries require finite values for all five fields.");
40
+ if (values.some((value, index) => index > 0 && value < values[index - 1])) {
41
+ throw new RangeError("Box summary must satisfy min <= q1 <= median <= q3 <= max.");
42
+ }
43
+ const [min, q1, median, q3, max] = values;
44
+ summaries.push({ [category]: key, [as.q1]: q1, [as.median]: median, [as.q3]: q3,
45
+ [as.lowerWhisker]: min, [as.upperWhisker]: max });
46
+ }
47
+ if (summaries.length === 0) throw new Error("Box transform requires at least one valid row.");
48
+ return cloneAndFreeze({ summaries, outliers: [] });
49
+ }
50
+
21
51
  function validateOutputs(as, occupied) {
22
52
  if (!isPlainObject(as)) throw new TypeError("Box as must be a plain object.");
23
53
  const keys = Object.keys(as);
@@ -40,6 +70,18 @@ export function validateBoxTransform(value) {
40
70
  }
41
71
  field(value.category, "Box category field");
42
72
  field(value.field, "Box measure field");
73
+ if (value.method === "precomputed") {
74
+ const summary = normalizeBoxSummary(value.summary);
75
+ if (summary === undefined || value.type !== "boxSummary" || value.field !== summary.median) {
76
+ throw new Error("Precomputed box summaries require a summary mapping and its median measure field.");
77
+ }
78
+ if (value.whisker !== undefined || value.factor !== undefined) {
79
+ throw new Error("Precomputed box summaries do not compute whiskers or factors.");
80
+ }
81
+ validateOutputs(value.as, new Set([value.category, ...Object.values(summary)]));
82
+ return value;
83
+ }
84
+ if (value.summary !== undefined) throw new Error("Box summary fields require the precomputed method.");
43
85
  if (value.method !== "linear") throw new Error(`Unsupported box quantile method "${value.method}".`);
44
86
  const whisker = value.whisker ?? "tukey";
45
87
  if (!["tukey", "minmax"].includes(whisker)) {
@@ -56,6 +98,16 @@ export function validateBoxTransform(value) {
56
98
  }
57
99
 
58
100
  export function normalizeBoxTransform(options = {}) {
101
+ if (options.summary !== undefined) {
102
+ const summary = normalizeBoxSummary(options.summary);
103
+ const normalized = { type: options.type ?? "boxSummary", category: options.category,
104
+ field: options.field ?? summary.median, method: "precomputed", summary, as: options.as ?? BOX_FIELDS };
105
+ if (options.factor !== undefined || (options.whisker !== undefined && options.whisker !== "minmax")) {
106
+ throw new Error("Precomputed box summaries require minmax whiskers without a factor.");
107
+ }
108
+ validateBoxTransform(normalized);
109
+ return cloneAndFreeze(normalized);
110
+ }
59
111
  const {
60
112
  type = "boxSummary",
61
113
  category,
@@ -79,16 +131,11 @@ export function normalizeBoxTransform(options = {}) {
79
131
  return cloneAndFreeze(normalized);
80
132
  }
81
133
 
82
- function quantile(sorted, p) {
83
- const position = (sorted.length - 1) * p;
84
- const lower = Math.floor(position);
85
- const upper = Math.ceil(position);
86
- return interpolateNumber(sorted[lower], sorted[upper], position - lower);
87
- }
88
134
 
89
135
  export function deriveBoxData(rows, transform) {
90
136
  if (!Array.isArray(rows)) throw new TypeError("Box rows must be an array.");
91
137
  validateBoxTransform(transform);
138
+ if (transform.method === "precomputed") return derivePrecomputedBoxData(rows, transform);
92
139
  const { category, field: measure, factor, as } = transform;
93
140
  const whisker = transform.whisker ?? "tukey";
94
141
  const groups = [];
@@ -111,7 +158,7 @@ export function deriveBoxData(rows, transform) {
111
158
  for (const group of groups) {
112
159
  const sorted = group.rows.map(row => row[measure]).sort((a, b) => a - b);
113
160
  const [q1, median, q3] = [0.25, 0.5, 0.75].map(probability =>
114
- stableDecimal(quantile(sorted, probability))
161
+ stableDecimal(quantileSorted(sorted, probability))
115
162
  );
116
163
  const spread = q3 - q1;
117
164
  const lowerFence = whisker === "minmax"
@@ -1,3 +1,4 @@
1
+ import { requireStringValue as nonEmptyField } from "../core/validation.js";
1
2
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
3
  import { isNominalValue, readNominalField } from "./scales/fields.js";
3
4
  import { aggregateScalarValues } from "./aggregate.js";
@@ -7,12 +8,6 @@ export const CATEGORY_ORDER_CHANNELS = Object.freeze(["x", "y", "theta"]);
7
8
  const DIRECTIONS = ["ascending", "descending"];
8
9
  const AGGREGATES = ["sum", "mean", "min", "max"];
9
10
 
10
- function nonEmptyField(value, label) {
11
- if (typeof value !== "string" || value.length === 0) {
12
- throw new TypeError(`${label} must be a non-empty string.`);
13
- }
14
- return value;
15
- }
16
11
 
17
12
  const sameValue = Object.is;
18
13
 
@@ -1,3 +1,5 @@
1
+ import { rejectUnknownProperties as rejectUnknownKeys } from "../core/validation.js";
2
+ import { requireStringValue as requireField } from "../core/validation.js";
1
3
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
4
 
3
5
  const TRANSFORM_KEYS = Object.freeze([
@@ -6,17 +8,7 @@ const TRANSFORM_KEYS = Object.freeze([
6
8
  const SEQUENCE_KEYS = Object.freeze(["start", "end", "step"]);
7
9
  const MAX_OUTPUT_ROWS = 10_000;
8
10
 
9
- function rejectUnknownKeys(value, supported, label) {
10
- const unknown = Object.keys(value).find(key => !supported.includes(key));
11
- if (unknown !== undefined) throw new Error(`Unknown ${label} property "${unknown}".`);
12
- }
13
11
 
14
- function requireField(value, label) {
15
- if (typeof value !== "string" || value.length === 0) {
16
- throw new TypeError(`${label} must be a non-empty string.`);
17
- }
18
- return value;
19
- }
20
12
 
21
13
  function scalarType(value, label) {
22
14
  if (value === null) return "null";
@@ -258,20 +250,24 @@ export function deriveCompleteRows(rows, transform) {
258
250
  if (!allowed.has(identity)) {
259
251
  throw new Error(`Complete observed key is outside the requested domain at row ${entry.index}.`);
260
252
  }
261
- if (lookup.has(identity)) {
262
- throw new Error("Complete source contains a duplicate group and key combination.");
263
- }
264
- lookup.set(identity, entry);
253
+ if (!lookup.has(identity)) lookup.set(identity, []);
254
+ lookup.get(identity).push(entry);
255
+ }
256
+ const groupSize = group.values.length + domain.length - lookup.size;
257
+ if (output.length + groupSize > MAX_OUTPUT_ROWS) {
258
+ throw new RangeError(`Complete output cannot exceed ${MAX_OUTPUT_ROWS} rows.`);
265
259
  }
266
260
  for (const keyValue of domain) {
267
261
  const identity = scalarKey(keyValue, "Complete domain value");
268
- const entry = lookup.get(identity);
269
- if (entry === undefined) {
262
+ const entries = lookup.get(identity);
263
+ if (entries === undefined) {
270
264
  output.push(syntheticRow(fields, group, keyValue, transform));
271
265
  } else {
272
- const row = { ...entry.row };
273
- if (transform.members !== undefined) define(row, transform.members, [entry.index]);
274
- output.push(row);
266
+ for (const entry of entries) {
267
+ const row = { ...entry.row };
268
+ if (transform.members !== undefined) define(row, transform.members, [entry.index]);
269
+ output.push(row);
270
+ }
275
271
  }
276
272
  }
277
273
  }
@@ -1,3 +1,6 @@
1
+ import {parseTimeValue,validateParseTimeFormat} from "./parseTime.js";
2
+ import { rejectUnknownProperties as rejectUnknownKeys } from "../core/validation.js";
3
+ import { requireStringValue as requireField } from "../core/validation.js";
1
4
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
5
 
3
6
  const TRANSFORM_KEYS = Object.freeze(["type", "as", "expression"]);
@@ -7,23 +10,12 @@ const NUMERIC_UNARY = new Set(["negate", "absolute", "log", "sqrt"]);
7
10
  const BOOLEAN_UNARY = new Set(["not", "isNull"]);
8
11
  const LOGICAL = new Set(["and", "or"]);
9
12
  const VARIADIC = new Set(["coalesce", "concat"]);
13
+ const RESERVED_PATH_KEYS = new Set(["__proto__", "prototype", "constructor"]);
10
14
  const MAX_DEPTH = 16;
11
15
  const MAX_NODES = 128;
12
16
  const MAX_WORK = 10_000_000;
13
17
 
14
- function rejectUnknownKeys(value, supported, label) {
15
- const unknown = Object.keys(value).find(key => !supported.includes(key));
16
- if (unknown !== undefined) {
17
- throw new Error(`Unknown ${label} property "${unknown}".`);
18
- }
19
- }
20
18
 
21
- function requireField(value, label) {
22
- if (typeof value !== "string" || value.length === 0) {
23
- throw new TypeError(`${label} must be a non-empty string.`);
24
- }
25
- return value;
26
- }
27
19
 
28
20
  function validateConstant(value) {
29
21
  if (value === null || typeof value === "string" || typeof value === "boolean") return;
@@ -50,7 +42,7 @@ function validateOperands(expression, minimum) {
50
42
  }
51
43
  }
52
44
 
53
- function inspectExpression(expression, depth, state) {
45
+ function inspectExpression(expression, depth, state, optionalPathRoot = false) {
54
46
  if (!isPlainObject(expression)) {
55
47
  throw new TypeError("Computed expression nodes must be plain objects.");
56
48
  }
@@ -72,7 +64,9 @@ function inspectExpression(expression, depth, state) {
72
64
  }
73
65
  if (kinds[0] === "field") {
74
66
  rejectUnknownKeys(expression, ["field"], "computed field expression");
75
- state.fields.add(requireField(expression.field, "Computed expression field"));
67
+ const field = requireField(expression.field, "Computed expression field");
68
+ state.fields.add(field);
69
+ if (!optionalPathRoot) state.requiredFields.add(field);
76
70
  return;
77
71
  }
78
72
  if (kinds[0] === "constant") {
@@ -88,7 +82,39 @@ function inspectExpression(expression, depth, state) {
88
82
  inspectExpression(expression.right, depth + 1, state);
89
83
  return;
90
84
  }
91
- if (NUMERIC_UNARY.has(expression.op) || BOOLEAN_UNARY.has(expression.op)) {
85
+ if (expression.op === "getPath") {
86
+ rejectUnknownKeys(expression, ["op", "operand", "path"], "computed path expression");
87
+ if (!isPlainObject(expression.operand) ||
88
+ Object.keys(expression.operand).length !== 1 ||
89
+ !Object.hasOwn(expression.operand, "field")) {
90
+ throw new TypeError("Computed getPath operand must be a literal field reference.");
91
+ }
92
+ if (!Array.isArray(expression.path) || expression.path.length < 1 ||
93
+ expression.path.length > 8 || !Array.from(expression.path).every(key =>
94
+ typeof key === "string" && key.length > 0 && !RESERVED_PATH_KEYS.has(key))) {
95
+ throw new TypeError("Computed getPath path must contain 1 to 8 nonempty, nonreserved string keys.");
96
+ }
97
+ inspectExpression(expression.operand, depth + 1, state, true);
98
+ return;
99
+ }
100
+ if (expression.op === "replaceLiteral") {
101
+ rejectUnknownKeys(expression, ["op", "operand", "search", "replacement"], "computed literal replacement");
102
+ requireNode(expression, "operand", expression.op);
103
+ if (typeof expression.search !== "string" || expression.search.length === 0 ||
104
+ typeof expression.replacement !== "string") {
105
+ throw new TypeError("Computed replaceLiteral requires a nonempty string search and string replacement.");
106
+ }
107
+ inspectExpression(expression.operand, depth + 1, state);
108
+ return;
109
+ }
110
+ if (expression.op === "parseTime") {
111
+ rejectUnknownKeys(expression, ["op", "operand", "format"], "computed time expression");
112
+ requireNode(expression, "operand", expression.op);
113
+ validateParseTimeFormat(expression.format);
114
+ inspectExpression(expression.operand, depth + 1, state);
115
+ return;
116
+ }
117
+ if (NUMERIC_UNARY.has(expression.op) || BOOLEAN_UNARY.has(expression.op) || expression.op === "toNumber") {
92
118
  rejectUnknownKeys(expression, ["op", "operand"], "computed unary expression");
93
119
  requireNode(expression, "operand", expression.op);
94
120
  inspectExpression(expression.operand, depth + 1, state);
@@ -131,7 +157,7 @@ function inspectComputedTransform(transform) {
131
157
  throw new Error(`Unsupported computed transform "${transform.type}".`);
132
158
  }
133
159
  requireField(transform.as, "Computed output field");
134
- const state = { nodes: 0, fields: new Set(), ancestors: new WeakSet() };
160
+ const state = { nodes: 0, fields: new Set(), requiredFields: new Set(), ancestors: new WeakSet() };
135
161
  inspectExpression(transform.expression, 1, state);
136
162
  return state;
137
163
  }
@@ -233,6 +259,27 @@ function compare(left, right, operation, rowIndex) {
233
259
  : order >= 0;
234
260
  }
235
261
 
262
+ function ownDataProperty(record, key, rowIndex) {
263
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
264
+ if (descriptor === undefined) return undefined;
265
+ if (!Object.hasOwn(descriptor, "value")) {
266
+ throw new TypeError(`Computed getPath cannot read an accessor at row ${rowIndex}.`);
267
+ }
268
+ return descriptor.value;
269
+ }
270
+
271
+ function nestedScalar(expression, row, rowIndex) {
272
+ let value = ownDataProperty(row, expression.operand.field, rowIndex);
273
+ for (const key of expression.path) {
274
+ if (value === undefined || value === null) return null;
275
+ if (!isPlainObject(value)) {
276
+ throw new TypeError(`Computed getPath requires plain records at row ${rowIndex}.`);
277
+ }
278
+ value = ownDataProperty(value, key, rowIndex);
279
+ }
280
+ return scalar(value, "Computed getPath result", rowIndex);
281
+ }
282
+
236
283
  function evaluate(expression, row, rowIndex) {
237
284
  if (Object.hasOwn(expression, "field")) {
238
285
  return scalar(
@@ -243,6 +290,16 @@ function evaluate(expression, row, rowIndex) {
243
290
  }
244
291
  if (Object.hasOwn(expression, "constant")) return expression.constant;
245
292
  const operation = expression.op;
293
+ if (operation === "getPath") return nestedScalar(expression, row, rowIndex);
294
+ if (operation === "replaceLiteral") {
295
+ const value = evaluate(expression.operand, row, rowIndex);
296
+ if (typeof value !== "string") {
297
+ throw new TypeError(`Computed replaceLiteral requires a string at row ${rowIndex}.`);
298
+ }
299
+ const index = value.indexOf(expression.search);
300
+ return index < 0 ? value
301
+ : value.slice(0, index) + expression.replacement + value.slice(index + expression.search.length);
302
+ }
246
303
  if (operation === "if") {
247
304
  return boolean(evaluate(expression.condition, row, rowIndex), operation, rowIndex)
248
305
  ? evaluate(expression.then, row, rowIndex)
@@ -282,6 +339,17 @@ function evaluate(expression, row, rowIndex) {
282
339
  ? value === null
283
340
  : !boolean(value, operation, rowIndex);
284
341
  }
342
+ if (operation === "parseTime") {
343
+ return parseTimeValue(evaluate(expression.operand, row, rowIndex), expression.format);
344
+ }
345
+ if (operation === "toNumber") {
346
+ const value = evaluate(expression.operand, row, rowIndex);
347
+ if (typeof value === "number") return number(value, operation, rowIndex);
348
+ if (typeof value !== "string" || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim())) {
349
+ throw new TypeError(`Computed toNumber requires a finite number or decimal string at row ${rowIndex}.`);
350
+ }
351
+ return number(Number(value.trim()), operation, rowIndex);
352
+ }
285
353
  if (NUMERIC_UNARY.has(operation)) {
286
354
  const value = number(evaluate(expression.operand, row, rowIndex), operation, rowIndex);
287
355
  if (operation === "log" && value <= 0) {
@@ -318,7 +386,7 @@ function evaluate(expression, row, rowIndex) {
318
386
  }
319
387
 
320
388
  export function deriveComputedRows(rows, transform) {
321
- const { nodes, fields } = inspectComputedTransform(transform);
389
+ const { nodes, fields, requiredFields } = inspectComputedTransform(transform);
322
390
  if (rows.length * nodes > MAX_WORK) {
323
391
  throw new RangeError(`Computed work cannot exceed ${MAX_WORK} row-nodes.`);
324
392
  }
@@ -326,15 +394,14 @@ export function deriveComputedRows(rows, transform) {
326
394
  throw new Error(`Computed output field "${transform.as}" already exists.`);
327
395
  }
328
396
  if (rows.length > 0) {
329
- rows.forEach((row, index) => {
330
- for (const field of fields) {
331
- if (!Object.hasOwn(row, field)) {
332
- throw new Error(
333
- `Computed source does not contain field "${field}" at row ${index}.`
334
- );
397
+ for (const field of fields) {
398
+ const knownOptional = !requiredFields.has(field) && rows.some(row => Object.hasOwn(row, field));
399
+ rows.forEach((row, index) => {
400
+ if (!knownOptional && !Object.hasOwn(row, field)) {
401
+ throw new Error(`Computed source does not contain field "${field}" at row ${index}.`);
335
402
  }
336
- }
337
- });
403
+ });
404
+ }
338
405
  }
339
406
  let outputType;
340
407
  return rows.map((row, index) => {