ggaction 0.0.1

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 +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +108 -0
  4. package/package.json +84 -0
  5. package/src/AGENTS.md +119 -0
  6. package/src/ChartProgram.js +10 -0
  7. package/src/actions/boxPlots/components.js +88 -0
  8. package/src/actions/boxPlots/create.js +84 -0
  9. package/src/actions/boxPlots/index.js +10 -0
  10. package/src/actions/boxPlots/materialize.js +183 -0
  11. package/src/actions/boxPlots/options.js +147 -0
  12. package/src/actions/boxPlots/resolve.js +38 -0
  13. package/src/actions/canvas/actions.js +144 -0
  14. package/src/actions/canvas/index.js +6 -0
  15. package/src/actions/coordinates/actions.js +85 -0
  16. package/src/actions/coordinates/index.js +5 -0
  17. package/src/actions/data/box.js +40 -0
  18. package/src/actions/data/create.js +33 -0
  19. package/src/actions/data/density.js +62 -0
  20. package/src/actions/data/derived.js +52 -0
  21. package/src/actions/data/filter.js +168 -0
  22. package/src/actions/data/index.js +32 -0
  23. package/src/actions/data/interval.js +69 -0
  24. package/src/actions/data/regression.js +71 -0
  25. package/src/actions/data/shared.js +25 -0
  26. package/src/actions/encodings/appearance.js +243 -0
  27. package/src/actions/encodings/barWidth.js +60 -0
  28. package/src/actions/encodings/color.js +329 -0
  29. package/src/actions/encodings/density.js +225 -0
  30. package/src/actions/encodings/histogram.js +50 -0
  31. package/src/actions/encodings/index.js +23 -0
  32. package/src/actions/encodings/offset.js +104 -0
  33. package/src/actions/encodings/position/policies/area.js +24 -0
  34. package/src/actions/encodings/position/policies/bar.js +110 -0
  35. package/src/actions/encodings/position/policies/common.js +17 -0
  36. package/src/actions/encodings/position/policies/index.js +25 -0
  37. package/src/actions/encodings/position/policies/line.js +74 -0
  38. package/src/actions/encodings/position/policies/point.js +19 -0
  39. package/src/actions/encodings/position/policies/rule.js +17 -0
  40. package/src/actions/encodings/position/resolve.js +170 -0
  41. package/src/actions/encodings/position.js +176 -0
  42. package/src/actions/encodings/ranged.js +276 -0
  43. package/src/actions/encodings/ruleAppearance.js +47 -0
  44. package/src/actions/encodings/shared.js +164 -0
  45. package/src/actions/encodings/strokeDash.js +145 -0
  46. package/src/actions/errorBands/components.js +81 -0
  47. package/src/actions/errorBands/create.js +159 -0
  48. package/src/actions/errorBands/index.js +7 -0
  49. package/src/actions/errorBands/resolve.js +28 -0
  50. package/src/actions/errorBars/components.js +62 -0
  51. package/src/actions/errorBars/create.js +144 -0
  52. package/src/actions/errorBars/index.js +7 -0
  53. package/src/actions/errorBars/resolve.js +25 -0
  54. package/src/actions/guides/axes/axes.js +204 -0
  55. package/src/actions/guides/axes/axis.js +131 -0
  56. package/src/actions/guides/axes/index.js +17 -0
  57. package/src/actions/guides/axes/labels.js +231 -0
  58. package/src/actions/guides/axes/lines.js +149 -0
  59. package/src/actions/guides/axes/policy.js +155 -0
  60. package/src/actions/guides/axes/tickGroups.js +149 -0
  61. package/src/actions/guides/axes/ticks.js +179 -0
  62. package/src/actions/guides/axes/titles.js +264 -0
  63. package/src/actions/guides/grids/grid.js +228 -0
  64. package/src/actions/guides/grids/index.js +1 -0
  65. package/src/actions/guides/grids/resolve.js +258 -0
  66. package/src/actions/guides/guides.js +138 -0
  67. package/src/actions/guides/index.js +11 -0
  68. package/src/actions/guides/legends/categorical/actions.js +329 -0
  69. package/src/actions/guides/legends/categorical/components.js +140 -0
  70. package/src/actions/guides/legends/categorical/index.js +49 -0
  71. package/src/actions/guides/legends/categorical/layout.js +445 -0
  72. package/src/actions/guides/legends/categorical/options.js +188 -0
  73. package/src/actions/guides/legends/categorical/recipes.js +120 -0
  74. package/src/actions/guides/legends/categorical/resolve.js +131 -0
  75. package/src/actions/guides/legends/categorical/symbols.js +280 -0
  76. package/src/actions/guides/legends/categorical/validation.js +27 -0
  77. package/src/actions/guides/legends/continuous/common.js +273 -0
  78. package/src/actions/guides/legends/continuous/gradient.js +363 -0
  79. package/src/actions/guides/legends/continuous/index.js +25 -0
  80. package/src/actions/guides/legends/continuous/interval.js +234 -0
  81. package/src/actions/guides/legends/continuous/opacity.js +382 -0
  82. package/src/actions/guides/legends/edit.js +314 -0
  83. package/src/actions/guides/legends/index.js +9 -0
  84. package/src/actions/guides/legends/size.js +220 -0
  85. package/src/actions/guides/tickValues.js +84 -0
  86. package/src/actions/index.js +31 -0
  87. package/src/actions/intervals/resolve.js +352 -0
  88. package/src/actions/marks/area.js +369 -0
  89. package/src/actions/marks/bar/create.js +46 -0
  90. package/src/actions/marks/bar/edit.js +90 -0
  91. package/src/actions/marks/bar/index.js +9 -0
  92. package/src/actions/marks/bar/materialize.js +130 -0
  93. package/src/actions/marks/index.js +13 -0
  94. package/src/actions/marks/line.js +248 -0
  95. package/src/actions/marks/point.js +420 -0
  96. package/src/actions/marks/rule.js +287 -0
  97. package/src/actions/marks/shared.js +112 -0
  98. package/src/actions/primitives/createGraphics.js +163 -0
  99. package/src/actions/primitives/editGraphics.js +208 -0
  100. package/src/actions/primitives/index.js +9 -0
  101. package/src/actions/primitives/semantic.js +168 -0
  102. package/src/actions/primitives/semanticValue.js +182 -0
  103. package/src/actions/regression/components.js +216 -0
  104. package/src/actions/regression/create.js +102 -0
  105. package/src/actions/regression/index.js +23 -0
  106. package/src/actions/regression/resolve.js +57 -0
  107. package/src/actions/scales/consumers.js +283 -0
  108. package/src/actions/scales/create.js +148 -0
  109. package/src/actions/scales/definitions.js +319 -0
  110. package/src/actions/scales/edit.js +290 -0
  111. package/src/actions/scales/index.js +11 -0
  112. package/src/actions/scales/materialize.js +466 -0
  113. package/src/actions/scales/quantitativeColor.js +45 -0
  114. package/src/actions/selection/actions.js +384 -0
  115. package/src/actions/selection/index.js +23 -0
  116. package/src/actions/titles/actions.js +235 -0
  117. package/src/actions/titles/index.js +19 -0
  118. package/src/actions/titles/resolve.js +321 -0
  119. package/src/core/ChartProgram.js +316 -0
  120. package/src/core/action.js +120 -0
  121. package/src/core/identifiers.js +28 -0
  122. package/src/core/immutable.js +94 -0
  123. package/src/core/specs.js +29 -0
  124. package/src/core/textMetrics.js +23 -0
  125. package/src/core/validation.js +54 -0
  126. package/src/core/vocabulary.js +58 -0
  127. package/src/extension.js +2 -0
  128. package/src/grammar/aggregate.js +259 -0
  129. package/src/grammar/areaSeries.js +217 -0
  130. package/src/grammar/bars/aggregate.js +98 -0
  131. package/src/grammar/bars/geometry.js +54 -0
  132. package/src/grammar/bars/policy.js +90 -0
  133. package/src/grammar/boxPlot.js +140 -0
  134. package/src/grammar/coordinates.js +25 -0
  135. package/src/grammar/curveCommands.js +356 -0
  136. package/src/grammar/density.js +296 -0
  137. package/src/grammar/filter.js +169 -0
  138. package/src/grammar/histogram.js +332 -0
  139. package/src/grammar/interval.js +343 -0
  140. package/src/grammar/lineSeries.js +197 -0
  141. package/src/grammar/markFilter.js +46 -0
  142. package/src/grammar/markSelection.js +326 -0
  143. package/src/grammar/palettes.js +208 -0
  144. package/src/grammar/pathCommands.js +86 -0
  145. package/src/grammar/pointShapes.js +164 -0
  146. package/src/grammar/positionCompatibility.js +32 -0
  147. package/src/grammar/regression.js +578 -0
  148. package/src/grammar/ruleAppearance.js +12 -0
  149. package/src/grammar/rules.js +90 -0
  150. package/src/grammar/scales/appearance.js +185 -0
  151. package/src/grammar/scales/color.js +349 -0
  152. package/src/grammar/scales/continuous.js +111 -0
  153. package/src/grammar/scales/definition.js +153 -0
  154. package/src/grammar/scales/discretized.js +159 -0
  155. package/src/grammar/scales/fields.js +133 -0
  156. package/src/grammar/scales/index.js +12 -0
  157. package/src/grammar/scales/mapping.js +27 -0
  158. package/src/grammar/scales/ordinal.js +221 -0
  159. package/src/grammar/scales/policies.js +50 -0
  160. package/src/grammar/scales/temporal.js +53 -0
  161. package/src/grammar/scales/transformed.js +236 -0
  162. package/src/grammar/scales/types.js +102 -0
  163. package/src/grammar/scales/validation.js +66 -0
  164. package/src/grammar/scales.js +1 -0
  165. package/src/grammar/schemas/concreteGraphic.js +73 -0
  166. package/src/grammar/schemas/graphic.js +131 -0
  167. package/src/grammar/schemas/graphicBounds.js +146 -0
  168. package/src/grammar/schemas/graphicTree.js +133 -0
  169. package/src/grammar/schemas/mark.js +4 -0
  170. package/src/grammar/schemas/semanticPath.js +174 -0
  171. package/src/grammar/seriesLayout.js +76 -0
  172. package/src/grammar/ticks.js +149 -0
  173. package/src/grammar/transforms.js +37 -0
  174. package/src/index.js +2 -0
  175. package/src/layout/canvas.js +114 -0
  176. package/src/layout/grid.js +17 -0
  177. package/src/layout/legend.js +51 -0
  178. package/src/layout/text.js +62 -0
  179. package/src/layout/title.js +107 -0
  180. package/src/materialization/bars/aggregate.js +184 -0
  181. package/src/materialization/bars/grouped.js +117 -0
  182. package/src/materialization/bars/histogram.js +163 -0
  183. package/src/materialization/bars/ranged.js +53 -0
  184. package/src/materialization/bars/resolve.js +56 -0
  185. package/src/materialization/dataProvenance.js +15 -0
  186. package/src/materialization/density.js +29 -0
  187. package/src/materialization/dependencies.js +134 -0
  188. package/src/materialization/encodings.js +50 -0
  189. package/src/materialization/graphicHierarchy.js +104 -0
  190. package/src/materialization/legends.js +20 -0
  191. package/src/materialization/marks.js +110 -0
  192. package/src/materialization/planner.js +38 -0
  193. package/src/materialization/selection/filter.js +40 -0
  194. package/src/materialization/selection/items.js +503 -0
  195. package/src/materialization/selection/path.js +37 -0
  196. package/src/materialization/selection/point.js +118 -0
  197. package/src/materialization/selection/policies/area.js +10 -0
  198. package/src/materialization/selection/policies/bar.js +10 -0
  199. package/src/materialization/selection/policies/index.js +40 -0
  200. package/src/materialization/selection/policies/line.js +10 -0
  201. package/src/materialization/selection/policies/point.js +10 -0
  202. package/src/materialization/selection/policies/rule.js +10 -0
  203. package/src/materialization/selection/state.js +67 -0
  204. package/src/materialization/selection/styles.js +183 -0
  205. package/src/renderers/canvas/circle.js +62 -0
  206. package/src/renderers/canvas/index.js +132 -0
  207. package/src/renderers/canvas/line.js +54 -0
  208. package/src/renderers/canvas/path.js +98 -0
  209. package/src/renderers/canvas/rect.js +57 -0
  210. package/src/renderers/canvas/text.js +96 -0
  211. package/src/renderers/canvas/validation.js +23 -0
  212. package/src/renderers/png.js +29 -0
  213. package/src/selectors/coordinates.js +13 -0
  214. package/src/selectors/datasets.js +13 -0
  215. package/src/selectors/index.js +4 -0
  216. package/src/selectors/layers.js +32 -0
  217. package/src/selectors/scales.js +21 -0
  218. package/src/theme/defaults.js +13 -0
  219. package/types/extension.d.ts +14 -0
  220. package/types/index.d.ts +81 -0
  221. package/types/png.d.ts +14 -0
  222. package/types/program.d.ts +1096 -0
