ggaction 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +5 -4
  3. package/knowledge/action-card.schema.json +1 -0
  4. package/knowledge/action-cards.json +1 -10941
  5. package/knowledge/action-cards.schema.json +1 -0
  6. package/knowledge/extension-authoring.md +248 -0
  7. package/knowledge/intent-taxonomy.json +1 -183
  8. package/knowledge/intent-taxonomy.schema.json +1 -0
  9. package/knowledge/mcp-resources.json +1 -95
  10. package/knowledge/mcp-resources.schema.json +1 -0
  11. package/knowledge/task-packet.schema.json +1 -159
  12. package/knowledge/task-resolver.js +1 -1230
  13. package/package.json +19 -2
  14. package/src/actions/boxPlots/edit.js +45 -298
  15. package/src/actions/boxPlots/options.js +46 -77
  16. package/src/actions/boxPlots/resolve.js +1 -1
  17. package/src/actions/charts/bar.js +3 -0
  18. package/src/actions/charts/heatmap.js +73 -83
  19. package/src/actions/charts/index.js +1 -1
  20. package/src/actions/charts/parallel.js +35 -1
  21. package/src/actions/charts/shared.js +20 -0
  22. package/src/actions/composition/actions.js +46 -49
  23. package/src/actions/data/bin2d.js +105 -2
  24. package/src/actions/data/box.js +17 -17
  25. package/src/actions/data/density.js +42 -78
  26. package/src/actions/data/gradientProfile.js +22 -52
  27. package/src/actions/data/horizon.js +16 -41
  28. package/src/actions/data/index.js +10 -10
  29. package/src/actions/data/interval.js +16 -38
  30. package/src/actions/data/intervalEdit.js +55 -0
  31. package/src/actions/data/regression.js +7 -17
  32. package/src/actions/data/shared.js +47 -0
  33. package/src/actions/data/timeUnit.js +14 -38
  34. package/src/actions/data/window.js +14 -38
  35. package/src/actions/distributions/revision.js +154 -34
  36. package/src/actions/encodings/angle.js +9 -10
  37. package/src/actions/encodings/appearance.js +17 -51
  38. package/src/actions/encodings/density/resolve.js +3 -1
  39. package/src/actions/encodings/density.js +1 -15
  40. package/src/actions/encodings/horizon/resolve.js +13 -3
  41. package/src/actions/encodings/horizon.js +8 -7
  42. package/src/actions/encodings/index.js +9 -9
  43. package/src/actions/encodings/offset.js +8 -24
  44. package/src/actions/encodings/parallel.js +9 -10
  45. package/src/actions/encodings/pathOrder.js +6 -13
  46. package/src/actions/encodings/position/policies/index.js +4 -4
  47. package/src/actions/encodings/position/policies/line.js +47 -17
  48. package/src/actions/encodings/position/resolve.js +24 -5
  49. package/src/actions/encodings/ranged.js +18 -23
  50. package/src/actions/encodings/remove.js +15 -25
  51. package/src/actions/encodings/ruleAppearance.js +9 -24
  52. package/src/actions/encodings/shared.js +37 -0
  53. package/src/actions/encodings/strokeDash.js +8 -23
  54. package/src/actions/encodings/text.js +9 -10
  55. package/src/actions/errorBands/create.js +105 -39
  56. package/src/actions/errorBands/edit.js +89 -93
  57. package/src/actions/errorBands/index.js +1 -2
  58. package/src/actions/errorBars/create.js +78 -27
  59. package/src/actions/errorBars/edit.js +81 -48
  60. package/src/actions/errorBars/index.js +1 -2
  61. package/src/actions/facets/actions.js +45 -69
  62. package/src/actions/facets/derive.js +38 -53
  63. package/src/actions/facets/replay.js +0 -1
  64. package/src/actions/gradientPlots/components.js +59 -48
  65. package/src/actions/gradientPlots/edit.js +35 -216
  66. package/src/actions/gradientPlots/index.js +11 -10
  67. package/src/actions/gradientPlots/materialize.js +21 -35
  68. package/src/actions/gradientPlots/options.js +5 -7
  69. package/src/actions/gradientPlots/paint.js +9 -6
  70. package/src/actions/gradientPlots/resolve.js +1 -1
  71. package/src/actions/guides/axes/index.js +3 -3
  72. package/src/actions/guides/axes/labels.js +68 -26
  73. package/src/actions/guides/axes/lines.js +22 -2
  74. package/src/actions/guides/axes/policy.js +28 -8
  75. package/src/actions/guides/axes/ticks.js +27 -9
  76. package/src/actions/guides/axes/titles.js +113 -65
  77. package/src/actions/guides/grids/resolve.js +14 -0
  78. package/src/actions/guides/guides.js +2 -4
  79. package/src/actions/guides/index.js +2 -2
  80. package/src/actions/guides/legends/categorical/actions.js +44 -68
  81. package/src/actions/guides/legends/categorical/components.js +6 -1
  82. package/src/actions/guides/legends/categorical/index.js +2 -2
  83. package/src/actions/guides/legends/categorical/layout.js +148 -175
  84. package/src/actions/guides/legends/categorical/recipes.js +5 -0
  85. package/src/actions/guides/legends/categorical/resolve.js +8 -1
  86. package/src/actions/guides/legends/continuous/common.js +117 -66
  87. package/src/actions/guides/legends/continuous/gradient.js +38 -37
  88. package/src/actions/guides/legends/continuous/interval.js +97 -76
  89. package/src/actions/guides/legends/continuous/opacity.js +28 -38
  90. package/src/actions/guides/legends/edit.js +14 -1
  91. package/src/actions/guides/legends/index.js +1 -1
  92. package/src/actions/guides/legends/lane.js +2 -1
  93. package/src/actions/guides/legends/remove.js +8 -0
  94. package/src/actions/guides/legends/size.js +36 -43
  95. package/src/actions/guides/legends/strokeWidth.js +20 -26
  96. package/src/actions/guides/polar/axes/labels.js +17 -1
  97. package/src/actions/guides/polar/axes/shared.js +7 -1
  98. package/src/actions/guides/polar/axes/titles.js +19 -2
  99. package/src/actions/guides/polar/grids.js +4 -0
  100. package/src/actions/guides/polar/resolve.js +6 -0
  101. package/src/actions/index.js +13 -13
  102. package/src/actions/intervals/resolve.js +87 -117
  103. package/src/actions/marks/arc/actions.js +31 -36
  104. package/src/actions/marks/area/actions.js +23 -31
  105. package/src/actions/marks/index.js +6 -6
  106. package/src/actions/marks/line/actions.js +33 -52
  107. package/src/actions/marks/line/materialize.js +7 -5
  108. package/src/actions/marks/point/materialize.js +5 -3
  109. package/src/actions/marks/remove.js +21 -0
  110. package/src/actions/marks/rule/actions.js +53 -53
  111. package/src/actions/marks/shared.js +8 -4
  112. package/src/actions/marks/text/actions.js +14 -6
  113. package/src/actions/marks/text/layout.js +21 -45
  114. package/src/actions/marks/tick/actions.js +22 -21
  115. package/src/actions/primitives/createGraphics.js +4 -0
  116. package/src/actions/primitives/editGraphics.js +4 -0
  117. package/src/actions/primitives/index.js +3 -3
  118. package/src/actions/primitives/semantic.js +1 -1
  119. package/src/actions/primitives/semanticValidation/index.js +2 -2
  120. package/src/actions/primitives/semanticValidation/layer.js +1 -1
  121. package/src/actions/regression/edit.js +3 -43
  122. package/src/actions/scales/consumers/families.js +24 -4
  123. package/src/actions/scales/consumers/index.js +1 -1
  124. package/src/actions/scales/consumers/seriesLayout.js +17 -0
  125. package/src/actions/scales/definitions.js +100 -103
  126. package/src/actions/scales/materialize.js +5 -0
  127. package/src/actions/selection/actions.js +79 -121
  128. package/src/actions/titles/actions.js +48 -94
  129. package/src/actions/titles/resolve.js +14 -27
  130. package/src/actions/violinPlots/create.js +37 -44
  131. package/src/core/action.js +20 -5
  132. package/src/core/extensionRegistry.js +104 -0
  133. package/src/core/textMetrics.js +74 -37
  134. package/src/core/validation.js +18 -0
  135. package/src/extension.js +8 -1
  136. package/src/grammar/aggregate.js +48 -28
  137. package/src/grammar/arcs.js +58 -11
  138. package/src/grammar/areaSeries.js +14 -16
  139. package/src/grammar/bin2d.js +37 -23
  140. package/src/grammar/boxPlot.js +14 -13
  141. package/src/grammar/categoricalDensity.js +14 -14
  142. package/src/grammar/categoryOrder.js +8 -14
  143. package/src/grammar/curveCommands.js +87 -20
  144. package/src/grammar/density.js +162 -141
  145. package/src/grammar/facets/index.js +14 -0
  146. package/src/grammar/facets/scales.js +7 -4
  147. package/src/grammar/gradientProfile.js +12 -15
  148. package/src/grammar/histogram.js +145 -83
  149. package/src/grammar/horizon.js +60 -23
  150. package/src/grammar/interval.js +14 -13
  151. package/src/grammar/lineSeries.js +91 -61
  152. package/src/grammar/numeric.js +290 -0
  153. package/src/grammar/paint.js +3 -4
  154. package/src/grammar/palettes.js +21 -32
  155. package/src/grammar/pathCommands.js +6 -0
  156. package/src/grammar/polarPaths.js +5 -5
  157. package/src/grammar/regression/derive.js +48 -9
  158. package/src/grammar/regression/models.js +248 -69
  159. package/src/grammar/regression/parameters.js +8 -0
  160. package/src/grammar/scales/color.js +35 -26
  161. package/src/grammar/scales/continuous.js +71 -24
  162. package/src/grammar/scales/definition.js +1 -1
  163. package/src/grammar/scales/discretized.js +42 -12
  164. package/src/grammar/scales/fields.js +21 -4
  165. package/src/grammar/scales/ordinal.js +117 -61
  166. package/src/grammar/scales/temporal.js +40 -11
  167. package/src/grammar/scales/transformed.js +199 -52
  168. package/src/grammar/schemas/graphicBounds.js +270 -79
  169. package/src/grammar/seriesLayout.js +50 -18
  170. package/src/grammar/ticks.js +123 -53
  171. package/src/grammar/timeUnit.js +18 -8
  172. package/src/grammar/transforms.js +4 -1
  173. package/src/grammar/window.js +70 -57
  174. package/src/index.js +1 -1
  175. package/src/layout/canvas.js +4 -8
  176. package/src/layout/composition.js +39 -40
  177. package/src/layout/facets.js +10 -14
  178. package/src/layout/labels.js +91 -15
  179. package/src/layout/legend.js +22 -20
  180. package/src/layout/legendLane.js +54 -40
  181. package/src/layout/title.js +7 -7
  182. package/src/materialization/bars/aggregate.js +14 -40
  183. package/src/materialization/bars/grouped.js +7 -18
  184. package/src/materialization/bars/histogram.js +2 -19
  185. package/src/materialization/bars/ranged.js +20 -15
  186. package/src/materialization/bars/resolve.js +21 -0
  187. package/src/materialization/facetGuides/legacyCategorical.js +2 -1
  188. package/src/materialization/facetGuides/placement.js +34 -19
  189. package/src/materialization/facetGuides/preparation.js +9 -5
  190. package/src/materialization/facets.js +39 -27
  191. package/src/materialization/guides/resources.js +2 -1
  192. package/src/materialization/marks/capabilities.js +5 -2
  193. package/src/materialization/marks/index.js +24 -4
  194. package/src/materialization/scales/policies/bar.js +61 -59
  195. package/src/materialization/scales/policies/binnedPosition.js +34 -0
  196. package/src/materialization/scales/policies/series.js +13 -7
  197. package/src/materialization/scales/resolve.js +4 -5
  198. package/src/materialization/selection/items/arc.js +16 -2
  199. package/src/materialization/selection/items/bar.js +4 -2
  200. package/src/materialization/selection/items/common.js +21 -1
  201. package/src/materialization/selection/items/path.js +19 -2
  202. package/src/materialization/selection/items/point.js +4 -16
  203. package/src/materialization/selection/items/tick.js +13 -21
  204. package/src/materialization/selection/point.js +11 -4
  205. package/src/materialization/selection/styles.js +19 -27
  206. package/src/materialization/text.js +4 -1
  207. package/src/mcp/adapter.js +8 -3
  208. package/src/renderers/canvas/fill.js +2 -2
  209. package/src/renderers/canvas/index.js +35 -2
  210. package/src/renderers/canvas/native.js +160 -0
  211. package/src/renderers/pdf.js +14 -0
  212. package/src/renderers/svg.js +37 -4
  213. package/types/extension.d.ts +19 -0
  214. package/types/index.d.ts +3 -0
  215. package/types/program.d.ts +509 -131
  216. package/types/svg.d.ts +1 -0
  217. package/src/actions/errorBands/components.js +0 -81
  218. package/src/actions/errorBands/options.js +0 -39
  219. package/src/actions/errorBands/resolve.js +0 -28
  220. package/src/actions/errorBars/components.js +0 -62
  221. package/src/actions/errorBars/options.js +0 -58
  222. package/src/actions/errorBars/resolve.js +0 -25
