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
@@ -7,7 +7,7 @@ const OPTIONS = Object.freeze([
7
7
  "type",
8
8
  "domain",
9
9
  "range",
10
- "interpolate", "midpoint",
10
+ "interpolate", "midpoint", "base", "constant",
11
11
  "clamp",
12
12
  "reverse",
13
13
  "unknown"
@@ -19,7 +19,7 @@ export const setQuantitativeColorScale = /* @__PURE__ */ closedAction(
19
19
  description: "Create or update an internal quantitative color scale."
20
20
  }, OPTIONS,
21
21
  function (args = {}) {
22
- if (!["sequential", "quantize", "quantile", "threshold"].includes(args.type)) {
22
+ if (!["sequential", "log", "sqrt", "symlog", "quantize", "quantile", "threshold"].includes(args.type)) {
23
23
  throw new Error(`Unsupported quantitative color scale type "${args.type}".`);
24
24
  }
25
25
  const existing = findSemanticScale(this, args.id);
@@ -34,7 +34,7 @@ export const setQuantitativeColorScale = /* @__PURE__ */ closedAction(
34
34
  next = next.editSemantic({ property: `scale[${args.id}].midpoint`, remove: true });
35
35
  }
36
36
  for (const property of [
37
- "type", "domain", "range", "interpolate", "midpoint", "clamp", "reverse", "unknown"
37
+ "type", "domain", "range", "interpolate", "midpoint", "base", "constant", "clamp", "reverse", "unknown"
38
38
  ]) {
39
39
  if (!Object.hasOwn(args, property)) continue;
40
40
  if (existing?.[property] === args[property]) continue;
@@ -19,7 +19,6 @@ import {
19
19
  findSelectionPolicy,
20
20
  requireSelectionPolicy
21
21
  } from "../../materialization/selection/policies/index.js";
22
- import { legendGraphicIds } from "../../materialization/guides/resources.js";
23
22
  import {
24
23
  normalizeDimOthers,
25
24
  validateUnitInterval
@@ -84,18 +83,7 @@ function categoricalLegendKinds(program, target) {
84
83
  function resetCategoricalLegendSymbols(program, target) {
85
84
  const kinds = categoricalLegendKinds(program, target);
86
85
  if (kinds.length === 0) return program;
87
- let next = program;
88
- for (const kind of kinds) {
89
- for (const id of legendGraphicIds(kind).filter(id => id.includes("Symbol"))) {
90
- const graphic = next.graphicSpec.objects[id];
91
- if (graphic === undefined) continue;
92
- next = graphic.type === "collection"
93
- ? next.editGraphics({ target: id, property: "items", value: [] })
94
- : next.editGraphics({ target: id, property: "length", value: 0 });
95
- }
96
- next = next.rematerializeLegendSymbols({ kind });
97
- }
98
- return next;
86
+ return program.rematerializeLegend();
99
87
  }
100
88
 
101
89
  function targetHighlightEntries(program, target) {
@@ -1,5 +1,5 @@
1
1
  import { rematerializeTypography } from "../../materialization/typography.js";
2
- import { themeTokens } from "../../theme/defaults.js";
2
+ import { AXIS_LABEL_FONT_SIZES, AXIS_TITLE_FONT_SIZE, themeTokens } from "../../theme/defaults.js";
3
3
  import {
4
4
  normalizeThemeState,
5
5
  resolveEffectiveThemeTokens,
@@ -21,7 +21,7 @@ const THEME_STYLE_PROPERTIES = Object.freeze([
21
21
  "background",
22
22
  "fill",
23
23
  "stroke",
24
- "fontFamily"
24
+ "fontFamily", "fontSize"
25
25
  ]);
26
26
  const COLOR_TOKENS = Object.freeze([
27
27
  "mark",
@@ -51,15 +51,38 @@ function tokenFor(value, sourceTokens, targetTokens) {
51
51
  return matches[0];
52
52
  }
53
53
 
54
- function roleValues(role, sourceTokens, legacy = []) {
54
+ function axisFamily(name) {
55
+ return name.startsWith("parallel") ? "parallel"
56
+ : /^(theta|radial|radius)/u.test(name) ? "polar" : "cartesian";
57
+ }
58
+
59
+ function axisTextRole(property, component) {
60
+ if (!["labels", "title"].includes(component)) return undefined;
61
+ const role = component === "labels" ? "axisLabel" : "axisTitle";
62
+ if (property === "fontFamily") return `${role}FontFamily`;
63
+ if (property === "fontSize") return `${role}FontSize`;
64
+ if (["fill", "color"].includes(property)) return role;
65
+ return undefined;
66
+ }
67
+
68
+ function roleToken(tokens, role, family = "cartesian") {
69
+ if (tokens[role] !== undefined) return tokens[role];
70
+ if (role === "axisLabel") return family === "parallel" ? tokens.axis : tokens.text;
71
+ if (["axisLabelFontFamily", "axisTitleFontFamily"].includes(role)) return tokens.fontFamily;
72
+ if (role === "axisLabelFontSize") return AXIS_LABEL_FONT_SIZES[family];
73
+ if (role === "axisTitleFontSize") return AXIS_TITLE_FONT_SIZE;
74
+ return undefined;
75
+ }
76
+
77
+ function roleValues(role, sourceTokens, legacy = [], family) {
55
78
  return new Set([
56
- sourceTokens[role],
57
- themeTokens("light")[role],
79
+ roleToken(sourceTokens, role, family),
80
+ roleToken(themeTokens("light"), role, family),
58
81
  ...legacy.map(token => themeTokens("light")[token] ?? token)
59
82
  ]);
60
83
  }
61
84
 
62
- function mapRoleValue(value, role, sourceTokens, targetTokens, legacy) {
85
+ function mapRoleValue(value, role, sourceTokens, targetTokens, legacy, family) {
63
86
  if (Array.isArray(value)) {
64
87
  let changed = false;
65
88
  const next = value.map(item => {
@@ -68,17 +91,18 @@ function mapRoleValue(value, role, sourceTokens, targetTokens, legacy) {
68
91
  role,
69
92
  sourceTokens,
70
93
  targetTokens,
71
- legacy
94
+ legacy, family
72
95
  );
73
96
  changed ||= mapped.changed;
74
97
  return mapped.value;
75
98
  });
76
99
  return { changed, value: changed ? next : value };
77
100
  }
78
- if (!roleValues(role, sourceTokens, legacy).has(value)) {
101
+ if (!roleValues(role, sourceTokens, legacy, family).has(value)) {
79
102
  return { changed: false, value };
80
103
  }
81
- return { changed: value !== targetTokens[role], value: targetTokens[role] };
104
+ const target = roleToken(targetTokens, role, family);
105
+ return { changed: value !== target, value: target };
82
106
  }
83
107
 
84
108
  function mapColorValue(value, sourceTokens, targetTokens) {
@@ -158,6 +182,9 @@ function componentRole(program, id, property) {
158
182
  }
159
183
 
160
184
  function graphicRole(program, id, property) {
185
+ const axisPart = /Axis(Labels|Titles?)$/u.exec(id);
186
+ const scoped = axisPart === null ? undefined : axisTextRole(property, axisPart[1] === "Labels" ? "labels" : "title");
187
+ if (scoped !== undefined) return scoped;
161
188
  if (property === "fontFamily") return "fontFamily";
162
189
  if (id === "canvas" && property === "background") return "background";
163
190
  if (id === `${program.compositionSpec?.id}-headers` && property === "fill") {
@@ -198,6 +225,7 @@ function graphicRole(program, id, property) {
198
225
  }
199
226
 
200
227
  function roleLegacy(role) {
228
+ if (role === "axisLabel") return ["text", "axis"];
201
229
  if (role === "axis") return ["text"];
202
230
  if (role === "axisTitle") return ["text", "strongText"];
203
231
  if (role === "strongText") return ["text"];
@@ -238,7 +266,7 @@ function mapGraphicProperties(
238
266
  role,
239
267
  sourceTokens,
240
268
  targetTokens,
241
- roleLegacy(role)
269
+ roleLegacy(role), axisFamily(id)
242
270
  );
243
271
  if (mapped.changed) {
244
272
  next[property] = mapped.value;
@@ -304,6 +332,11 @@ function recolorGraphic(
304
332
 
305
333
  function configRole(program, path) {
306
334
  const property = path.at(-1);
335
+ if (path[0] === "guides" && path[1] === "axis") {
336
+ const component = path[2] === "parallel" ? path.at(-2) : path[3];
337
+ const scoped = axisTextRole(property, component);
338
+ if (scoped !== undefined) return scoped;
339
+ }
307
340
  if (property === "fontFamily") return "fontFamily";
308
341
  if (path[0] === "facets" && path.includes("headers") &&
309
342
  property === "color") {
@@ -369,7 +402,7 @@ function recolorConfig(
369
402
  }
370
403
  if (value === null || typeof value !== "object") {
371
404
  const property = path.at(-1);
372
- if (!["color", "fill", "stroke", "background", "fontFamily"]
405
+ if (!["color", "fill", "stroke", "background", "fontFamily", "fontSize"]
373
406
  .includes(property)) {
374
407
  return { changed: false, value };
375
408
  }
@@ -387,7 +420,7 @@ function recolorConfig(
387
420
  role,
388
421
  sourceTokens,
389
422
  targetTokens,
390
- roleLegacy(role)
423
+ roleLegacy(role), axisFamily(String(path[2] ?? ""))
391
424
  );
392
425
  }
393
426
  if (["theme", "highlights", "selections"].includes(path[0])) {
@@ -722,10 +755,11 @@ function addAxisOverrides(overrides, op, args) {
722
755
  `g:${idPrefix}Axis${match[2]}.${component === "line" || component === "ticks" ? "stroke" : "fill"}`
723
756
  );
724
757
  }
725
- if (["labels", "title"].includes(component) &&
726
- Object.hasOwn(args, "fontFamily")) {
727
- overrides.add(`c:guides.axis.${channel}.${component}.fontFamily`);
728
- overrides.add(`g:${idPrefix}Axis${match[2]}.fontFamily`);
758
+ for (const property of ["fontFamily", "fontSize"]) {
759
+ if (["labels", "title"].includes(component) && Object.hasOwn(args, property)) {
760
+ overrides.add(`c:guides.axis.${channel}.${component}.${property}`);
761
+ overrides.add(`g:${idPrefix}Axis${match[2]}.${property}`);
762
+ }
729
763
  }
730
764
  }
731
765
 
@@ -750,15 +784,22 @@ function addCompleteAxisOverride(overrides, name, args) {
750
784
  `g:${idPrefix}Axis${suffix}.${component === "line" || component === "ticks" ? "stroke" : "fill"}`
751
785
  );
752
786
  }
753
- if (["labels", "title"].includes(component) &&
754
- Object.hasOwn(options ?? {}, "fontFamily")) {
755
- overrides.add(`c:guides.axis.${channel}.${component}.fontFamily`);
756
- overrides.add(`g:${idPrefix}Axis${suffix}.fontFamily`);
787
+ for (const property of ["fontFamily", "fontSize"]) {
788
+ if (["labels", "title"].includes(component) && Object.hasOwn(options ?? {}, property)) {
789
+ overrides.add(`c:guides.axis.${channel}.${component}.${property}`);
790
+ overrides.add(`g:${idPrefix}Axis${suffix}.${property}`);
791
+ }
757
792
  }
758
793
  }
759
794
  }
760
795
 
761
796
  function addCompleteAxisOverrides(overrides, op, args) {
797
+ if (args.guides?.axes !== null && typeof args.guides?.axes === "object") {
798
+ addCompleteAxisOverrides(overrides, "createAxes", args.guides.axes);
799
+ }
800
+ if (op === "createGuides" && args.axes !== null && typeof args.axes === "object") {
801
+ addCompleteAxisOverrides(overrides, "createAxes", args.axes);
802
+ }
762
803
  const match = /^(?:create|edit)(X|Y|Theta|Radial)Axis$/u.exec(op);
763
804
  if (match !== null) addCompleteAxisOverride(overrides, match[1], args);
764
805
  const group = /^(?:create|edit)(X|Y)AxisTicksAndLabels$/u.exec(op);
@@ -823,11 +864,10 @@ function addParallelOverrides(overrides, program, op, args) {
823
864
  `c:guides.axis.parallel.axes.dimensions.${index}.${component}.color`
824
865
  );
825
866
  }
826
- if (["labels", "title"].includes(component) &&
827
- Object.hasOwn(options ?? {}, "fontFamily")) {
828
- overrides.add(
829
- `c:guides.axis.parallel.axes.dimensions.${index}.${component}.fontFamily`
830
- );
867
+ for (const property of ["fontFamily", "fontSize"]) {
868
+ if (["labels", "title"].includes(component) && Object.hasOwn(options ?? {}, property)) {
869
+ overrides.add(`c:guides.axis.parallel.axes.dimensions.${index}.${component}.${property}`);
870
+ }
831
871
  }
832
872
  }
833
873
  }
@@ -921,7 +961,7 @@ function explicitOverrides(program, node) {
921
961
  const overrides = new Set();
922
962
  if (op === "editGraphics" &&
923
963
  typeof args.target === "string" &&
924
- ["background", "fill", "stroke", "fontFamily"].includes(args.property)) {
964
+ ["background", "fill", "stroke", "fontFamily", "fontSize"].includes(args.property)) {
925
965
  overrides.add(`g:${args.target}.${args.property}`);
926
966
  }
927
967
  if (["createCanvas", "editCanvas"].includes(op) &&
@@ -962,6 +1002,13 @@ function explicitOverrides(program, node) {
962
1002
  if (op === "editFacetHeaders") {
963
1003
  addFacetOverrides(overrides, program, args);
964
1004
  }
1005
+ if (["facet", "facetGrid", "repeatCharts"].includes(op) && args.headers !== undefined) {
1006
+ const { row, column, ...common } = args.headers;
1007
+ addFacetOverrides(overrides, program, common);
1008
+ for (const [role, patch] of [["row", row], ["column", column]]) {
1009
+ if (patch !== undefined) addFacetOverrides(overrides, program, { ...patch, role });
1010
+ }
1011
+ }
965
1012
  return overrides;
966
1013
  }
967
1014
 
@@ -979,6 +1026,12 @@ function collectOverrides(program) {
979
1026
  return overrides;
980
1027
  }
981
1028
 
1029
+ function axisTypographyChanged(before, after) {
1030
+ if (before === after || after === null || typeof after !== "object") return false;
1031
+ return Object.entries(after).some(([key, value]) => ["fontFamily", "fontSize"].includes(key)
1032
+ ? value !== before?.[key] : axisTypographyChanged(before?.[key], value));
1033
+ }
1034
+
982
1035
  export function reconcileProgramTheme(program, { source, metadata }) {
983
1036
  program = reconcileFollowingRegressions(program);
984
1037
  const storedState = program.materializationConfigs.theme;
@@ -1033,7 +1086,8 @@ export function reconcileProgramTheme(program, { source, metadata }) {
1033
1086
 
1034
1087
  const facetsChanged = configs.value.facets !==
1035
1088
  themed.materializationConfigs.facets;
1036
- const typographyChanged = sourceTokens.fontFamily !== targetTokens.fontFamily;
1089
+ const typographyChanged = sourceTokens.fontFamily !== targetTokens.fontFamily ||
1090
+ axisTypographyChanged(themed.guideConfigs.axis, configs.value.guides.axis);
1037
1091
  if (facetsChanged && next.compositionSpec?.type === "facet") {
1038
1092
  next = next.materializeComposition();
1039
1093
  } else if (typographyChanged) {
@@ -158,3 +158,15 @@ export function setThemeStateOverrides(state, overrides) {
158
158
  overrides: cloneAndFreeze([...overrides].sort())
159
159
  });
160
160
  }
161
+
162
+ // Resolve active font defaults before guide layout validates their occupied bounds.
163
+ export function axisThemeTypography(state, component) {
164
+ if (resolveEffectiveThemeFrame(state) === undefined) return {};
165
+ const tokens = resolveEffectiveThemeTokens(state);
166
+ const role = component === "labels" ? "axisLabel" : "axisTitle";
167
+ const fontSize = tokens[`${role}FontSize`];
168
+ return {
169
+ fontFamily: tokens[`${role}FontFamily`] ?? tokens.fontFamily,
170
+ ...(fontSize === undefined ? {} : { fontSize })
171
+ };
172
+ }
@@ -1,3 +1,4 @@
1
+ import { paintedTitleComponent, titleTextProperties } from "../../layout/title.js";
1
2
  import { withGuideLayoutValidation } from "../../materialization/guides/layout.js";
2
3
  import { action } from "../../core/action.js";
3
4
  import { noOptions } from "../../core/validation.js";
@@ -56,24 +57,9 @@ function ensureTextShape(program, id, component) {
56
57
  return program;
57
58
  }
58
59
 
59
- function distributed(value, count) {
60
- return count === 1 && Array.isArray(value) ? value[0] : value;
61
- }
62
-
63
60
  function editTextGraphic(program, id, component, style) {
64
- const count = component.lines.length;
65
61
  let next = ensureTextShape(program, id, component);
66
- for (const [property, value] of Object.entries({
67
- x: distributed(component.x, count),
68
- y: distributed(component.y, count),
69
- text: distributed(component.lines, count),
70
- fill: style.color,
71
- fontSize: style.fontSize,
72
- fontFamily: style.fontFamily,
73
- fontWeight: style.fontWeight,
74
- textAlign: component.textAlign,
75
- textBaseline: "middle"
76
- })) {
62
+ for (const [property, value] of Object.entries(titleTextProperties(component, style))) {
77
63
  next = next.editGraphics({ target: id, property, value });
78
64
  }
79
65
  if (component.explicitRotation) {
@@ -99,10 +85,11 @@ function titleTextAction(kind, create) {
99
85
  if (create && this.graphicSpec.objects[id] !== undefined) {
100
86
  throw new Error(`${op} requires a missing chart ${kind} graphic.`);
101
87
  }
102
- const component = resolveTitleLayout(this, config)[kind];
103
- if (component === undefined) {
88
+ const layout = resolveTitleLayout(this, config)[kind];
89
+ if (layout === undefined) {
104
90
  throw new Error(`${op} requires semantic subtitle text.`);
105
91
  }
92
+ const component = paintedTitleComponent(layout);
106
93
  if (!create) {
107
94
  return editTextGraphic(this, id, component, config[`${kind}Style`]);
108
95
  }
@@ -1,3 +1,7 @@
1
+ import { textBoundsFitCanvas } from "../../core/textMetrics.js";
2
+ import { resolveGuideCollisionBlocks } from "../../materialization/guides/resources.js";
3
+ import { canvasOverflowError } from "../../layout/canvas.js";
4
+ import { validateFontStyle } from "../../core/font.js";
1
5
  import { isPlainObject } from "../../core/immutable.js";
2
6
  import {
3
7
  validateKeys,
@@ -19,11 +23,11 @@ import { findCanvasGraphic } from
19
23
  "../../materialization/graphicHierarchy.js";
20
24
 
21
25
  const OPTIONS = Object.freeze([
22
- "text", "subtitle", "position", "align", "offset", "gap", "titleStyle",
26
+ "text", "subtitle", "position", "align", "offset", "alongOffset", "gap", "titleStyle",
23
27
  "subtitleStyle", "maxWidth", "wrap", "lineHeight"
24
28
  ]);
25
29
  const STYLE_OPTIONS = Object.freeze([
26
- "color", "fontSize", "fontFamily", "fontWeight"
30
+ "color", "fontSize", "fontFamily", "fontWeight", "fontStyle"
27
31
  ]);
28
32
  const POSITIONS = Object.freeze(["top", "bottom", "left", "right"]);
29
33
  const ALIGNS = Object.freeze(["left", "center", "right"]);
@@ -32,6 +36,7 @@ const DEFAULTS = Object.freeze({
32
36
  position: "top",
33
37
  align: "left",
34
38
  offset: 0,
39
+ alongOffset: 0,
35
40
  gap: 8,
36
41
  titleStyle: Object.freeze({
37
42
  color: DEFAULT_COLORS.strongText,
@@ -47,9 +52,12 @@ const DEFAULTS = Object.freeze({
47
52
  })
48
53
  });
49
54
 
50
- export function validateTitleString(value, label) {
55
+ export function validateTitleString(value, label, multiline = false) {
51
56
  validateNonEmptyString(value, label);
52
- if (/\r|\n/u.test(value)) {
57
+ if (multiline && value.split(/\r\n|\r|\n/u).every(line => line === "")) {
58
+ throw new Error(`${label} requires at least one non-empty line.`);
59
+ }
60
+ if (!multiline && /\r|\n/u.test(value)) {
53
61
  throw new Error(`${label} does not accept explicit newlines.`);
54
62
  }
55
63
  return value;
@@ -70,6 +78,7 @@ function normalizeStyle(value, defaults, label) {
70
78
  )) {
71
79
  throw new TypeError(`${label} fontWeight must be a non-empty string or number.`);
72
80
  }
81
+ if (style.fontStyle !== undefined) validateFontStyle(style.fontStyle, label);
73
82
  return style;
74
83
  }
75
84
 
@@ -78,6 +87,7 @@ function normalizeConfig(args, defaults, { hasSubtitle }) {
78
87
  const align = args.align ?? defaults.align;
79
88
  const offset = args.offset ?? defaults.offset;
80
89
  const gap = args.gap ?? defaults.gap;
90
+ const alongOffset = args.alongOffset ?? defaults.alongOffset ?? 0;
81
91
  const titleStyle = normalizeStyle(
82
92
  args.titleStyle,
83
93
  defaults.titleStyle,
@@ -100,6 +110,7 @@ function normalizeConfig(args, defaults, { hasSubtitle }) {
100
110
  if (!Number.isFinite(offset)) {
101
111
  throw new TypeError("Chart title offset must be a finite number.");
102
112
  }
113
+ if (!Number.isFinite(alongOffset)) throw new TypeError("Chart title alongOffset must be a finite number.");
103
114
  validateNonNegativeFinite(gap, "Chart title gap");
104
115
  if (maxWidth !== undefined) {
105
116
  validatePositiveFinite(maxWidth, "Chart title maxWidth");
@@ -121,6 +132,7 @@ function normalizeConfig(args, defaults, { hasSubtitle }) {
121
132
  position,
122
133
  align,
123
134
  offset,
135
+ alongOffset,
124
136
  gap,
125
137
  titleStyle,
126
138
  subtitleStyle,
@@ -132,10 +144,10 @@ function normalizeConfig(args, defaults, { hasSubtitle }) {
132
144
 
133
145
  export function normalizeTitleOptions(args) {
134
146
  validateOptionObject(args, OPTIONS, "createTitle");
135
- const text = validateTitleString(args.text, "Chart title text");
147
+ const text = validateTitleString(args.text, "Chart title text", true);
136
148
  const subtitle = args.subtitle === undefined
137
149
  ? undefined
138
- : validateTitleString(args.subtitle, "Chart subtitle");
150
+ : validateTitleString(args.subtitle, "Chart subtitle", true);
139
151
  return {
140
152
  text,
141
153
  subtitle,
@@ -151,12 +163,12 @@ export function normalizeTitleEditOptions(args, previous, semanticTitle) {
151
163
  });
152
164
  const text = args.text === undefined
153
165
  ? semanticTitle.text
154
- : validateTitleString(args.text, "Chart title text");
166
+ : validateTitleString(args.text, "Chart title text", true);
155
167
  const subtitle = args.subtitle === false
156
168
  ? undefined
157
169
  : args.subtitle === undefined
158
170
  ? semanticTitle.subtitle
159
- : validateTitleString(args.subtitle, "Chart subtitle");
171
+ : validateTitleString(args.subtitle, "Chart subtitle", true);
160
172
  if (args.subtitle !== undefined && args.subtitle !== false && typeof args.subtitle !== "string") {
161
173
  throw new TypeError("Chart subtitle must be a non-empty string or false.");
162
174
  }
@@ -175,11 +187,8 @@ export function requireTitleConfig(program) {
175
187
  }
176
188
 
177
189
  function validateLayout(position, titleBounds, plot, canvas) {
178
- if (
179
- titleBounds.left < 0 || titleBounds.top < 0 ||
180
- titleBounds.right > canvas.width || titleBounds.bottom > canvas.height
181
- ) {
182
- throw new Error(`Chart title requires more ${position}-margin space.`);
190
+ if (!textBoundsFitCanvas(titleBounds, canvas)) {
191
+ throw canvasOverflowError(`Chart title requires more ${position}-margin space.`, [titleBounds], canvas);
183
192
  }
184
193
  const outsidePlot = {
185
194
  top: titleBounds.bottom <= plot.y,
@@ -205,19 +214,31 @@ export function resolveTitleLayout(program, config) {
205
214
  throw new Error("Chart title layout requires Canvas bounds and dimensions.");
206
215
  }
207
216
  const block = buildTitleReadingBlock({
208
- text: validateTitleString(program.semanticSpec.title.text, "Chart title text"),
217
+ text: validateTitleString(program.semanticSpec.title.text, "Chart title text", true),
209
218
  subtitle: program.semanticSpec.title.subtitle === undefined
210
219
  ? undefined
211
220
  : validateTitleString(
212
221
  program.semanticSpec.title.subtitle,
213
- "Chart subtitle"
222
+ "Chart subtitle", true
214
223
  )
215
224
  }, config, program.materializationConfigs.textMetrics);
225
+ const automatic = program.materializationConfigs.canvas?.plot !== undefined;
226
+ const near = ["top", "left"].includes(config.position);
216
227
  const horizontal = ["top", "bottom"].includes(config.position);
228
+ let titleEdge = horizontal ? plot.y + (near ? 0 : plot.height)
229
+ : plot.x + (near ? 0 : plot.width);
230
+ if (automatic) {
231
+ const occupied = resolveGuideCollisionBlocks(program.graphicSpec, program.guideConfigs,
232
+ undefined, program.materializationConfigs.textMetrics).filter(item => item.position === config.position);
233
+ const key = horizontal ? (near ? "top" : "bottom") : (near ? "left" : "right");
234
+ for (const item of occupied) titleEdge = near ? Math.min(titleEdge, item.bounds[key]) : Math.max(titleEdge, item.bounds[key]);
235
+ titleEdge += (near ? -1 : 1) * (16 + config.offset);
236
+ }
217
237
  let component;
218
238
  if (horizontal) {
219
- const x = alignedTextAnchor(plot.x, plot.width, config.align);
220
- const blockTop = config.position === "top"
239
+ const x = alignedTextAnchor(plot.x, plot.width, config.align) + (config.alongOffset ?? 0);
240
+ const blockTop = automatic ? titleEdge - (near ? block.height : 0)
241
+ : config.position === "top"
221
242
  ? 16 + config.offset
222
243
  : plot.y + plot.height + config.offset;
223
244
  component = (lines, centers) => ({
@@ -230,8 +251,9 @@ export function resolveTitleLayout(program, config) {
230
251
  });
231
252
  } else {
232
253
  const rotation = config.position === "left" ? -Math.PI / 2 : Math.PI / 2;
233
- const y = alignedTitleAnchor(plot.y, plot.height, block.width, config.align);
234
- const edge = config.position === "left"
254
+ const y = alignedTitleAnchor(plot.y, plot.height, block.width, config.align) + (config.alongOffset ?? 0);
255
+ const edge = automatic ? titleEdge + (near ? -block.height : block.height)
256
+ : config.position === "left"
235
257
  ? 16 + config.offset
236
258
  : canvas.properties.width - 16 + config.offset;
237
259
  const mapX = value => config.position === "left" ? edge + value : edge - value;
@@ -2,7 +2,7 @@ import { action } from "../../core/action.js";
2
2
  import { validateUserId } from "../../core/identifiers.js";
3
3
  import { validateKeys } from "../../core/validation.js";
4
4
  import { findDataset } from "../../selectors/datasets.js";
5
- import { findLayer } from "../../selectors/layers.js";
5
+ import { findLayer, resolveConfiguredOwner } from "../../selectors/layers.js";
6
6
  import { rebindDistributionGuides } from "../distributions/revision.js";
7
7
  import {
8
8
  resolveViolinDensity,
@@ -15,24 +15,7 @@ const OPTIONS = Object.freeze([
15
15
  "target", "data", "x", "y", "split", "density"
16
16
  ]);
17
17
 
18
- function resolveOwner(program, requested) {
19
- const eligible = program.semanticSpec.layers.filter(
20
- layer => program.markConfigs[layer.id]?.violinPlot?.materialized === true
21
- );
22
- if (requested !== undefined) {
23
- const id = validateUserId(requested, "Violin-plot owner id");
24
- const layer = findLayer(program, id);
25
- if (layer === undefined || !eligible.includes(layer)) {
26
- throw new Error(`Unknown violin-plot owner "${id}".`);
27
- }
28
- return layer;
29
- }
30
- const current = findLayer(program, program.context.currentMark);
31
- if (current !== undefined && eligible.includes(current)) return current;
32
- if (eligible.length === 1) return eligible[0];
33
- if (eligible.length === 0) throw new Error(`${OPERATION} requires a violin plot.`);
34
- throw new Error(`${OPERATION} target is ambiguous; provide target.`);
35
- }
18
+
36
19
 
37
20
  function currentPosition(owner, current, channel) {
38
21
  const categorical = current.orientation === "vertical"
@@ -84,7 +67,7 @@ export const editViolinPlot = /* @__PURE__ */ action(
84
67
  if (!OPTIONS.slice(1).some(key => Object.hasOwn(args, key))) {
85
68
  throw new Error(`${OPERATION} requires at least one violin-plot option.`);
86
69
  }
87
- const owner = resolveOwner(this, args.target);
70
+ const owner = resolveConfiguredOwner(this, args.target, { config: "violinPlot", operation: OPERATION, kind: "violin", materialized: true }, validateUserId);
88
71
  const current = this.markConfigs[owner.id].violinPlot;
89
72
  const source = Object.hasOwn(args, "data")
90
73
  ? validateUserId(args.data, "Violin-plot data id")
@@ -272,7 +272,7 @@ export function ownCompositionSpec(compositionSpec, children) {
272
272
  }
273
273
  const allowed = [
274
274
  "id", "type", "direction", "children", "columns", "gap", "align",
275
- "padding", "facet"
275
+ "padding", "facet", "spacing"
276
276
  ];
277
277
  const unknown = Object.keys(compositionSpec).find(key => !allowed.includes(key));
278
278
  if (unknown !== undefined) {
@@ -282,6 +282,9 @@ export function ownCompositionSpec(compositionSpec, children) {
282
282
  throw new TypeError("compositionSpec.id must be a non-empty string.");
283
283
  }
284
284
  const facet = compositionSpec.type === "facet";
285
+ if (compositionSpec.spacing !== undefined && (!facet || !["plot", "canvas"].includes(compositionSpec.spacing))) {
286
+ throw new Error("compositionSpec.spacing requires a facet and canvas or plot spacing.");
287
+ }
285
288
  if (compositionSpec.type !== undefined && !facet) {
286
289
  throw new Error(`Unknown compositionSpec type "${compositionSpec.type}".`);
287
290
  }
package/src/core/font.js CHANGED
@@ -5,8 +5,15 @@ export function normalizeRendererFontWeight(fontWeight) {
5
5
  return Math.min(900, Math.max(100, Math.round(fontWeight / 100) * 100));
6
6
  }
7
7
 
8
- export function textMetricKey({ text, fontFamily, fontSize, fontWeight }) {
9
- return JSON.stringify([text, fontFamily, fontSize, fontWeight]);
8
+ export function validateFontStyle(value, label = "Text") {
9
+ if (!["normal", "italic"].includes(value)) {
10
+ throw new Error(`${label} fontStyle must be normal or italic.`);
11
+ }
12
+ return value;
13
+ }
14
+
15
+ export function textMetricKey({ text, fontFamily, fontSize, fontWeight, fontStyle = "normal" }) {
16
+ return JSON.stringify([text, fontFamily, fontSize, fontWeight, fontStyle]);
10
17
  }
11
18
 
12
19
  export function textMetricFontWeight(value = "normal") {
@@ -1,6 +1,7 @@
1
1
  // The method's value-producing contract is shared by runtime validation and
2
2
  // authoring knowledge; editing can inherit these values from stored state.
3
3
  export const IMPUTE_REQUIRED_OPTIONS = Object.freeze({
4
+ mean: Object.freeze([]),
4
5
  constant: Object.freeze(["value"]),
5
6
  forward: Object.freeze(["sortBy"]),
6
7
  backward: Object.freeze(["sortBy"]),
@@ -199,6 +199,9 @@ export function collectMarkConfigReferences(program) {
199
199
 
200
200
  export function collectGuideConfigReferences(program) {
201
201
  const references = [];
202
+ for (const target of Object.keys(program.guideConfigs?.legendGroups ?? {})) {
203
+ append(references, reference("mark", target, "legend", target, ["legendGroups", target]));
204
+ }
202
205
  for (const [family, configs] of Object.entries(program.guideConfigs ?? {})) {
203
206
  collectKnownKeys(configs, [], (key, value, path, parentPath) => {
204
207
  const ownerId = parentPath.length === 0 ? family : parentPath.join(".");
@@ -2,7 +2,7 @@ import { cloneAndFreeze } from "./immutable.js";
2
2
  import { validateUserId } from "./identifiers.js";
3
3
  import { validateOptionObject, validatePositiveFinite, validateNonNegativeFinite, validateNonEmptyString } from "./validation.js";
4
4
 
5
- import { textMetricKey } from "./font.js";
5
+ import { textMetricKey, validateFontStyle } from "./font.js";
6
6
 
7
7
  export function normalizeTextMetricProfile(profile) {
8
8
  validateOptionObject(profile, ["schemaVersion", "id", "measurements"], "Text metrics profile");
@@ -11,7 +11,7 @@ export function normalizeTextMetricProfile(profile) {
11
11
  if (!Array.isArray(profile.measurements)) throw new TypeError("Text metrics measurements must be an array.");
12
12
  const seen = new Set();
13
13
  for (const measurement of profile.measurements) {
14
- validateOptionObject(measurement, ["text", "fontFamily", "fontSize", "fontWeight", "width"], "Text measurement");
14
+ validateOptionObject(measurement, ["text", "fontFamily", "fontSize", "fontWeight", "fontStyle", "width"], "Text measurement");
15
15
  if (typeof measurement.text !== "string") throw new TypeError("Text measurement text must be a string.");
16
16
  validateNonEmptyString(measurement.fontFamily, "Text measurement fontFamily");
17
17
  validatePositiveFinite(measurement.fontSize, "Text measurement fontSize");
@@ -20,6 +20,7 @@ export function normalizeTextMetricProfile(profile) {
20
20
  measurement.fontWeight > 900 || measurement.fontWeight % 100 !== 0) {
21
21
  throw new RangeError("Text measurement fontWeight must be 100–900 in steps of 100.");
22
22
  }
23
+ if (measurement.fontStyle !== undefined) validateFontStyle(measurement.fontStyle, "Text measurement");
23
24
  const key = textMetricKey(measurement);
24
25
  if (seen.has(key)) throw new Error("Duplicate text measurement combination.");
25
26
  seen.add(key);