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,231 @@
1
+ import { action } from "../../../core/action.js";
2
+ import { validateUserId } from "../../../core/identifiers.js";
3
+ import {
4
+ sameOrderedValues,
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
+ formatTransformedTick,
14
+ mapOrdinalPositionValues
15
+ } from "../../../grammar/scales.js";
16
+ import { formatTimeTick } from "../../../grammar/ticks.js";
17
+ import { valuesFromTickConfig } from "../tickValues.js";
18
+ import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
19
+ "../../../theme/defaults.js";
20
+ import {
21
+ defaultAxisPosition,
22
+ formatAxisValue,
23
+ resolveAxisLabelGeometry,
24
+ validateAxisFormat,
25
+ validateAxisPosition
26
+ } from "./policy.js";
27
+ import { resolvePlotGraphicPlacement } from
28
+ "../../../materialization/graphicHierarchy.js";
29
+
30
+ const OPTIONS = Object.freeze([
31
+ "scale", "position", "count", "values", "offset", "format", "color",
32
+ "fontSize", "fontFamily", "fontWeight"
33
+ ]);
34
+
35
+ const DEFAULTS = Object.freeze({
36
+ count: 5,
37
+ color: DEFAULT_COLORS.text,
38
+ fontSize: 12,
39
+ fontFamily: DEFAULT_FONT_FAMILY,
40
+ fontWeight: "normal"
41
+ });
42
+
43
+ function validateOptions(args, operation, create) {
44
+ for (const key of Object.keys(args)) {
45
+ if (!OPTIONS.includes(key) || (!create && key === "scale")) {
46
+ throw new Error(`Unknown ${operation} option "${key}".`);
47
+ }
48
+ }
49
+ if (Object.hasOwn(args, "count") && Object.hasOwn(args, "values")) {
50
+ throw new Error(`${operation} cannot use count and values together.`);
51
+ }
52
+ }
53
+
54
+ function validateConfig(channel, config) {
55
+ validateAxisPosition(channel, config.position);
56
+ if (config.mode === "count" && (!Number.isInteger(config.count) || config.count <= 0)) throw new RangeError("Label count must be a positive integer.");
57
+ if (
58
+ config.mode === "values" &&
59
+ (!Array.isArray(config.values) || !config.values.every(value =>
60
+ typeof value === "string" ||
61
+ typeof value === "boolean" ||
62
+ (typeof value === "number" && Number.isFinite(value))
63
+ ))
64
+ ) throw new TypeError("Label values must be nominal values or finite numbers.");
65
+ validateNonNegativeFinite(config.offset, "Label offset");
66
+ validatePositiveFinite(config.fontSize, "Label fontSize");
67
+ validateNonEmptyString(config.color, "Label color");
68
+ validateNonEmptyString(config.fontFamily, "Label fontFamily");
69
+ if ((typeof config.fontWeight !== "string" && !Number.isFinite(config.fontWeight))) throw new TypeError("Label fontWeight must be a string or number.");
70
+ validateAxisFormat(config.format);
71
+ }
72
+
73
+ function assertTickCompatibility(ticks, config, operation) {
74
+ if (!ticks) return;
75
+ if (ticks.scale !== config.scale || ticks.mode !== config.mode) throw new Error(`${operation} conflicts with axis ticks.`);
76
+ if (config.mode === "count" && ticks.count !== config.count) throw new Error(`${operation} conflicts with axis ticks.`);
77
+ if (config.mode === "values" && !sameOrderedValues(ticks.values, config.values)) throw new Error(`${operation} conflicts with axis ticks.`);
78
+ }
79
+
80
+ function resolve(program, channel, config) {
81
+ const scale = program.resolvedScales[config.scale];
82
+ const bounds = resolveGraphicBounds(program);
83
+ const discrete = ["ordinal", "band", "point"].includes(scale?.type);
84
+ if ((
85
+ !["linear", "time", "ordinal", "band", "point"].includes(scale?.type) &&
86
+ !isTransformedScaleType(scale?.type)
87
+ ) || !bounds) throw new Error("Axis labels require a supported resolved scale and Canvas bounds.");
88
+ if (discrete && config.mode !== "values") throw new Error("Discrete axis labels require explicit or inferred values, not count.");
89
+ const values = valuesFromTickConfig(program, config);
90
+ if (discrete) {
91
+ const domainValues = new Set(scale.domain);
92
+ if (!values.every(value => domainValues.has(value))) throw new RangeError("Label values must be inside the scale domain.");
93
+ } else {
94
+ const low = Math.min(...scale.domain), high = Math.max(...scale.domain);
95
+ if (!values.every(value => value >= low && value <= high)) throw new RangeError("Label values must be inside the scale domain.");
96
+ }
97
+ const positions = discrete
98
+ ? mapOrdinalPositionValues(values, scale)
99
+ : mapContinuousScaleValues(values, scale);
100
+ const text = values.map(value => formatAxisValue(
101
+ value,
102
+ scale.type,
103
+ config.format,
104
+ item => scale.type === "time"
105
+ ? formatTimeTick(item, scale.domain)
106
+ : isTransformedScaleType(scale.type)
107
+ ? formatTransformedTick(scale.type, item)
108
+ : String(item)
109
+ ));
110
+ const resolved = {
111
+ values,
112
+ text,
113
+ ...resolveAxisLabelGeometry({
114
+ bounds,
115
+ channel,
116
+ position: config.position,
117
+ positions,
118
+ offset: config.offset
119
+ })
120
+ };
121
+ const canvas = program.graphicSpec.objects.canvas?.properties;
122
+ const maximumTextWidth = Math.max(
123
+ 0,
124
+ ...text.map(value => [...value].length * config.fontSize * 0.6)
125
+ );
126
+ const fits = config.position === "top"
127
+ ? resolved.y - config.fontSize >= 0
128
+ : config.position === "right"
129
+ ? resolved.x + maximumTextWidth <= canvas?.width
130
+ : true;
131
+ if (!canvas || !fits) {
132
+ throw new Error(`The ${channel}-axis labels do not fit the Canvas margin.`);
133
+ }
134
+ return resolved;
135
+ }
136
+
137
+ function makeEdit(channel) {
138
+ const op = channel === "x" ? "editXAxisLabels" : "editYAxisLabels";
139
+ return action({ op, description: `Edit concrete ${channel}-axis labels.` }, function (args = {}) {
140
+ validateOptions(args, op, false);
141
+ const id = `${channel}AxisLabels`;
142
+ if (this.graphicSpec.objects[id]?.type !== "text") throw new Error(`${op} requires existing axis labels.`);
143
+ const previous = this.guideConfigs.axis?.[channel]?.labels;
144
+ if (!previous) throw new Error(`${op} requires label configuration.`);
145
+ const explicitMode = Object.hasOwn(args, "values") || Object.hasOwn(args, "count");
146
+ const ticks = this.guideConfigs.axis?.[channel]?.ticks;
147
+ const inferredValues = !explicitMode && previous.inferredValues === true &&
148
+ ticks?.mode === "values"
149
+ ? ticks.values
150
+ : undefined;
151
+ const mode = Object.hasOwn(args, "values") || inferredValues !== undefined
152
+ ? "values"
153
+ : Object.hasOwn(args, "count") ? "count" : previous.mode;
154
+ const config = {
155
+ ...previous,
156
+ ...args,
157
+ ...(inferredValues === undefined ? {} : { values: inferredValues }),
158
+ ...(explicitMode ? { inferredValues: false } : {}),
159
+ mode
160
+ };
161
+ if (mode === "values") delete config.count; else delete config.values;
162
+ validateConfig(channel, config);
163
+ assertTickCompatibility(this.guideConfigs.axis?.[channel]?.ticks, config, op);
164
+ const resolved = resolve(this, channel, config);
165
+ let next = this._withGuideConfig(channel, "labels", config)
166
+ .editGraphics({ target: id, property: "length", value: resolved.values.length });
167
+ for (const property of ["x", "y", "text"]) next = next.editGraphics({ target: id, property, value: resolved[property] });
168
+ return next
169
+ .editGraphics({ target: id, property: "fill", value: config.color })
170
+ .editGraphics({ target: id, property: "fontSize", value: config.fontSize })
171
+ .editGraphics({ target: id, property: "fontFamily", value: config.fontFamily })
172
+ .editGraphics({ target: id, property: "fontWeight", value: config.fontWeight })
173
+ .editGraphics({ target: id, property: "textAlign", value: resolved.textAlign })
174
+ .editGraphics({ target: id, property: "textBaseline", value: resolved.textBaseline });
175
+ });
176
+ }
177
+
178
+ const editXAxisLabels = makeEdit("x");
179
+ const editYAxisLabels = makeEdit("y");
180
+
181
+ function makeCreate(channel) {
182
+ const op = channel === "x" ? "createXAxisLabels" : "createYAxisLabels";
183
+ const edit = channel === "x" ? "editXAxisLabels" : "editYAxisLabels";
184
+ return action({ op, description: `Create concrete ${channel}-axis labels.` }, function (args = {}) {
185
+ validateOptions(args, op, true);
186
+ const scale = validateUserId(args.scale ?? channel, "Scale id");
187
+ const guideScale = this.semanticSpec.guides.axis?.[channel]?.scale;
188
+ if (guideScale && guideScale !== scale) throw new Error(`${op} conflicts with the existing axis scale.`);
189
+ const id = `${channel}AxisLabels`;
190
+ if (this.graphicSpec.objects[id]) throw new Error(`${op} requires missing axis labels.`);
191
+ const ticks = this.guideConfigs.axis?.[channel]?.ticks;
192
+ const hasValues = Object.hasOwn(args, "values");
193
+ const hasCount = Object.hasOwn(args, "count");
194
+ const mode = hasValues ? "values" : hasCount ? "count" : ticks?.mode ?? "count";
195
+ const config = {
196
+ scale,
197
+ position: defaultAxisPosition(channel),
198
+ offset: channel === "x" ? 18 : 12,
199
+ format: "auto",
200
+ color: DEFAULTS.color,
201
+ fontSize: DEFAULTS.fontSize,
202
+ fontFamily: DEFAULTS.fontFamily,
203
+ fontWeight: DEFAULTS.fontWeight,
204
+ ...args,
205
+ inferredValues: !hasValues && !hasCount && ticks?.inferredValues === true,
206
+ mode
207
+ };
208
+ if (mode === "values") config.values ??= ticks?.values; else config.count ??= ticks?.count ?? DEFAULTS.count;
209
+ validateConfig(channel, config);
210
+ assertTickCompatibility(ticks, config, op);
211
+ resolve(this, channel, config);
212
+ return this.editSemantic({ property: `guide.axis.${channel}.scale`, value: scale })
213
+ .createGraphics({
214
+ id,
215
+ type: "text",
216
+ length: 0,
217
+ ...resolvePlotGraphicPlacement(this)
218
+ })
219
+ ._withGuideConfig(channel, "labels", config)[edit]();
220
+ });
221
+ }
222
+
223
+ const createXAxisLabels = makeCreate("x");
224
+ const createYAxisLabels = makeCreate("y");
225
+
226
+ export function registerAxisLabelActions(Class) {
227
+ Class.prototype.editXAxisLabels = editXAxisLabels;
228
+ Class.prototype.editYAxisLabels = editYAxisLabels;
229
+ Class.prototype.createXAxisLabels = createXAxisLabels;
230
+ Class.prototype.createYAxisLabels = createYAxisLabels;
231
+ }
@@ -0,0 +1,149 @@
1
+ import { action } from "../../../core/action.js";
2
+ import { validateUserId } from "../../../core/identifiers.js";
3
+ import {
4
+ validateKeys,
5
+ validateNonEmptyString,
6
+ validateNonNegativeFinite
7
+ } from "../../../core/validation.js";
8
+ import { resolveGraphicBounds } from "../../../layout/canvas.js";
9
+ import { DEFAULT_COLORS } from "../../../theme/defaults.js";
10
+ import {
11
+ defaultAxisPosition,
12
+ resolveAxisLineGeometry,
13
+ validateAxisPosition
14
+ } from "./policy.js";
15
+ import { resolvePlotGraphicPlacement } from
16
+ "../../../materialization/graphicHierarchy.js";
17
+
18
+ const DEFAULT_STYLE = Object.freeze({ color: DEFAULT_COLORS.text, lineWidth: 1 });
19
+ const CREATE_OPTIONS = Object.freeze(["scale", "position", "color", "lineWidth"]);
20
+ const EDIT_OPTIONS = Object.freeze(["position", "color", "lineWidth"]);
21
+
22
+ function validateStyle({ color, lineWidth }) {
23
+ validateNonEmptyString(color, "Axis line color");
24
+ validateNonNegativeFinite(lineWidth, "Axis lineWidth");
25
+ }
26
+
27
+ function resolveGeometry(program, channel, scaleId, position) {
28
+ const hasConsumer = program.semanticSpec.layers.some(
29
+ layer => layer.encoding?.[channel]?.scale === scaleId
30
+ );
31
+
32
+ if (!hasConsumer) {
33
+ throw new Error(
34
+ `Axis line requires scale "${scaleId}" on the ${channel} channel.`
35
+ );
36
+ }
37
+
38
+ const range = program.resolvedScales[scaleId]?.range;
39
+ const bounds = resolveGraphicBounds(program);
40
+
41
+ if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite)) {
42
+ throw new Error(`Axis line requires a resolved numeric scale "${scaleId}".`);
43
+ }
44
+
45
+ if (
46
+ bounds === undefined ||
47
+ ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite)
48
+ ) {
49
+ throw new Error("Axis line requires graphical Canvas bounds.");
50
+ }
51
+
52
+ return resolveAxisLineGeometry(bounds, channel, position, range);
53
+ }
54
+
55
+ function axisIds(channel) {
56
+ return {
57
+ graphic: `${channel}AxisLine`,
58
+ guidePath: `guide.axis.${channel}.scale`
59
+ };
60
+ }
61
+
62
+ function createEditAxisLine(channel) {
63
+ const operation = channel === "x" ? "editXAxisLine" : "editYAxisLine";
64
+
65
+ return action(
66
+ { op: operation, description: `Edit the concrete ${channel}-axis line.` },
67
+ function (args = {}) {
68
+ validateKeys(args, EDIT_OPTIONS, operation);
69
+ const { graphic } = axisIds(channel);
70
+ const line = this.graphicSpec.objects[graphic];
71
+
72
+ if (line?.type !== "line") {
73
+ throw new Error(`${operation} requires an existing ${channel}-axis line.`);
74
+ }
75
+
76
+ const previous = this.guideConfigs.axis?.[channel]?.line;
77
+ const position = validateAxisPosition(
78
+ channel,
79
+ args.position ?? previous?.position ?? defaultAxisPosition(channel)
80
+ );
81
+ const scaleId = this.semanticSpec.guides.axis?.[channel]?.scale;
82
+ const geometry = resolveGeometry(this, channel, scaleId, position);
83
+ const color = args.color ?? line.properties.stroke;
84
+ const lineWidth = args.lineWidth ?? line.properties.strokeWidth;
85
+ validateStyle({ color, lineWidth });
86
+ let next = this._withGuideConfig(channel, "line", {
87
+ position,
88
+ color,
89
+ lineWidth
90
+ });
91
+
92
+ for (const property of ["x1", "y1", "x2", "y2"]) {
93
+ next = next.editGraphics({ target: graphic, property, value: geometry[property] });
94
+ }
95
+
96
+ return next
97
+ .editGraphics({ target: graphic, property: "stroke", value: color })
98
+ .editGraphics({ target: graphic, property: "strokeWidth", value: lineWidth });
99
+ }
100
+ );
101
+ }
102
+
103
+ const editXAxisLine = createEditAxisLine("x");
104
+ const editYAxisLine = createEditAxisLine("y");
105
+
106
+ function createAxisLine(channel) {
107
+ const operation = channel === "x" ? "createXAxisLine" : "createYAxisLine";
108
+ const editOperation = channel === "x" ? "editXAxisLine" : "editYAxisLine";
109
+
110
+ return action(
111
+ { op: operation, description: `Create the concrete ${channel}-axis line.` },
112
+ function (args = {}) {
113
+ validateKeys(args, CREATE_OPTIONS, operation);
114
+ const scale = validateUserId(args.scale ?? channel, "Scale id");
115
+ const position = validateAxisPosition(
116
+ channel,
117
+ args.position ?? defaultAxisPosition(channel)
118
+ );
119
+ const color = args.color ?? DEFAULT_STYLE.color;
120
+ const lineWidth = args.lineWidth ?? DEFAULT_STYLE.lineWidth;
121
+ validateStyle({ color, lineWidth });
122
+ resolveGeometry(this, channel, scale, position);
123
+ const { graphic, guidePath } = axisIds(channel);
124
+
125
+ if (this.graphicSpec.objects[graphic] !== undefined) {
126
+ throw new Error(`${operation} requires a missing ${channel}-axis line.`);
127
+ }
128
+
129
+ return this
130
+ .editSemantic({ property: guidePath, value: scale })
131
+ .createGraphics({
132
+ id: graphic,
133
+ type: "line",
134
+ ...resolvePlotGraphicPlacement(this)
135
+ })
136
+ [editOperation]({ position, color, lineWidth });
137
+ }
138
+ );
139
+ }
140
+
141
+ const createXAxisLine = createAxisLine("x");
142
+ const createYAxisLine = createAxisLine("y");
143
+
144
+ export function registerAxisLineActions(ProgramClass) {
145
+ ProgramClass.prototype.editXAxisLine = editXAxisLine;
146
+ ProgramClass.prototype.editYAxisLine = editYAxisLine;
147
+ ProgramClass.prototype.createXAxisLine = createXAxisLine;
148
+ ProgramClass.prototype.createYAxisLine = createYAxisLine;
149
+ }
@@ -0,0 +1,155 @@
1
+ const AXIS_POSITIONS = Object.freeze({
2
+ x: Object.freeze(["bottom", "top"]),
3
+ y: Object.freeze(["left", "right"])
4
+ });
5
+
6
+ const NUMERIC_FORMATS = new Set([
7
+ ".0f", ".1f", ".2f", ".0%", ".1%", ".2e"
8
+ ]);
9
+ const TIME_FORMATS = new Set(["%Y", "%Y-%m", "%Y-%m-%d"]);
10
+
11
+ export function defaultAxisPosition(channel) {
12
+ return channel === "x" ? "bottom" : "left";
13
+ }
14
+
15
+ export function validateAxisPosition(channel, position) {
16
+ if (!AXIS_POSITIONS[channel]?.includes(position)) {
17
+ throw new Error(`Unsupported ${channel}-axis position "${position}".`);
18
+ }
19
+ return position;
20
+ }
21
+
22
+ export function defaultAxisTitleRotation(channel, position) {
23
+ if (channel === "x") return 0;
24
+ return position === "right" ? Math.PI / 2 : -Math.PI / 2;
25
+ }
26
+
27
+ export function axisBaseline(bounds, channel, position) {
28
+ if (channel === "x") {
29
+ return position === "top" ? bounds.y : bounds.y + bounds.height;
30
+ }
31
+ return position === "right" ? bounds.x + bounds.width : bounds.x;
32
+ }
33
+
34
+ export function resolveAxisLineGeometry(bounds, channel, position, range) {
35
+ const baseline = axisBaseline(bounds, channel, position);
36
+ return channel === "x"
37
+ ? { x1: range[0], y1: baseline, x2: range[1], y2: baseline }
38
+ : { x1: baseline, y1: range[0], x2: baseline, y2: range[1] };
39
+ }
40
+
41
+ export function resolveAxisTickGeometry({
42
+ bounds,
43
+ channel,
44
+ position,
45
+ positions,
46
+ length
47
+ }) {
48
+ const baseline = axisBaseline(bounds, channel, position);
49
+ if (channel === "x") {
50
+ const end = position === "top" ? baseline - length : baseline + length;
51
+ return { x1: positions, y1: baseline, x2: positions, y2: end };
52
+ }
53
+ const end = position === "right" ? baseline + length : baseline - length;
54
+ return position === "right"
55
+ ? { x1: baseline, y1: positions, x2: end, y2: positions }
56
+ : { x1: end, y1: positions, x2: baseline, y2: positions };
57
+ }
58
+
59
+ export function resolveAxisLabelGeometry({
60
+ bounds,
61
+ channel,
62
+ position,
63
+ positions,
64
+ offset
65
+ }) {
66
+ const baseline = axisBaseline(bounds, channel, position);
67
+ if (channel === "x") {
68
+ return {
69
+ x: positions,
70
+ y: position === "top" ? baseline - offset : baseline + offset,
71
+ textAlign: "center",
72
+ textBaseline: position === "top" ? "bottom" : "top"
73
+ };
74
+ }
75
+ return {
76
+ x: position === "right" ? baseline + offset : baseline - offset,
77
+ y: positions,
78
+ textAlign: position === "right" ? "left" : "right",
79
+ textBaseline: "middle"
80
+ };
81
+ }
82
+
83
+ export function resolveAxisTitleGeometry({
84
+ bounds,
85
+ channel,
86
+ position,
87
+ along,
88
+ offset
89
+ }) {
90
+ const baseline = axisBaseline(bounds, channel, position);
91
+ return channel === "x"
92
+ ? { x: along, y: position === "top" ? baseline - offset : baseline + offset }
93
+ : { x: position === "right" ? baseline + offset : baseline - offset, y: along };
94
+ }
95
+
96
+ export function validateAxisFormat(format) {
97
+ if (format === "auto") return format;
98
+ if (
99
+ format !== null &&
100
+ typeof format === "object" &&
101
+ Number.isInteger(format.decimals) &&
102
+ format.decimals >= 0 &&
103
+ Object.keys(format).length === 1
104
+ ) {
105
+ return format;
106
+ }
107
+ if (typeof format === "string" && (
108
+ NUMERIC_FORMATS.has(format) || TIME_FORMATS.has(format)
109
+ )) {
110
+ return format;
111
+ }
112
+ throw new TypeError(
113
+ "Label format must be auto, { decimals }, or a supported format string."
114
+ );
115
+ }
116
+
117
+ function pad(value, length = 2) {
118
+ return String(value).padStart(length, "0");
119
+ }
120
+
121
+ function formatTime(value, format) {
122
+ const date = new Date(value);
123
+ if (!Number.isFinite(date.getTime())) {
124
+ throw new TypeError("Time axis format requires valid timestamps.");
125
+ }
126
+ const year = pad(date.getUTCFullYear(), 4);
127
+ const month = pad(date.getUTCMonth() + 1);
128
+ const day = pad(date.getUTCDate());
129
+ if (format === "%Y") return year;
130
+ if (format === "%Y-%m") return `${year}-${month}`;
131
+ return `${year}-${month}-${day}`;
132
+ }
133
+
134
+ export function formatAxisValue(value, scaleType, format, autoFormatter) {
135
+ validateAxisFormat(format);
136
+ if (format === "auto") return autoFormatter(value);
137
+ if (["ordinal", "band", "point"].includes(scaleType)) {
138
+ throw new Error('Discrete axis labels require format "auto".');
139
+ }
140
+ if (scaleType === "time") {
141
+ if (!TIME_FORMATS.has(format)) {
142
+ throw new Error("Time axis labels require a supported time format string.");
143
+ }
144
+ return formatTime(value, format);
145
+ }
146
+ if (TIME_FORMATS.has(format)) {
147
+ throw new Error("Quantitative axis labels cannot use a time format string.");
148
+ }
149
+ if (typeof format === "object") return value.toFixed(format.decimals);
150
+ if (format.endsWith("f")) return value.toFixed(Number(format[1]));
151
+ if (format.endsWith("%")) {
152
+ return `${(value * 100).toFixed(Number(format[1]))}%`;
153
+ }
154
+ return value.toExponential(2);
155
+ }
@@ -0,0 +1,149 @@
1
+ import { action } from "../../../core/action.js";
2
+ import { isPlainObject } from "../../../core/immutable.js";
3
+ import { validateKeys } from "../../../core/validation.js";
4
+
5
+ const CREATE_OPTIONS = Object.freeze([
6
+ "scale",
7
+ "position",
8
+ "count",
9
+ "values",
10
+ "ticks",
11
+ "labels"
12
+ ]);
13
+ const EDIT_OPTIONS = Object.freeze([
14
+ "position",
15
+ "count",
16
+ "values",
17
+ "ticks",
18
+ "labels"
19
+ ]);
20
+ const TICK_OPTIONS = Object.freeze(["length", "color", "lineWidth"]);
21
+ const LABEL_OPTIONS = Object.freeze([
22
+ "offset",
23
+ "format",
24
+ "color",
25
+ "fontSize",
26
+ "fontFamily",
27
+ "fontWeight"
28
+ ]);
29
+ const SHARED_CREATE = Object.freeze(["scale", "position", "count", "values"]);
30
+ const SHARED_EDIT = Object.freeze(["position", "count", "values"]);
31
+
32
+ function validateNested(value, supported, label) {
33
+ if (!isPlainObject(value)) {
34
+ throw new TypeError(`${label} must be a plain object.`);
35
+ }
36
+
37
+ validateKeys(value, supported, label);
38
+ }
39
+
40
+ function validateArgs(args, operation, create) {
41
+ validateKeys(args, create ? CREATE_OPTIONS : EDIT_OPTIONS, operation);
42
+
43
+ if (Object.hasOwn(args, "count") && Object.hasOwn(args, "values")) {
44
+ throw new Error(`${operation} cannot use count and values together.`);
45
+ }
46
+
47
+ if (Object.hasOwn(args, "ticks")) {
48
+ validateNested(args.ticks, TICK_OPTIONS, `${operation}.ticks`);
49
+ }
50
+
51
+ if (Object.hasOwn(args, "labels")) {
52
+ validateNested(args.labels, LABEL_OPTIONS, `${operation}.labels`);
53
+ }
54
+
55
+ if (!create && Object.keys(args).length === 0) {
56
+ throw new TypeError(`${operation} requires at least one option.`);
57
+ }
58
+ }
59
+
60
+ function select(source, keys) {
61
+ const result = {};
62
+
63
+ for (const key of keys) {
64
+ if (Object.hasOwn(source, key)) result[key] = source[key];
65
+ }
66
+
67
+ return result;
68
+ }
69
+
70
+ function names(channel) {
71
+ const prefix = channel === "x" ? "X" : "Y";
72
+ return {
73
+ create: `create${prefix}AxisTicksAndLabels`,
74
+ edit: `edit${prefix}AxisTicksAndLabels`,
75
+ createTicks: `create${prefix}AxisTicks`,
76
+ createLabels: `create${prefix}AxisLabels`,
77
+ editTicks: `edit${prefix}AxisTicks`,
78
+ editLabels: `edit${prefix}AxisLabels`
79
+ };
80
+ }
81
+
82
+ function makeCreate(channel) {
83
+ const operation = names(channel);
84
+
85
+ return action(
86
+ {
87
+ op: operation.create,
88
+ description: `Create ${channel}-axis ticks and labels.`
89
+ },
90
+ function (args = {}) {
91
+ validateArgs(args, operation.create, true);
92
+ const shared = select(args, SHARED_CREATE);
93
+ const tickArgs = { ...shared, ...(args.ticks ?? {}) };
94
+ const labelArgs = {
95
+ ...select(shared, ["scale", "position"]),
96
+ ...(args.labels ?? {})
97
+ };
98
+
99
+ return this[operation.createTicks](tickArgs)[operation.createLabels](
100
+ labelArgs
101
+ );
102
+ }
103
+ );
104
+ }
105
+
106
+ function makeEdit(channel) {
107
+ const operation = names(channel);
108
+
109
+ return action(
110
+ {
111
+ op: operation.edit,
112
+ description: `Edit ${channel}-axis ticks and labels.`
113
+ },
114
+ function (args = {}) {
115
+ validateArgs(args, operation.edit, false);
116
+ const shared = select(args, SHARED_EDIT);
117
+ const hasShared = Object.keys(shared).length > 0;
118
+ let next = this;
119
+
120
+ if (hasShared || Object.hasOwn(args, "ticks")) {
121
+ next = next[operation.editTicks]({
122
+ ...shared,
123
+ ...(args.ticks ?? {})
124
+ });
125
+ }
126
+
127
+ if (hasShared || Object.hasOwn(args, "labels")) {
128
+ next = next[operation.editLabels]({
129
+ ...shared,
130
+ ...(args.labels ?? {})
131
+ });
132
+ }
133
+
134
+ return next;
135
+ }
136
+ );
137
+ }
138
+
139
+ const createXAxisTicksAndLabels = makeCreate("x");
140
+ const createYAxisTicksAndLabels = makeCreate("y");
141
+ const editXAxisTicksAndLabels = makeEdit("x");
142
+ const editYAxisTicksAndLabels = makeEdit("y");
143
+
144
+ export function registerAxisTickGroupActions(Class) {
145
+ Class.prototype.createXAxisTicksAndLabels = createXAxisTicksAndLabels;
146
+ Class.prototype.createYAxisTicksAndLabels = createYAxisTicksAndLabels;
147
+ Class.prototype.editXAxisTicksAndLabels = editXAxisTicksAndLabels;
148
+ Class.prototype.editYAxisTicksAndLabels = editYAxisTicksAndLabels;
149
+ }