@@ -1,7 +1,7 @@
1
1
  import { resolveOptionalUserId, validateUserId } from "../../core/identifiers.js";
2
2
  import { findLayer, hasLayer } from "../../selectors/layers.js";
3
3
 
4
- const CATEGORICAL = Object.freeze(["nominal", "ordinal", "temporal"]);
4
+ const CATEGORICAL = Object.freeze(["nominal", "ordinal"]);
5
5
 
6
6
  export function resolveGradientOrientation(x, y) {
7
7
  const xType = x?.fieldType;
@@ -1,17 +1,17 @@
1
- import { registerAxisCollectionActions } from "./axes.js";
1
+ import { registerAxisTickGroupActions } from "./tickGroups.js";
2
+ import { registerCompleteAxisEditActions } from "./edit.js";
2
3
  import { registerAxisLabelActions } from "./labels.js";
3
4
  import { registerAxisLineActions } from "./lines.js";
4
- import { registerAxisTickGroupActions } from "./tickGroups.js";
5
5
  import { registerAxisTickActions } from "./ticks.js";
6
6
  import { registerAxisTitleActions } from "./titles.js";
7
7
  import { registerAxisActions } from "./axis.js";
8
- import { registerCompleteAxisEditActions } from "./edit.js";
9
8
  import {
10
9
  removeRadialAxis,
11
10
  removeThetaAxis,
12
11
  removeXAxis,
13
12
  removeYAxis
14
13
  } from "./remove.js";
14
+ import { registerAxisCollectionActions } from "./axes.js";
15
15
  import { registerParallelAxisActions } from "./parallel.js";
16
16
 
17
17
  export function registerBasicCartesianAxisActions(ProgramClass) {
@@ -2,6 +2,7 @@ import { action } from "../../../core/action.js";
2
2
  import { validateUserId } from "../../../core/identifiers.js";
3
3
  import {
4
4
  sameOrderedValues,
5
+ validateGeneratedItemLimit,
5
6
  validateOptionObject,
6
7
  validateNonEmptyString,
7
8
  validateNonNegativeFinite,
@@ -27,20 +28,36 @@ import {
27
28
  } from "./policy.js";
28
29
  import { findCanvasGraphic, resolvePlotGraphicPlacement } from
29
30
  "../../../materialization/graphicHierarchy.js";
30
- import { resolveTextBounds } from "../../../core/textMetrics.js";
31
+ import {
32
+ resolveTextBounds,
33
+ textBoundsFitCanvas,
34
+ textBoundsIntersect
35
+ } from "../../../core/textMetrics.js";
36
+ import { resolveConcreteGraphicBounds } from
37
+ "../../../grammar/schemas/graphicBounds.js";
31
38
 
32
- const OPTIONS = Object.freeze([
39
+ const OPTIONS = [
33
40
  "scale", "position", "count", "values", "offset", "format", "color",
34
41
  "fontSize", "fontFamily", "fontWeight"
35
- ]);
42
+ ];
36
43
 
37
- const DEFAULTS = Object.freeze({
44
+ const DEFAULTS = {
38
45
  count: 5,
39
46
  color: DEFAULT_COLORS.text,
40
47
  fontSize: 12,
41
48
  fontFamily: DEFAULT_FONT_FAMILY,
42
49
  fontWeight: "normal"
43
- });
50
+ };
51
+
52
+ export function validateAxisTextStyle(config, label) {
53
+ validateNonEmptyString(config.color, `${label} color`);
54
+ validatePositiveFinite(config.fontSize, `${label} fontSize`);
55
+ validateNonEmptyString(config.fontFamily, `${label} fontFamily`);
56
+ if (typeof config.fontWeight !== "string" &&
57
+ !Number.isFinite(config.fontWeight)) {
58
+ throw new TypeError(`${label} fontWeight must be a string or number.`);
59
+ }
60
+ }
44
61
 
45
62
  function validateOptions(args, operation, create) {
46
63
  validateOptionObject(
@@ -55,7 +72,10 @@ function validateOptions(args, operation, create) {
55
72
 
56
73
  function validateConfig(channel, config) {
57
74
  validateAxisPosition(channel, config.position);
58
- if (config.mode === "count" && (!Number.isInteger(config.count) || config.count <= 0)) throw new RangeError("Label count must be a positive integer.");
75
+ if (config.mode === "count") {
76
+ if (!Number.isInteger(config.count) || config.count <= 0) throw new RangeError("Label count must be a positive integer.");
77
+ validateGeneratedItemLimit(config.count, "Label count");
78
+ }
59
79
  if (
60
80
  config.mode === "values" &&
61
81
  (!Array.isArray(config.values) || !config.values.every(value =>
@@ -64,11 +84,11 @@ function validateConfig(channel, config) {
64
84
  (typeof value === "number" && Number.isFinite(value))
65
85
  ))
66
86
  ) throw new TypeError("Label values must be nominal values or finite numbers.");
87
+ if (config.mode === "values") {
88
+ validateGeneratedItemLimit(config.values.length, "Label value count");
89
+ }
67
90
  validateNonNegativeFinite(config.offset, "Label offset");
68
- validatePositiveFinite(config.fontSize, "Label fontSize");
69
- validateNonEmptyString(config.color, "Label color");
70
- validateNonEmptyString(config.fontFamily, "Label fontFamily");
71
- if ((typeof config.fontWeight !== "string" && !Number.isFinite(config.fontWeight))) throw new TypeError("Label fontWeight must be a string or number.");
91
+ validateAxisTextStyle(config, "Label");
72
92
  validateAxisFormat(config.format);
73
93
  }
74
94
 
@@ -89,6 +109,7 @@ function resolve(program, channel, config) {
89
109
  ) || !bounds) throw new Error("Axis labels require a supported resolved scale and Canvas bounds.");
90
110
  if (discrete && config.mode !== "values") throw new Error("Discrete axis labels require explicit or inferred values, not count.");
91
111
  const values = valuesFromTickConfig(program, config);
112
+ validateGeneratedItemLimit(values.length, "Label value count");
92
113
  if (discrete) {
93
114
  const domainValues = new Set(scale.domain);
94
115
  if (!values.every(value => domainValues.has(value))) throw new RangeError("Label values must be inside the scale domain.");
@@ -128,17 +149,29 @@ function resolve(program, channel, config) {
128
149
  y: Array.isArray(resolved.y) ? resolved.y[index] : resolved.y,
129
150
  text: value,
130
151
  fontSize: config.fontSize,
152
+ fontFamily: config.fontFamily,
153
+ fontWeight: config.fontWeight,
131
154
  textAlign: resolved.textAlign,
132
155
  textBaseline: resolved.textBaseline
133
156
  }));
134
- const fits = config.position === "top"
135
- ? labelBounds.every(item => item.top >= 0)
136
- : config.position === "right"
137
- ? labelBounds.every(item => item.right <= canvas?.width)
138
- : true;
139
- if (!canvas || !fits) {
157
+ const orderedBounds = [...labelBounds].sort((left, right) => channel === "x"
158
+ ? left.left - right.left
159
+ : left.top - right.top);
160
+ if (!canvas || !labelBounds.every(item => textBoundsFitCanvas(item, canvas))) {
140
161
  throw new Error(`The ${channel}-axis labels do not fit the Canvas margin.`);
141
162
  }
163
+ if (orderedBounds.some((item, index) => index > 0 &&
164
+ textBoundsIntersect(orderedBounds[index - 1], item))) {
165
+ throw new Error(`The ${channel}-axis labels overlap each other.`);
166
+ }
167
+ const title = program.graphicSpec.objects[`${channel}AxisTitle`]
168
+ ? resolveConcreteGraphicBounds(program.graphicSpec, `${channel}AxisTitle`)
169
+ : undefined;
170
+ if (title &&
171
+ program.guideConfigs.axis?.[channel]?.title?.inferredOffset !== true &&
172
+ labelBounds.some(item => textBoundsIntersect(item, title))) {
173
+ throw new Error(`The ${channel}-axis labels overlap the axis title.`);
174
+ }
142
175
  return resolved;
143
176
  }
144
177
 
@@ -170,16 +203,25 @@ function makeEdit(channel) {
170
203
  validateConfig(channel, config);
171
204
  assertTickCompatibility(this.guideConfigs.axis?.[channel]?.ticks, config, op);
172
205
  const resolved = resolve(this, channel, config);
173
- let next = this._withGuideConfig(channel, "labels", config)
174
- .editGraphics({ target: id, property: "length", value: resolved.values.length });
175
- for (const property of ["x", "y", "text"]) next = next.editGraphics({ target: id, property, value: resolved[property] });
176
- return next
177
- .editGraphics({ target: id, property: "fill", value: config.color })
178
- .editGraphics({ target: id, property: "fontSize", value: config.fontSize })
179
- .editGraphics({ target: id, property: "fontFamily", value: config.fontFamily })
180
- .editGraphics({ target: id, property: "fontWeight", value: config.fontWeight })
181
- .editGraphics({ target: id, property: "textAlign", value: resolved.textAlign })
182
- .editGraphics({ target: id, property: "textBaseline", value: resolved.textBaseline });
206
+ let next = this._withGuideConfig(channel, "labels", config);
207
+ for (const [property, value] of Object.entries({
208
+ length: resolved.values.length,
209
+ x: resolved.x,
210
+ y: resolved.y,
211
+ text: resolved.text,
212
+ fill: config.color,
213
+ fontSize: config.fontSize,
214
+ fontFamily: config.fontFamily,
215
+ fontWeight: config.fontWeight,
216
+ textAlign: resolved.textAlign,
217
+ textBaseline: resolved.textBaseline
218
+ })) next = next.editGraphics({ target: id, property, value });
219
+ const titleConfig = next.guideConfigs.axis?.[channel]?.title;
220
+ if (titleConfig?.inferredOffset === true) {
221
+ const editTitle = channel === "x" ? "editXAxisTitle" : "editYAxisTitle";
222
+ next = next[editTitle]();
223
+ }
224
+ return next;
183
225
  });
184
226
  }
185
227
 
@@ -24,6 +24,20 @@ function validateStyle({ color, lineWidth }) {
24
24
  validateNonNegativeFinite(lineWidth, "Axis lineWidth");
25
25
  }
26
26
 
27
+ function resolveLineRange(resolvedScale, bounds, channel) {
28
+ const range = resolvedScale?.range;
29
+ if (
30
+ resolvedScale?.type !== "time" ||
31
+ !Number.isFinite(resolvedScale.bandwidth)
32
+ ) return range;
33
+ const plotRange = channel === "x"
34
+ ? [bounds.x, bounds.x + bounds.width]
35
+ : [bounds.y + bounds.height, bounds.y];
36
+ return Math.sign(range[1] - range[0]) === Math.sign(plotRange[1] - plotRange[0])
37
+ ? plotRange
38
+ : plotRange.reverse();
39
+ }
40
+
27
41
  function resolveGeometry(program, channel, scaleId, position) {
28
42
  const hasConsumer = program.semanticSpec.layers.some(
29
43
  layer => layer.encoding?.[channel]?.scale === scaleId
@@ -35,7 +49,8 @@ function resolveGeometry(program, channel, scaleId, position) {
35
49
  );
36
50
  }
37
51
 
38
- const range = program.resolvedScales[scaleId]?.range;
52
+ const resolvedScale = program.resolvedScales[scaleId];
53
+ const range = resolvedScale?.range;
39
54
  const bounds = resolveGraphicBounds(program);
40
55
 
41
56
  if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite)) {
@@ -49,7 +64,12 @@ function resolveGeometry(program, channel, scaleId, position) {
49
64
  throw new Error("Axis line requires graphical Canvas bounds.");
50
65
  }
51
66
 
52
- return resolveAxisLineGeometry(bounds, channel, position, range);
67
+ return resolveAxisLineGeometry(
68
+ bounds,
69
+ channel,
70
+ position,
71
+ resolveLineRange(resolvedScale, bounds, channel)
72
+ );
53
73
  }
54
74
 
55
75
  function axisIds(channel) {
@@ -1,3 +1,5 @@
1
+ import { formatVisibleText } from "../../../core/textMetrics.js";
2
+
1
3
  const AXIS_POSITIONS = Object.freeze({
2
4
  x: Object.freeze(["bottom", "top"]),
3
5
  y: Object.freeze(["left", "right"])
@@ -6,7 +8,24 @@ const AXIS_POSITIONS = Object.freeze({
6
8
  const NUMERIC_FORMATS = new Set([
7
9
  ".0f", ".1f", ".2f", ".0%", ".1%", ".2e"
8
10
  ]);
9
- const TIME_FORMATS = new Set(["%Y", "%Y-%m", "%Y-%m-%d"]);
11
+ const TIME_DIRECTIVES = new Set(["Y", "m", "d", "b", "%"]);
12
+ const MONTH_NAMES = Object.freeze([
13
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
14
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
15
+ ]);
16
+
17
+ function isTimeFormat(format) {
18
+ if (typeof format !== "string") return false;
19
+ let directives = 0;
20
+ for (let index = 0; index < format.length; index += 1) {
21
+ if (format[index] !== "%") continue;
22
+ const directive = format[index + 1];
23
+ if (!TIME_DIRECTIVES.has(directive)) return false;
24
+ if (directive !== "%") directives += 1;
25
+ index += 1;
26
+ }
27
+ return directives > 0;
28
+ }
10
29
 
11
30
  export function defaultAxisPosition(channel) {
12
31
  return channel === "x" ? "bottom" : "left";
@@ -105,7 +124,7 @@ export function validateAxisFormat(format) {
105
124
  return format;
106
125
  }
107
126
  if (typeof format === "string" && (
108
- NUMERIC_FORMATS.has(format) || TIME_FORMATS.has(format)
127
+ NUMERIC_FORMATS.has(format) || isTimeFormat(format)
109
128
  )) {
110
129
  return format;
111
130
  }
@@ -126,24 +145,25 @@ function formatTime(value, format) {
126
145
  const year = pad(date.getUTCFullYear(), 4);
127
146
  const month = pad(date.getUTCMonth() + 1);
128
147
  const day = pad(date.getUTCDate());
129
- if (format === "%Y") return year;
130
- if (format === "%Y-%m") return `${year}-${month}`;
131
- return `${year}-${month}-${day}`;
148
+ const values = { Y: year, m: month, d: day, b: MONTH_NAMES[date.getUTCMonth()] };
149
+ return format.replace(/%([Ymdb%])/g, (_, directive) =>
150
+ directive === "%" ? "%" : values[directive]
151
+ );
132
152
  }
133
153
 
134
154
  export function formatAxisValue(value, scaleType, format, autoFormatter) {
135
155
  validateAxisFormat(format);
136
- if (format === "auto") return autoFormatter(value);
156
+ if (format === "auto") return formatVisibleText(autoFormatter(value));
137
157
  if (["ordinal", "band", "point"].includes(scaleType)) {
138
158
  throw new Error('Discrete axis labels require format "auto".');
139
159
  }
140
160
  if (scaleType === "time") {
141
- if (!TIME_FORMATS.has(format)) {
161
+ if (!isTimeFormat(format)) {
142
162
  throw new Error("Time axis labels require a supported time format string.");
143
163
  }
144
164
  return formatTime(value, format);
145
165
  }
146
- if (TIME_FORMATS.has(format)) {
166
+ if (isTimeFormat(format)) {
147
167
  throw new Error("Quantitative axis labels cannot use a time format string.");
148
168
  }
149
169
  if (typeof format === "object") return value.toFixed(format.decimals);
@@ -1,6 +1,7 @@
1
1
  import { action } from "../../../core/action.js";
2
2
  import { validateUserId } from "../../../core/identifiers.js";
3
3
  import {
4
+ validateGeneratedItemLimit,
4
5
  validateNonEmptyString,
5
6
  validateNonNegativeFinite,
6
7
  validateOptionObject
@@ -11,6 +12,7 @@ import {
11
12
  mapContinuousScaleValues,
12
13
  mapOrdinalPositionValues
13
14
  } from "../../../grammar/scales/index.js";
15
+ import { numericExtent } from "../../../grammar/numeric.js";
14
16
  import {
15
17
  DEFAULT_TICK_COUNT,
16
18
  inferHistogramBoundaries,
@@ -40,11 +42,17 @@ function validateOptions(args, operation, create) {
40
42
  operation
41
43
  );
42
44
  if (Object.hasOwn(args, "count") && Object.hasOwn(args, "values")) throw new Error(`${operation} cannot use count and values together.`);
45
+ if (Array.isArray(args.values)) {
46
+ validateGeneratedItemLimit(args.values.length, "Tick value count");
47
+ }
43
48
  }
44
49
 
45
50
  function validateConfig(channel, config) {
46
51
  validateAxisPosition(channel, config.position);
47
- if (config.mode === "count" && (!Number.isInteger(config.count) || config.count <= 0)) throw new RangeError("Tick count must be a positive integer.");
52
+ if (config.mode === "count") {
53
+ if (!Number.isInteger(config.count) || config.count <= 0) throw new RangeError("Tick count must be a positive integer.");
54
+ validateGeneratedItemLimit(config.count, "Tick count");
55
+ }
48
56
  if (
49
57
  config.mode === "values" &&
50
58
  (!Array.isArray(config.values) || !config.values.every(value =>
@@ -69,6 +77,7 @@ function geometry(program, channel, config) {
69
77
  if (discrete && config.mode !== "values") throw new Error("Discrete axis ticks require explicit or inferred values, not count.");
70
78
  const domain = scale.domain;
71
79
  const values = valuesFromTickConfig(program, config);
80
+ validateGeneratedItemLimit(values.length, "Tick value count");
72
81
  if (discrete) {
73
82
  const domainValues = new Set(domain);
74
83
  if (!values.every(value => domainValues.has(value))) throw new RangeError("Tick values must be inside the scale domain.");
@@ -90,16 +99,25 @@ function geometry(program, channel, config) {
90
99
  })
91
100
  };
92
101
  const canvas = findCanvasGraphic(program)?.properties;
93
- const coordinates = channel === "x"
94
- ? [...resolved.x1, ...resolved.x2, resolved.y1, resolved.y2]
95
- : [resolved.x1, resolved.x2, ...resolved.y1, ...resolved.y2];
96
- const newEdgeDoesNotFit =
97
- (config.position === "top" && resolved.y2 < 0) ||
98
- (config.position === "right" && resolved.x2 > canvas?.width);
102
+ const xCoordinates = channel === "x"
103
+ ? [...resolved.x1, ...resolved.x2]
104
+ : [resolved.x1, resolved.x2];
105
+ const yCoordinates = channel === "x"
106
+ ? [resolved.y1, resolved.y2]
107
+ : [...resolved.y1, ...resolved.y2];
108
+ const coordinates = [...xCoordinates, ...yCoordinates];
109
+ const [left, right] = numericExtent(xCoordinates);
110
+ const [top, bottom] = numericExtent(yCoordinates);
111
+ const strokeExtent = config.lineWidth / 2;
112
+ const epsilon = 1e-9;
113
+ const fits = canvas &&
114
+ left - strokeExtent >= -epsilon &&
115
+ right + strokeExtent <= canvas.width + epsilon &&
116
+ top - strokeExtent >= -epsilon &&
117
+ bottom + strokeExtent <= canvas.height + epsilon;
99
118
  if (
100
- !canvas ||
101
119
  coordinates.some(value => !Number.isFinite(value)) ||
102
- newEdgeDoesNotFit
120
+ !fits
103
121
  ) {
104
122
  throw new Error(`The ${channel}-axis ticks do not fit the Canvas margin.`);
105
123
  }
@@ -3,8 +3,7 @@ import { validateUserId } from "../../../core/identifiers.js";
3
3
  import {
4
4
  validateKeys,
5
5
  validateNonEmptyString,
6
- validateNonNegativeFinite,
7
- validatePositiveFinite
6
+ validateNonNegativeFinite
8
7
  } from "../../../core/validation.js";
9
8
  import { resolveGraphicBounds } from "../../../layout/canvas.js";
10
9
  import {
@@ -16,6 +15,7 @@ import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
16
15
  "../../../theme/defaults.js";
17
16
  import { findDataset } from "../../../selectors/datasets.js";
18
17
  import { formatAggregateTitle } from "../../../grammar/aggregate.js";
18
+ import { finiteMidpoint } from "../../../grammar/numeric.js";
19
19
  import {
20
20
  defaultAxisPosition,
21
21
  defaultAxisTitleRotation,
@@ -24,19 +24,67 @@ import {
24
24
  } from "./policy.js";
25
25
  import { findCanvasGraphic, resolvePlotGraphicPlacement } from
26
26
  "../../../materialization/graphicHierarchy.js";
27
- import { resolveTextBounds } from "../../../core/textMetrics.js";
27
+ import {
28
+ resolveTextBounds,
29
+ textBoundsFitCanvas,
30
+ textBoundsIntersect
31
+ } from "../../../core/textMetrics.js";
32
+ import { resolveConcreteGraphicBounds } from
33
+ "../../../grammar/schemas/graphicBounds.js";
34
+ import { validateAxisTextStyle } from "./labels.js";
35
+
36
+ function titleBounds(geometry, config, text) {
37
+ return resolveTextBounds({
38
+ ...geometry,
39
+ text,
40
+ fontSize: config.fontSize,
41
+ fontFamily: config.fontFamily,
42
+ fontWeight: config.fontWeight,
43
+ textAlign: "center",
44
+ textBaseline: "middle",
45
+ rotation: config.rotation
46
+ });
47
+ }
48
+
49
+ function inferredTitleOffset(program, channel, config) {
50
+ const plot = resolveGraphicBounds(program);
51
+ const canvas = findCanvasGraphic(program)?.properties;
52
+ const labels = program.graphicSpec.objects[`${channel}AxisLabels`]
53
+ ? resolveConcreteGraphicBounds(program.graphicSpec, `${channel}AxisLabels`)
54
+ : undefined;
55
+ const preferred = channel === "x" ? 42 : 52;
56
+ if (!plot || !canvas || !labels) return preferred;
57
+ const horizontal = channel === "x";
58
+ const positive = config.position === (horizontal ? "bottom" : "right");
59
+ const near = horizontal ? "top" : "left";
60
+ const far = horizontal ? "bottom" : "right";
61
+ const bounds = titleBounds(resolveAxisTitleGeometry({
62
+ bounds: plot,
63
+ channel,
64
+ position: config.position,
65
+ along: 0,
66
+ offset: preferred
67
+ }), config, program.semanticSpec.guides.axis?.[channel]?.title ?? "");
68
+ const needed = positive
69
+ ? labels[far] + 4 - bounds[near]
70
+ : bounds[far] - labels[near] + 4;
71
+ const available = positive
72
+ ? canvas[horizontal ? "height" : "width"] - bounds[far]
73
+ : bounds[near];
74
+ return preferred + Math.min(available, Math.max(0, needed));
75
+ }
28
76
 
29
- const CREATE_OPTIONS = Object.freeze([
77
+ const CREATE_OPTIONS = [
30
78
  "text", "scale", "position", "at", "offset", "rotation", "color",
31
79
  "fontSize", "fontFamily", "fontWeight"
32
- ]);
80
+ ];
33
81
  const EDIT_OPTIONS = CREATE_OPTIONS.filter(key => key !== "scale");
34
- const DEFAULTS = Object.freeze({
82
+ const DEFAULTS = {
35
83
  color: DEFAULT_COLORS.text,
36
84
  fontSize: 13,
37
85
  fontFamily: DEFAULT_FONT_FAMILY,
38
86
  fontWeight: 600
39
- });
87
+ };
40
88
 
41
89
  function validateText(text) {
42
90
  return validateNonEmptyString(text, "Axis title text");
@@ -47,10 +95,7 @@ function validateConfig(channel, config) {
47
95
  if (!["start", "center", "end"].includes(config.at) && !Number.isFinite(config.at)) throw new TypeError("Axis title at must be start, center, end, or a finite number.");
48
96
  validateNonNegativeFinite(config.offset, "Axis title offset");
49
97
  if (!Number.isFinite(config.rotation)) throw new TypeError("Axis title rotation must be finite radians.");
50
- validateNonEmptyString(config.color, "Axis title color");
51
- validatePositiveFinite(config.fontSize, "Axis title fontSize");
52
- validateNonEmptyString(config.fontFamily, "Axis title fontFamily");
53
- if (typeof config.fontWeight !== "string" && !Number.isFinite(config.fontWeight)) throw new TypeError("Axis title fontWeight must be a string or number.");
98
+ validateAxisTextStyle(config, "Axis title");
54
99
  }
55
100
 
56
101
  export function inferAxisTitleText(program, channel, scaleId) {
@@ -64,34 +109,24 @@ export function inferAxisTitleText(program, channel, scaleId) {
64
109
  encoding.field.length
65
110
  ) {
66
111
  const dataset = findDataset(program, layer.data);
67
- const density = dataset?.transform?.length === 1 &&
68
- dataset.transform[0].type === "density"
112
+ const transform = dataset?.transform?.length === 1
69
113
  ? dataset.transform[0]
70
114
  : undefined;
71
- const densityTitle = density === undefined
72
- ? undefined
73
- : encoding.field === density.as?.[0]
74
- ? density.field
75
- : encoding.field === density.as?.[1]
115
+ let derivedTitle;
116
+ if (transform?.type === "density") {
117
+ derivedTitle = encoding.field === transform.as?.[0]
118
+ ? transform.field
119
+ : encoding.field === transform.as?.[1]
76
120
  ? "Density"
77
121
  : undefined;
78
- const interval = dataset?.transform?.length === 1 &&
79
- dataset.transform[0].type === "interval"
80
- ? dataset.transform[0]
81
- : undefined;
82
- const intervalTitle = interval !== undefined &&
83
- Object.values(interval.as).includes(encoding.field)
84
- ? `${interval.center}(${interval.field})`
85
- : undefined;
86
- const box = dataset?.transform?.length === 1 &&
87
- dataset.transform[0].type === "boxSummary"
88
- ? dataset.transform[0]
89
- : undefined;
90
- const boxTitle = box !== undefined &&
91
- Object.values(box.as).includes(encoding.field)
92
- ? box.field
93
- : undefined;
94
- const title = encoding.title ?? densityTitle ?? intervalTitle ?? boxTitle ?? (encoding.aggregate === undefined
122
+ } else if (transform?.type === "interval" &&
123
+ Object.values(transform.as).includes(encoding.field)) {
124
+ derivedTitle = `${transform.center}(${transform.field})`;
125
+ } else if (transform?.type === "boxSummary" &&
126
+ Object.values(transform.as).includes(encoding.field)) {
127
+ derivedTitle = transform.field;
128
+ }
129
+ const title = encoding.title ?? derivedTitle ?? (encoding.aggregate === undefined
95
130
  ? encoding.field
96
131
  : formatAggregateTitle(encoding.aggregate, encoding.field));
97
132
  titles.add(title);
@@ -112,10 +147,11 @@ function resolveGeometry(program, channel, config) {
112
147
  !isTransformedScaleType(scale?.type)
113
148
  ) || !bounds) throw new Error("Axis title requires a supported resolved scale and Canvas bounds.");
114
149
  let along;
115
- if (config.at === "start") along = scale.range[0];
116
- else if (config.at === "center") along = (scale.range[0] + scale.range[1]) / 2;
117
- else if (config.at === "end") along = scale.range[1];
118
- else {
150
+ if (config.at === "center") {
151
+ along = finiteMidpoint(scale.range[0], scale.range[1]);
152
+ } else if (config.at === "start" || config.at === "end") {
153
+ along = scale.range[config.at === "start" ? 0 : 1];
154
+ } else {
119
155
  if (discrete) {
120
156
  if (!scale.domain.includes(config.at)) throw new RangeError("Axis title at value must be inside the scale domain.");
121
157
  along = mapOrdinalPositionValues([config.at], scale)[0];
@@ -132,24 +168,21 @@ function resolveGeometry(program, channel, config) {
132
168
  along,
133
169
  offset: config.offset
134
170
  });
135
- const text = program.semanticSpec.guides.axis?.[channel]?.title ?? "";
136
- const titleBounds = resolveTextBounds({
137
- ...geometry,
138
- text,
139
- fontSize: config.fontSize,
140
- textAlign: "center",
141
- textBaseline: "middle",
142
- rotation: config.rotation
143
- });
171
+ const resolvedBounds = titleBounds(
172
+ geometry,
173
+ config,
174
+ program.semanticSpec.guides.axis?.[channel]?.title ?? ""
175
+ );
144
176
  const canvas = findCanvasGraphic(program)?.properties;
145
- const newEdgeFits = config.position === "top"
146
- ? titleBounds.top >= 0
147
- : config.position === "right"
148
- ? titleBounds.right <= canvas?.width
149
- : true;
150
- if (!canvas || !newEdgeFits) {
177
+ if (!canvas || !textBoundsFitCanvas(resolvedBounds, canvas)) {
151
178
  throw new Error(`The ${channel}-axis title does not fit the Canvas margin.`);
152
179
  }
180
+ const labelsBounds = program.graphicSpec.objects[`${channel}AxisLabels`]
181
+ ? resolveConcreteGraphicBounds(program.graphicSpec, `${channel}AxisLabels`)
182
+ : undefined;
183
+ if (labelsBounds && textBoundsIntersect(resolvedBounds, labelsBounds)) {
184
+ throw new Error(`The ${channel}-axis title overlaps the axis labels.`);
185
+ }
153
186
  return geometry;
154
187
  }
155
188
 
@@ -180,6 +213,9 @@ function makeEdit(channel) {
180
213
  ? defaultAxisTitleRotation(channel, position)
181
214
  : appearance.rotation ?? previous.rotation,
182
215
  inferredRotation,
216
+ inferredOffset: Object.hasOwn(args, "offset")
217
+ ? false
218
+ : previous.inferredOffset === true,
183
219
  ...(explicitText ? { inferredText: false } : {})
184
220
  };
185
221
  validateConfig(channel, config);
@@ -196,6 +232,9 @@ function makeEdit(channel) {
196
232
  });
197
233
  }
198
234
  const resolvedText = validateText(next.semanticSpec.guides.axis?.[channel]?.title);
235
+ if (config.inferredOffset) {
236
+ config.offset = inferredTitleOffset(next, channel, config);
237
+ }
199
238
  const geometry = resolveGeometry(next, channel, config);
200
239
  next = next._withGuideConfig(channel, "title", config);
201
240
  const properties = {
@@ -216,16 +255,24 @@ function makeCreate(channel) {
216
255
  const operation = names(channel);
217
256
  return action({ op: operation.create, description: `Create the ${channel}-axis title.` }, function (args = {}) {
218
257
  validateKeys(args, CREATE_OPTIONS, operation.create);
219
- const scale = validateUserId(args.scale ?? channel, "Scale id");
258
+ const {
259
+ text: requestedText,
260
+ scale: requestedScale,
261
+ position: requestedPosition,
262
+ rotation: requestedRotation,
263
+ ...appearance
264
+ } = args;
265
+ const scale = validateUserId(requestedScale ?? channel, "Scale id");
220
266
  const guideScale = this.semanticSpec.guides.axis?.[channel]?.scale;
221
267
  if (guideScale && guideScale !== scale) throw new Error(`${operation.create} conflicts with the existing axis scale.`);
222
268
  if (this.graphicSpec.objects[operation.graphic]) throw new Error(`${operation.create} requires a missing axis title.`);
223
269
  const inferredText = !Object.hasOwn(args, "text");
224
270
  const text = validateText(
225
- args.text ?? inferAxisTitleText(this, channel, scale)
271
+ requestedText ?? inferAxisTitleText(this, channel, scale)
226
272
  );
227
- const position = args.position ?? defaultAxisPosition(channel);
273
+ const position = requestedPosition ?? defaultAxisPosition(channel);
228
274
  const inferredRotation = !Object.hasOwn(args, "rotation");
275
+ const inferredOffset = !Object.hasOwn(args, "offset");
229
276
  const config = {
230
277
  scale,
231
278
  inferredText,
@@ -234,21 +281,22 @@ function makeCreate(channel) {
234
281
  offset: channel === "x" ? 42 : 52,
235
282
  rotation: inferredRotation
236
283
  ? defaultAxisTitleRotation(channel, position)
237
- : args.rotation,
284
+ : requestedRotation,
238
285
  inferredRotation,
286
+ inferredOffset,
239
287
  color: DEFAULTS.color,
240
288
  fontSize: DEFAULTS.fontSize,
241
289
  fontFamily: DEFAULTS.fontFamily,
242
290
  fontWeight: DEFAULTS.fontWeight,
243
- ...Object.fromEntries(Object.entries(args).filter(
244
- ([key]) => !["text", "scale", "position", "rotation"].includes(key)
245
- ))
291
+ ...appearance
246
292
  };
247
- validateConfig(channel, config);
248
- resolveGeometry(this, channel, config);
249
- return this
293
+ let next = this
250
294
  .editSemantic({ property: `guide.axis.${channel}.scale`, value: scale })
251
- .editSemantic({ property: `guide.axis.${channel}.title`, value: text })
295
+ .editSemantic({ property: `guide.axis.${channel}.title`, value: text });
296
+ if (inferredOffset) config.offset = inferredTitleOffset(next, channel, config);
297
+ validateConfig(channel, config);
298
+ resolveGeometry(next, channel, config);
299
+ return next
252
300
  .createGraphics({
253
301
  id: operation.graphic,
254
302
  type: "text",