@@ -0,0 +1,179 @@
1
+ import { action } from "../../../core/action.js";
2
+ import { validateUserId } from "../../../core/identifiers.js";
3
+ import {
4
+ validateNonEmptyString,
5
+ validateNonNegativeFinite
6
+ } from "../../../core/validation.js";
7
+ import { resolveGraphicBounds } from "../../../layout/canvas.js";
8
+ import {
9
+ isTransformedScaleType,
10
+ mapContinuousScaleValues,
11
+ mapOrdinalPositionValues
12
+ } from "../../../grammar/scales.js";
13
+ import {
14
+ DEFAULT_TICK_COUNT,
15
+ inferHistogramBoundaries,
16
+ valuesFromTickConfig
17
+ } from "../tickValues.js";
18
+ import { DEFAULT_COLORS } from "../../../theme/defaults.js";
19
+ import {
20
+ defaultAxisPosition,
21
+ resolveAxisTickGeometry,
22
+ validateAxisPosition
23
+ } from "./policy.js";
24
+ import { resolvePlotGraphicPlacement } from
25
+ "../../../materialization/graphicHierarchy.js";
26
+
27
+ const OPTIONS = Object.freeze(["scale", "position", "count", "values", "length", "color", "lineWidth"]);
28
+ const DEFAULTS = Object.freeze({
29
+ count: DEFAULT_TICK_COUNT,
30
+ length: 6,
31
+ color: DEFAULT_COLORS.mutedText,
32
+ lineWidth: 1
33
+ });
34
+
35
+ function validateOptions(args, operation, create) {
36
+ for (const key of Object.keys(args)) if (!OPTIONS.includes(key) || (!create && key === "scale")) throw new Error(`Unknown ${operation} option "${key}".`);
37
+ if (Object.hasOwn(args, "count") && Object.hasOwn(args, "values")) throw new Error(`${operation} cannot use count and values together.`);
38
+ }
39
+
40
+ function validateConfig(channel, config) {
41
+ validateAxisPosition(channel, config.position);
42
+ if (config.mode === "count" && (!Number.isInteger(config.count) || config.count <= 0)) throw new RangeError("Tick count must be a positive integer.");
43
+ if (
44
+ config.mode === "values" &&
45
+ (!Array.isArray(config.values) || !config.values.every(value =>
46
+ typeof value === "string" ||
47
+ typeof value === "boolean" ||
48
+ (typeof value === "number" && Number.isFinite(value))
49
+ ))
50
+ ) throw new TypeError("Tick values must be nominal values or finite numbers.");
51
+ validateNonNegativeFinite(config.length, "Tick length");
52
+ validateNonNegativeFinite(config.lineWidth, "Tick lineWidth");
53
+ validateNonEmptyString(config.color, "Tick color");
54
+ }
55
+
56
+ function geometry(program, channel, config) {
57
+ const scale = program.resolvedScales[config.scale];
58
+ const bounds = resolveGraphicBounds(program);
59
+ const discrete = ["ordinal", "band", "point"].includes(scale?.type);
60
+ if ((
61
+ !["linear", "time", "ordinal", "band", "point"].includes(scale?.type) &&
62
+ !isTransformedScaleType(scale?.type)
63
+ ) || !bounds) throw new Error("Axis ticks require a supported resolved scale and Canvas bounds.");
64
+ if (discrete && config.mode !== "values") throw new Error("Discrete axis ticks require explicit or inferred values, not count.");
65
+ const domain = scale.domain;
66
+ const values = valuesFromTickConfig(program, config);
67
+ if (discrete) {
68
+ const domainValues = new Set(domain);
69
+ if (!values.every(value => domainValues.has(value))) throw new RangeError("Tick values must be inside the scale domain.");
70
+ } else {
71
+ const low = Math.min(...domain), high = Math.max(...domain);
72
+ if (!values.every(value => value >= low && value <= high)) throw new RangeError("Tick values must be inside the scale domain.");
73
+ }
74
+ const positions = discrete
75
+ ? mapOrdinalPositionValues(values, scale)
76
+ : mapContinuousScaleValues(values, scale);
77
+ const resolved = {
78
+ values,
79
+ ...resolveAxisTickGeometry({
80
+ bounds,
81
+ channel,
82
+ position: config.position,
83
+ positions,
84
+ length: config.length
85
+ })
86
+ };
87
+ const canvas = program.graphicSpec.objects.canvas?.properties;
88
+ const coordinates = channel === "x"
89
+ ? [...resolved.x1, ...resolved.x2, resolved.y1, resolved.y2]
90
+ : [resolved.x1, resolved.x2, ...resolved.y1, ...resolved.y2];
91
+ const newEdgeDoesNotFit =
92
+ (config.position === "top" && resolved.y2 < 0) ||
93
+ (config.position === "right" && resolved.x2 > canvas?.width);
94
+ if (
95
+ !canvas ||
96
+ coordinates.some(value => !Number.isFinite(value)) ||
97
+ newEdgeDoesNotFit
98
+ ) {
99
+ throw new Error(`The ${channel}-axis ticks do not fit the Canvas margin.`);
100
+ }
101
+ return resolved;
102
+ }
103
+
104
+ function makeEdit(channel) {
105
+ const op = channel === "x" ? "editXAxisTicks" : "editYAxisTicks";
106
+ return action({ op, description: `Edit concrete ${channel}-axis ticks.` }, function (args = {}) {
107
+ validateOptions(args, op, false);
108
+ const id = `${channel}AxisTicks`;
109
+ if (this.graphicSpec.objects[id]?.type !== "line") throw new Error(`${op} requires existing axis ticks.`);
110
+ const previous = this.guideConfigs.axis?.[channel]?.ticks;
111
+ if (!previous) throw new Error(`${op} requires tick configuration.`);
112
+ const explicitMode = Object.hasOwn(args, "values") || Object.hasOwn(args, "count");
113
+ const inferredValues = !explicitMode && previous.inferredValues === true
114
+ ? inferHistogramBoundaries(this, channel, previous.scale) ??
115
+ (["ordinal", "band", "point"].includes(
116
+ this.resolvedScales[previous.scale]?.type
117
+ )
118
+ ? this.resolvedScales[previous.scale].domain
119
+ : undefined)
120
+ : undefined;
121
+ const mode = Object.hasOwn(args, "values") || inferredValues !== undefined
122
+ ? "values"
123
+ : Object.hasOwn(args, "count") ? "count" : previous.mode;
124
+ const config = {
125
+ ...previous,
126
+ ...args,
127
+ ...(inferredValues === undefined ? {} : { values: inferredValues }),
128
+ ...(explicitMode ? { inferredValues: false } : {}),
129
+ mode
130
+ };
131
+ if (mode === "values") delete config.count; else delete config.values;
132
+ validateConfig(channel, config);
133
+ const resolved = geometry(this, channel, config);
134
+ let next = this._withGuideConfig(channel, config).editGraphics({ target: id, property: "length", value: resolved.values.length });
135
+ for (const property of ["x1", "y1", "x2", "y2"]) next = next.editGraphics({ target: id, property, value: resolved[property] });
136
+ return next.editGraphics({ target: id, property: "stroke", value: config.color }).editGraphics({ target: id, property: "strokeWidth", value: config.lineWidth });
137
+ });
138
+ }
139
+
140
+ const editXAxisTicks = makeEdit("x"), editYAxisTicks = makeEdit("y");
141
+
142
+ function makeCreate(channel) {
143
+ const op = channel === "x" ? "createXAxisTicks" : "createYAxisTicks";
144
+ const edit = channel === "x" ? "editXAxisTicks" : "editYAxisTicks";
145
+ return action({ op, description: `Create concrete ${channel}-axis ticks.` }, function (args = {}) {
146
+ validateOptions(args, op, true);
147
+ const scale = validateUserId(args.scale ?? channel, "Scale id");
148
+ const existingGuide = this.semanticSpec.guides.axis?.[channel]?.scale;
149
+ if (existingGuide && existingGuide !== scale) throw new Error(`${op} conflicts with the existing axis scale.`);
150
+ const id = `${channel}AxisTicks`;
151
+ if (this.graphicSpec.objects[id]) throw new Error(`${op} requires missing axis ticks.`);
152
+ const resolvedScale = this.resolvedScales[scale];
153
+ const inferredValues =
154
+ Object.hasOwn(args, "count") || Object.hasOwn(args, "values")
155
+ ? undefined
156
+ : ["ordinal", "band", "point"].includes(resolvedScale?.type)
157
+ ? resolvedScale.domain
158
+ : inferHistogramBoundaries(this, channel, scale);
159
+ const options =
160
+ inferredValues === undefined ? args : { ...args, values: inferredValues };
161
+ const config = { scale, position: defaultAxisPosition(channel), length: DEFAULTS.length, color: DEFAULTS.color, lineWidth: DEFAULTS.lineWidth, ...options, inferredValues: inferredValues !== undefined, mode: Object.hasOwn(options, "values") ? "values" : "count" };
162
+ if (config.mode === "values") delete config.count; else config.count ??= DEFAULTS.count;
163
+ validateConfig(channel, config); geometry(this, channel, config);
164
+ return this.editSemantic({ property: `guide.axis.${channel}.scale`, value: scale })
165
+ .createGraphics({
166
+ id,
167
+ type: "line",
168
+ length: 0,
169
+ ...resolvePlotGraphicPlacement(this)
170
+ })
171
+ ._withGuideConfig(channel, config)[edit]();
172
+ });
173
+ }
174
+
175
+ const createXAxisTicks = makeCreate("x"), createYAxisTicks = makeCreate("y");
176
+ export function registerAxisTickActions(Class) {
177
+ Class.prototype.editXAxisTicks = editXAxisTicks; Class.prototype.editYAxisTicks = editYAxisTicks;
178
+ Class.prototype.createXAxisTicks = createXAxisTicks; Class.prototype.createYAxisTicks = createYAxisTicks;
179
+ }
@@ -0,0 +1,264 @@
1
+ import { action } from "../../../core/action.js";
2
+ import { validateUserId } from "../../../core/identifiers.js";
3
+ import {
4
+ validateKeys,
5
+ validateNonEmptyString,
6
+ validateNonNegativeFinite,
7
+ validatePositiveFinite
8
+ } from "../../../core/validation.js";
9
+ import { resolveGraphicBounds } from "../../../layout/canvas.js";
10
+ import {
11
+ isTransformedScaleType,
12
+ mapContinuousScaleValues,
13
+ mapOrdinalPositionValues
14
+ } from "../../../grammar/scales.js";
15
+ import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
16
+ "../../../theme/defaults.js";
17
+ import { findDataset } from "../../../selectors/datasets.js";
18
+ import { formatAggregateTitle } from "../../../grammar/aggregate.js";
19
+ import {
20
+ defaultAxisPosition,
21
+ defaultAxisTitleRotation,
22
+ resolveAxisTitleGeometry,
23
+ validateAxisPosition
24
+ } from "./policy.js";
25
+ import { resolvePlotGraphicPlacement } from
26
+ "../../../materialization/graphicHierarchy.js";
27
+
28
+ const CREATE_OPTIONS = Object.freeze([
29
+ "text", "scale", "position", "at", "offset", "rotation", "color",
30
+ "fontSize", "fontFamily", "fontWeight"
31
+ ]);
32
+ const EDIT_OPTIONS = CREATE_OPTIONS.filter(key => key !== "scale");
33
+ const DEFAULTS = Object.freeze({
34
+ color: DEFAULT_COLORS.text,
35
+ fontSize: 13,
36
+ fontFamily: DEFAULT_FONT_FAMILY,
37
+ fontWeight: 600
38
+ });
39
+
40
+ function validateText(text) {
41
+ return validateNonEmptyString(text, "Axis title text");
42
+ }
43
+
44
+ function validateConfig(channel, config) {
45
+ validateAxisPosition(channel, config.position);
46
+ 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.");
47
+ validateNonNegativeFinite(config.offset, "Axis title offset");
48
+ if (!Number.isFinite(config.rotation)) throw new TypeError("Axis title rotation must be finite radians.");
49
+ validateNonEmptyString(config.color, "Axis title color");
50
+ validatePositiveFinite(config.fontSize, "Axis title fontSize");
51
+ validateNonEmptyString(config.fontFamily, "Axis title fontFamily");
52
+ if (typeof config.fontWeight !== "string" && !Number.isFinite(config.fontWeight)) throw new TypeError("Axis title fontWeight must be a string or number.");
53
+ }
54
+
55
+ function inferText(program, channel, scaleId) {
56
+ const titles = new Set();
57
+ const primaryTitles = new Set();
58
+ for (const layer of program.semanticSpec.layers) {
59
+ const encoding = layer.encoding?.[channel];
60
+ if (
61
+ encoding?.scale === scaleId &&
62
+ typeof encoding.field === "string" &&
63
+ encoding.field.length
64
+ ) {
65
+ const dataset = findDataset(program, layer.data);
66
+ const density = dataset?.transform?.length === 1 &&
67
+ dataset.transform[0].type === "density"
68
+ ? dataset.transform[0]
69
+ : undefined;
70
+ const densityTitle = density === undefined
71
+ ? undefined
72
+ : encoding.field === density.as?.[0]
73
+ ? density.field
74
+ : encoding.field === density.as?.[1]
75
+ ? "Density"
76
+ : undefined;
77
+ const interval = dataset?.transform?.length === 1 &&
78
+ dataset.transform[0].type === "interval"
79
+ ? dataset.transform[0]
80
+ : undefined;
81
+ const intervalTitle = interval !== undefined &&
82
+ Object.values(interval.as).includes(encoding.field)
83
+ ? `${interval.center}(${interval.field})`
84
+ : undefined;
85
+ const box = dataset?.transform?.length === 1 &&
86
+ dataset.transform[0].type === "boxSummary"
87
+ ? dataset.transform[0]
88
+ : undefined;
89
+ const boxTitle = box !== undefined &&
90
+ Object.values(box.as).includes(encoding.field)
91
+ ? box.field
92
+ : undefined;
93
+ const title = encoding.title ?? densityTitle ?? intervalTitle ?? boxTitle ?? (encoding.aggregate === undefined
94
+ ? encoding.field
95
+ : formatAggregateTitle(encoding.aggregate, encoding.field));
96
+ titles.add(title);
97
+ if (layer.mark?.type === "point") primaryTitles.add(title);
98
+ }
99
+ }
100
+ if (primaryTitles.size === 1) return [...primaryTitles][0];
101
+ if (titles.size !== 1) throw new Error(`Axis title text cannot be inferred for scale "${scaleId}".`);
102
+ return [...titles][0];
103
+ }
104
+
105
+ function resolveGeometry(program, channel, config) {
106
+ const scale = program.resolvedScales[config.scale];
107
+ const bounds = resolveGraphicBounds(program);
108
+ const discrete = ["ordinal", "band", "point"].includes(scale?.type);
109
+ if ((
110
+ !["linear", "time", "ordinal", "band", "point"].includes(scale?.type) &&
111
+ !isTransformedScaleType(scale?.type)
112
+ ) || !bounds) throw new Error("Axis title requires a supported resolved scale and Canvas bounds.");
113
+ let along;
114
+ if (config.at === "start") along = scale.range[0];
115
+ else if (config.at === "center") along = (scale.range[0] + scale.range[1]) / 2;
116
+ else if (config.at === "end") along = scale.range[1];
117
+ else {
118
+ if (discrete) {
119
+ if (!scale.domain.includes(config.at)) throw new RangeError("Axis title at value must be inside the scale domain.");
120
+ along = mapOrdinalPositionValues([config.at], scale)[0];
121
+ } else {
122
+ const low = Math.min(...scale.domain), high = Math.max(...scale.domain);
123
+ if (config.at < low || config.at > high) throw new RangeError("Axis title at value must be inside the scale domain.");
124
+ along = mapContinuousScaleValues([config.at], scale)[0];
125
+ }
126
+ }
127
+ const geometry = resolveAxisTitleGeometry({
128
+ bounds,
129
+ channel,
130
+ position: config.position,
131
+ along,
132
+ offset: config.offset
133
+ });
134
+ const text = program.semanticSpec.guides.axis?.[channel]?.title ?? "";
135
+ const width = [...text].length * config.fontSize * 0.6;
136
+ const height = config.fontSize;
137
+ const cosine = Math.cos(config.rotation);
138
+ const sine = Math.sin(config.rotation);
139
+ const extentX = Math.abs(cosine) * width / 2 + Math.abs(sine) * height / 2;
140
+ const extentY = Math.abs(sine) * width / 2 + Math.abs(cosine) * height / 2;
141
+ const canvas = program.graphicSpec.objects.canvas?.properties;
142
+ const newEdgeFits = config.position === "top"
143
+ ? geometry.y - extentY >= 0
144
+ : config.position === "right"
145
+ ? geometry.x + extentX <= canvas?.width
146
+ : true;
147
+ if (!canvas || !newEdgeFits) {
148
+ throw new Error(`The ${channel}-axis title does not fit the Canvas margin.`);
149
+ }
150
+ return geometry;
151
+ }
152
+
153
+ function names(channel) {
154
+ const prefix = channel === "x" ? "X" : "Y";
155
+ return { create: `create${prefix}AxisTitle`, edit: `edit${prefix}AxisTitle`, graphic: `${channel}AxisTitle` };
156
+ }
157
+
158
+ function makeEdit(channel) {
159
+ const operation = names(channel);
160
+ return action({ op: operation.edit, description: `Edit the ${channel}-axis title.` }, function (args = {}) {
161
+ validateKeys(args, EDIT_OPTIONS, operation.edit);
162
+ if (this.graphicSpec.objects[operation.graphic]?.type !== "text") throw new Error(`${operation.edit} requires an existing axis title.`);
163
+ const previous = this.guideConfigs.axis?.[channel]?.title;
164
+ if (!previous) throw new Error(`${operation.edit} requires title configuration.`);
165
+ const { text, ...appearance } = args;
166
+ const explicitText = Object.hasOwn(args, "text");
167
+ const explicitRotation = Object.hasOwn(args, "rotation");
168
+ const position = appearance.position ?? previous.position;
169
+ const inferredRotation = explicitRotation
170
+ ? false
171
+ : previous.inferredRotation === true;
172
+ const config = {
173
+ ...previous,
174
+ ...appearance,
175
+ position,
176
+ rotation: inferredRotation
177
+ ? defaultAxisTitleRotation(channel, position)
178
+ : appearance.rotation ?? previous.rotation,
179
+ inferredRotation,
180
+ ...(explicitText ? { inferredText: false } : {})
181
+ };
182
+ validateConfig(channel, config);
183
+ let next = this;
184
+ const resolvedTitle = explicitText
185
+ ? validateText(text)
186
+ : config.inferredText === true
187
+ ? inferText(this, channel, config.scale)
188
+ : this.semanticSpec.guides.axis?.[channel]?.title;
189
+ if (resolvedTitle !== this.semanticSpec.guides.axis?.[channel]?.title) {
190
+ next = next.editSemantic({
191
+ property: `guide.axis.${channel}.title`,
192
+ value: validateText(resolvedTitle)
193
+ });
194
+ }
195
+ const resolvedText = validateText(next.semanticSpec.guides.axis?.[channel]?.title);
196
+ const geometry = resolveGeometry(next, channel, config);
197
+ next = next._withGuideConfig(channel, "title", config);
198
+ const properties = {
199
+ x: geometry.x, y: geometry.y, text: resolvedText, fill: config.color,
200
+ fontSize: config.fontSize, fontFamily: config.fontFamily,
201
+ fontWeight: config.fontWeight, textAlign: "center", textBaseline: "middle",
202
+ rotation: config.rotation
203
+ };
204
+ for (const [property, value] of Object.entries(properties)) next = next.editGraphics({ target: operation.graphic, property, value });
205
+ return next;
206
+ });
207
+ }
208
+
209
+ const editXAxisTitle = makeEdit("x");
210
+ const editYAxisTitle = makeEdit("y");
211
+
212
+ function makeCreate(channel) {
213
+ const operation = names(channel);
214
+ return action({ op: operation.create, description: `Create the ${channel}-axis title.` }, function (args = {}) {
215
+ validateKeys(args, CREATE_OPTIONS, operation.create);
216
+ const scale = validateUserId(args.scale ?? channel, "Scale id");
217
+ const guideScale = this.semanticSpec.guides.axis?.[channel]?.scale;
218
+ if (guideScale && guideScale !== scale) throw new Error(`${operation.create} conflicts with the existing axis scale.`);
219
+ if (this.graphicSpec.objects[operation.graphic]) throw new Error(`${operation.create} requires a missing axis title.`);
220
+ const inferredText = !Object.hasOwn(args, "text");
221
+ const text = validateText(args.text ?? inferText(this, channel, scale));
222
+ const position = args.position ?? defaultAxisPosition(channel);
223
+ const inferredRotation = !Object.hasOwn(args, "rotation");
224
+ const config = {
225
+ scale,
226
+ inferredText,
227
+ position,
228
+ at: "center",
229
+ offset: channel === "x" ? 42 : 52,
230
+ rotation: inferredRotation
231
+ ? defaultAxisTitleRotation(channel, position)
232
+ : args.rotation,
233
+ inferredRotation,
234
+ color: DEFAULTS.color,
235
+ fontSize: DEFAULTS.fontSize,
236
+ fontFamily: DEFAULTS.fontFamily,
237
+ fontWeight: DEFAULTS.fontWeight,
238
+ ...Object.fromEntries(Object.entries(args).filter(
239
+ ([key]) => !["text", "scale", "position", "rotation"].includes(key)
240
+ ))
241
+ };
242
+ validateConfig(channel, config);
243
+ resolveGeometry(this, channel, config);
244
+ return this
245
+ .editSemantic({ property: `guide.axis.${channel}.scale`, value: scale })
246
+ .editSemantic({ property: `guide.axis.${channel}.title`, value: text })
247
+ .createGraphics({
248
+ id: operation.graphic,
249
+ type: "text",
250
+ ...resolvePlotGraphicPlacement(this)
251
+ })
252
+ ._withGuideConfig(channel, "title", config)[operation.edit]();
253
+ });
254
+ }
255
+
256
+ const createXAxisTitle = makeCreate("x");
257
+ const createYAxisTitle = makeCreate("y");
258
+
259
+ export function registerAxisTitleActions(Class) {
260
+ Class.prototype.createXAxisTitle = createXAxisTitle;
261
+ Class.prototype.createYAxisTitle = createYAxisTitle;
262
+ Class.prototype.editXAxisTitle = editXAxisTitle;
263
+ Class.prototype.editYAxisTitle = editYAxisTitle;
264
+ }
@@ -0,0 +1,228 @@
1
+ import { action } from "../../../core/action.js";
2
+ import { isPlainObject } from "../../../core/immutable.js";
3
+ import { validateKeys } from "../../../core/validation.js";
4
+ import {
5
+ gridNames,
6
+ editGridConfig,
7
+ refreshGridConfig,
8
+ resolveGridConfig,
9
+ resolveGridGeometry,
10
+ resolveGridResources,
11
+ validateGridCreateArgs,
12
+ validateGridEditArgs
13
+ } from "./resolve.js";
14
+
15
+ const AGGREGATE_OPTIONS = Object.freeze(["horizontal", "vertical"]);
16
+
17
+ function makeRematerialize(direction) {
18
+ const operation = gridNames(direction);
19
+ return action(
20
+ {
21
+ op: operation.rematerialize,
22
+ description: `Recompute concrete ${direction} grid lines.`
23
+ },
24
+ function (args = {}) {
25
+ if (!isPlainObject(args) || Object.keys(args).length !== 0) {
26
+ throw new TypeError(`${operation.rematerialize} does not accept options.`);
27
+ }
28
+ const storedConfig = this.guideConfigs.grid?.[direction];
29
+ const semantic = this.semanticSpec.guides.grid?.[direction];
30
+ if (
31
+ storedConfig === undefined ||
32
+ semantic?.scale !== storedConfig.scale ||
33
+ semantic.coordinate !== storedConfig.coordinate ||
34
+ this.graphicSpec.objects[operation.graphic]?.type !== "line"
35
+ ) {
36
+ throw new Error(
37
+ `${operation.rematerialize} requires an existing ${direction} grid.`
38
+ );
39
+ }
40
+
41
+ const config = refreshGridConfig(this, storedConfig);
42
+ const geometry = resolveGridGeometry(this, config);
43
+ let next = this._withGridConfig(direction, config).editGraphics({
44
+ target: operation.graphic,
45
+ property: "length",
46
+ value: geometry.values.length
47
+ });
48
+ for (const property of ["x1", "y1", "x2", "y2"]) {
49
+ next = next.editGraphics({
50
+ target: operation.graphic,
51
+ property,
52
+ value: geometry[property]
53
+ });
54
+ }
55
+ return next
56
+ .editGraphics({
57
+ target: operation.graphic,
58
+ property: "stroke",
59
+ value: config.color
60
+ })
61
+ .editGraphics({
62
+ target: operation.graphic,
63
+ property: "strokeWidth",
64
+ value: config.lineWidth
65
+ })
66
+ .editGraphics({
67
+ target: operation.graphic,
68
+ property: "strokeDash",
69
+ value: geometry.values.map(() => config.strokeDash)
70
+ });
71
+ }
72
+ );
73
+ }
74
+
75
+ function makeCreate(direction) {
76
+ const operation = gridNames(direction);
77
+ return action(
78
+ {
79
+ op: operation.create,
80
+ description: `Create a semantic and concrete ${direction} grid.`
81
+ },
82
+ function (args = {}) {
83
+ validateGridCreateArgs(args, operation.create);
84
+ if (
85
+ this.semanticSpec.guides.grid?.[direction] !== undefined ||
86
+ this.graphicSpec.objects[operation.graphic] !== undefined
87
+ ) {
88
+ throw new Error(`${operation.create} requires a missing grid.`);
89
+ }
90
+ const resources = resolveGridResources(this, direction, args);
91
+ const config = resolveGridConfig(this, direction, args, resources);
92
+ resolveGridGeometry(this, config);
93
+
94
+ return this
95
+ .editSemantic({
96
+ property: `guide.grid.${direction}.scale`,
97
+ value: resources.scale
98
+ })
99
+ .editSemantic({
100
+ property: `guide.grid.${direction}.coordinate`,
101
+ value: resources.coordinate
102
+ })
103
+ .createGraphics({
104
+ id: operation.graphic,
105
+ type: "line",
106
+ length: 0,
107
+ ...(resources.parent === undefined
108
+ ? {}
109
+ : { parent: resources.parent }),
110
+ before: resources.before
111
+ })
112
+ ._withGridConfig(direction, config)[operation.rematerialize]();
113
+ }
114
+ );
115
+ }
116
+
117
+ function makeEdit(direction) {
118
+ const operation = gridNames(direction);
119
+ const editOperation = `edit${direction === "horizontal" ? "Horizontal" : "Vertical"}Grid`;
120
+ return action(
121
+ {
122
+ op: editOperation,
123
+ description: `Edit the existing ${direction} grid.`
124
+ },
125
+ function (args = {}) {
126
+ validateGridEditArgs(args, editOperation);
127
+ const storedConfig = this.guideConfigs.grid?.[direction];
128
+ const semantic = this.semanticSpec.guides.grid?.[direction];
129
+ if (
130
+ storedConfig === undefined ||
131
+ semantic?.scale !== storedConfig.scale ||
132
+ semantic.coordinate !== storedConfig.coordinate ||
133
+ this.graphicSpec.objects[operation.graphic]?.type !== "line"
134
+ ) {
135
+ throw new Error(`${editOperation} requires an existing ${direction} grid.`);
136
+ }
137
+ const config = editGridConfig(storedConfig, args);
138
+ return this
139
+ ._withGridConfig(direction, config)
140
+ [operation.rematerialize]();
141
+ }
142
+ );
143
+ }
144
+
145
+ function normalizeDirection(value, direction) {
146
+ if (value === false) return undefined;
147
+ if (value === true || value === undefined) return {};
148
+ if (!isPlainObject(value)) {
149
+ throw new TypeError(
150
+ `createGrid ${direction} must be a boolean or plain object.`
151
+ );
152
+ }
153
+ return value;
154
+ }
155
+
156
+ const rematerializeHorizontalGrid = makeRematerialize("horizontal");
157
+ const rematerializeVerticalGrid = makeRematerialize("vertical");
158
+ const createHorizontalGrid = makeCreate("horizontal");
159
+ const createVerticalGrid = makeCreate("vertical");
160
+ const editHorizontalGrid = makeEdit("horizontal");
161
+ const editVerticalGrid = makeEdit("vertical");
162
+
163
+ const createGrid = action(
164
+ {
165
+ op: "createGrid",
166
+ description: "Create selected Cartesian grid directions."
167
+ },
168
+ function (args = {}) {
169
+ if (!isPlainObject(args)) {
170
+ throw new TypeError("createGrid options must be a plain object.");
171
+ }
172
+ validateKeys(args, AGGREGATE_OPTIONS, "createGrid");
173
+ const horizontal = normalizeDirection(args.horizontal, "horizontal");
174
+ const vertical = args.vertical === undefined
175
+ ? undefined
176
+ : normalizeDirection(args.vertical, "vertical");
177
+ if (horizontal === undefined && vertical === undefined) {
178
+ throw new Error("createGrid requires at least one selected direction.");
179
+ }
180
+
181
+ let next = this;
182
+ if (horizontal !== undefined) {
183
+ next = next.createHorizontalGrid(horizontal);
184
+ }
185
+ if (vertical !== undefined) {
186
+ next = next.createVerticalGrid(vertical);
187
+ }
188
+ return next;
189
+ }
190
+ );
191
+
192
+ const rematerializeGrid = action(
193
+ {
194
+ op: "rematerializeGrid",
195
+ description: "Recompute every existing grid direction."
196
+ },
197
+ function (args = {}) {
198
+ if (!isPlainObject(args) || Object.keys(args).length !== 0) {
199
+ throw new TypeError("rematerializeGrid does not accept options.");
200
+ }
201
+ let next = this;
202
+ let count = 0;
203
+ if (this.guideConfigs.grid?.horizontal !== undefined) {
204
+ next = next.rematerializeHorizontalGrid();
205
+ count += 1;
206
+ }
207
+ if (this.guideConfigs.grid?.vertical !== undefined) {
208
+ next = next.rematerializeVerticalGrid();
209
+ count += 1;
210
+ }
211
+ if (count === 0) {
212
+ throw new Error("rematerializeGrid requires an existing grid.");
213
+ }
214
+ return next;
215
+ }
216
+ );
217
+
218
+ export function registerGridActions(ProgramClass) {
219
+ ProgramClass.prototype.createGrid = createGrid;
220
+ ProgramClass.prototype.createHorizontalGrid = createHorizontalGrid;
221
+ ProgramClass.prototype.createVerticalGrid = createVerticalGrid;
222
+ ProgramClass.prototype.editHorizontalGrid = editHorizontalGrid;
223
+ ProgramClass.prototype.editVerticalGrid = editVerticalGrid;
224
+ ProgramClass.prototype.rematerializeGrid = rematerializeGrid;
225
+ ProgramClass.prototype.rematerializeHorizontalGrid =
226
+ rematerializeHorizontalGrid;
227
+ ProgramClass.prototype.rematerializeVerticalGrid = rematerializeVerticalGrid;
228
+ }
@@ -0,0 +1 @@
1
+ export { registerGridActions } from "./grid.js";