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,234 @@
1
+ import { action } from "../../../../core/action.js";
2
+ import { isPlainObject } from "../../../../core/immutable.js";
3
+ import { validateKeys } from "../../../../core/validation.js";
4
+ import { formatDiscretizedIntervals } from "../../../../grammar/scales.js";
5
+ import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
6
+ "../../../../theme/defaults.js";
7
+ import {
8
+ assertLegendInsideCanvas,
9
+ resolveContinuousBounds,
10
+ resolveContinuousPoint,
11
+ styleContinuousText,
12
+ validateNonNegative,
13
+ validatePositive
14
+ } from "./common.js";
15
+ import { resolveLegendGraphicPlacement } from
16
+ "../../../../materialization/graphicHierarchy.js";
17
+
18
+ const OPTIONS = Object.freeze([
19
+ "target", "channels", "position", "align", "offset", "title",
20
+ "symbol", "labels", "titleStyle", "itemGap", "direction", "border"
21
+ ]);
22
+ const SYMBOL_OPTIONS = Object.freeze([
23
+ "width", "height", "stroke", "strokeWidth"
24
+ ]);
25
+ const TEXT_OPTIONS = Object.freeze([
26
+ "offset", "color", "fontSize", "fontFamily", "fontWeight"
27
+ ]);
28
+
29
+ function textOptions(value, label, defaults) {
30
+ if (value !== undefined && !isPlainObject(value)) {
31
+ throw new TypeError(`${label} must be a plain object.`);
32
+ }
33
+ validateKeys(value ?? {}, TEXT_OPTIONS, label);
34
+ const result = { ...defaults, ...(value ?? {}) };
35
+ if (result.offset !== undefined) validateNonNegative(result.offset, `${label} offset`);
36
+ validatePositive(result.fontSize, `${label} fontSize`);
37
+ return result;
38
+ }
39
+
40
+ export function normalizeIntervalLegend(args) {
41
+ if (!isPlainObject(args)) {
42
+ throw new TypeError("createLegend options must be a plain object.");
43
+ }
44
+ validateKeys(args, OPTIONS, "createLegend");
45
+ if (args.channels !== undefined && (
46
+ !Array.isArray(args.channels) ||
47
+ args.channels.length !== 1 ||
48
+ args.channels[0] !== "color"
49
+ )) {
50
+ throw new Error('Interval legend requires channels: ["color"].');
51
+ }
52
+ if ((args.position ?? "right") !== "right") {
53
+ throw new Error('Interval legends currently support position "right".');
54
+ }
55
+ if ((args.direction ?? "vertical") !== "vertical") {
56
+ throw new Error('Interval legends currently support direction "vertical".');
57
+ }
58
+ if (args.border !== undefined && args.border !== false) {
59
+ throw new Error("Interval legend border is not implemented yet.");
60
+ }
61
+ if (args.symbol !== undefined && !isPlainObject(args.symbol)) {
62
+ throw new TypeError("createLegend.symbol must be a plain object.");
63
+ }
64
+ validateKeys(args.symbol ?? {}, SYMBOL_OPTIONS, "createLegend.symbol");
65
+ const symbol = {
66
+ width: args.symbol?.width ?? 14,
67
+ height: args.symbol?.height ?? 12,
68
+ stroke: args.symbol?.stroke ?? "white",
69
+ strokeWidth: args.symbol?.strokeWidth ?? 0.5
70
+ };
71
+ validatePositive(symbol.width, "Legend symbol width");
72
+ validatePositive(symbol.height, "Legend symbol height");
73
+ validateNonNegative(symbol.strokeWidth, "Legend symbol strokeWidth");
74
+ const offset = args.offset ?? 30;
75
+ const itemGap = args.itemGap ?? 28;
76
+ validateNonNegative(offset, "Legend offset");
77
+ validatePositive(itemGap, "Legend itemGap");
78
+ return {
79
+ target: args.target,
80
+ position: "right",
81
+ direction: "vertical",
82
+ align: args.align ?? "center",
83
+ offset,
84
+ itemGap,
85
+ title: args.title,
86
+ inferredTitle: args.title === undefined,
87
+ titleVisible: true,
88
+ symbol,
89
+ labels: textOptions(args.labels, "createLegend.labels", {
90
+ offset: 8,
91
+ color: DEFAULT_COLORS.text,
92
+ fontSize: 12,
93
+ fontFamily: DEFAULT_FONT_FAMILY,
94
+ fontWeight: "normal"
95
+ }),
96
+ titleStyle: textOptions(args.titleStyle, "createLegend.titleStyle", {
97
+ color: DEFAULT_COLORS.text,
98
+ fontSize: 13,
99
+ fontFamily: DEFAULT_FONT_FAMILY,
100
+ fontWeight: 600
101
+ }),
102
+ border: false
103
+ };
104
+ }
105
+
106
+ function resolveIntervalConfig(program, stored) {
107
+ const layer = resolveContinuousPoint(program, stored.target, "color");
108
+ const encoding = layer.encoding.color;
109
+ if (encoding.fieldType !== "quantitative") {
110
+ throw new Error("Interval legend requires quantitative color.");
111
+ }
112
+ const scale = program.resolvedScales[encoding.scale];
113
+ if (!["quantize", "quantile", "threshold"].includes(scale?.type)) {
114
+ throw new Error(`Interval legend requires a resolved discretized scale "${encoding.scale}".`);
115
+ }
116
+ return {
117
+ layer,
118
+ encoding,
119
+ scale,
120
+ config: {
121
+ ...stored,
122
+ target: layer.id,
123
+ scale: encoding.scale,
124
+ title: stored.inferredTitle ? encoding.field : stored.title
125
+ }
126
+ };
127
+ }
128
+
129
+ function resolveIntervalLayout(program, config, scale) {
130
+ const { plot, canvas } = resolveContinuousBounds(program);
131
+ const labels = formatDiscretizedIntervals(scale.thresholds);
132
+ const symbolX = plot.x + plot.width + config.offset;
133
+ const itemY = labels.map((_, index) => plot.y + 52 + index * config.itemGap);
134
+ const labelX = symbolX + config.symbol.width + config.labels.offset;
135
+ const title = { x: symbolX, y: plot.y + 20 };
136
+ assertLegendInsideCanvas([
137
+ title,
138
+ ...itemY.flatMap(y => [
139
+ { x: symbolX, y },
140
+ { x: labelX, y }
141
+ ])
142
+ ], canvas, "Interval legend layout");
143
+ return { labels, symbolX, labelX, itemY, title };
144
+ }
145
+
146
+ export const rematerializeIntervalLegend = action(
147
+ {
148
+ op: "rematerializeIntervalLegend",
149
+ description: "Rematerialize a discretized color interval legend."
150
+ },
151
+ function (args = {}) {
152
+ validateKeys(args, [], "rematerializeIntervalLegend");
153
+ const stored = this.guideConfigs.legend?.interval;
154
+ if (stored === undefined) {
155
+ throw new Error("Interval legend requires stored configuration.");
156
+ }
157
+ const { encoding, scale, config } = resolveIntervalConfig(this, stored);
158
+ const layout = resolveIntervalLayout(this, config, scale);
159
+ let next = this
160
+ .editSemantic({ property: "guide.legend.color.scale", value: encoding.scale })
161
+ .editSemantic({ property: "guide.legend.color.title", value: config.title })
162
+ ._withLegendConfig("interval", config)
163
+ .editGraphics({
164
+ target: "colorLegendSymbols",
165
+ property: "length",
166
+ value: scale.range.length
167
+ })
168
+ .editGraphics({ target: "colorLegendSymbols", property: "x", value: layout.symbolX })
169
+ .editGraphics({
170
+ target: "colorLegendSymbols",
171
+ property: "y",
172
+ value: layout.itemY.map(value => value - config.symbol.height / 2)
173
+ })
174
+ .editGraphics({ target: "colorLegendSymbols", property: "width", value: config.symbol.width })
175
+ .editGraphics({ target: "colorLegendSymbols", property: "height", value: config.symbol.height })
176
+ .editGraphics({ target: "colorLegendSymbols", property: "fill", value: scale.range })
177
+ .editGraphics({ target: "colorLegendSymbols", property: "stroke", value: config.symbol.stroke })
178
+ .editGraphics({ target: "colorLegendSymbols", property: "strokeWidth", value: config.symbol.strokeWidth })
179
+ .editGraphics({ target: "colorLegendLabels", property: "length", value: layout.labels.length })
180
+ .editGraphics({ target: "colorLegendLabels", property: "x", value: layout.labelX })
181
+ .editGraphics({ target: "colorLegendLabels", property: "y", value: layout.itemY })
182
+ .editGraphics({ target: "colorLegendLabels", property: "text", value: layout.labels });
183
+ next = styleContinuousText(next, "colorLegendLabels", config.labels);
184
+ if (config.titleVisible === false) return next;
185
+ next = next
186
+ .editGraphics({ target: "colorLegendTitle", property: "x", value: layout.title.x })
187
+ .editGraphics({ target: "colorLegendTitle", property: "y", value: layout.title.y })
188
+ .editGraphics({ target: "colorLegendTitle", property: "text", value: config.title });
189
+ return styleContinuousText(next, "colorLegendTitle", config.titleStyle);
190
+ }
191
+ );
192
+
193
+ export const createIntervalLegend = action(
194
+ {
195
+ op: "createIntervalLegend",
196
+ description: "Create a discretized color interval legend."
197
+ },
198
+ function (args = {}) {
199
+ const config = normalizeIntervalLegend(args);
200
+ const resolved = resolveIntervalConfig(this, config);
201
+ resolveIntervalLayout(this, resolved.config, resolved.scale);
202
+ if (this.graphicSpec.objects.colorLegendSymbols !== undefined) {
203
+ throw new Error("createIntervalLegend requires a missing interval legend.");
204
+ }
205
+ return this
206
+ .editSemantic({
207
+ property: "guide.legend.color.scale",
208
+ value: resolved.encoding.scale
209
+ })
210
+ .editSemantic({
211
+ property: "guide.legend.color.title",
212
+ value: resolved.config.title
213
+ })
214
+ ._withLegendConfig("interval", resolved.config)
215
+ .createGraphics({
216
+ id: "colorLegendSymbols",
217
+ type: "rect",
218
+ length: 0,
219
+ ...resolveLegendGraphicPlacement(this)
220
+ })
221
+ .createGraphics({
222
+ id: "colorLegendLabels",
223
+ type: "text",
224
+ length: 0,
225
+ ...resolveLegendGraphicPlacement(this)
226
+ })
227
+ .createGraphics({
228
+ id: "colorLegendTitle",
229
+ type: "text",
230
+ ...resolveLegendGraphicPlacement(this)
231
+ })
232
+ .rematerializeIntervalLegend();
233
+ }
234
+ );
@@ -0,0 +1,382 @@
1
+ import { action } from "../../../../core/action.js";
2
+ import { isPlainObject } from "../../../../core/immutable.js";
3
+ import { validateKeys } from "../../../../core/validation.js";
4
+ import { mapLinearValues } from "../../../../grammar/scales.js";
5
+ import { DEFAULT_COLORS } from "../../../../theme/defaults.js";
6
+ import {
7
+ assertLegendInsideCanvas,
8
+ editLegendBackground,
9
+ formatContinuousValues,
10
+ normalizeContinuousLegend,
11
+ requireResolvedLegendScale,
12
+ resolveContinuousBounds,
13
+ resolveContinuousPoint,
14
+ resolveLegendBackgroundBounds,
15
+ sampleContinuousValues,
16
+ styleContinuousText,
17
+ validateNonNegative,
18
+ validatePositive
19
+ } from "./common.js";
20
+ import { resolveLegendGraphicPlacement } from
21
+ "../../../../materialization/graphicHierarchy.js";
22
+
23
+ const SYMBOL_OPTIONS = Object.freeze([
24
+ "type", "radius", "fill", "stroke", "strokeWidth"
25
+ ]);
26
+
27
+ export function normalizeOpacitySymbol(value) {
28
+ if (value === undefined) {
29
+ return { type: "point", radius: 7, fill: DEFAULT_COLORS.mark };
30
+ }
31
+ if (!isPlainObject(value)) {
32
+ throw new TypeError("createLegend.symbol must be one point recipe object.");
33
+ }
34
+ validateKeys(value, SYMBOL_OPTIONS, "createLegend.symbol");
35
+ const symbol = {
36
+ type: "point",
37
+ radius: 7,
38
+ fill: DEFAULT_COLORS.mark,
39
+ ...value
40
+ };
41
+ if (symbol.type !== "point") {
42
+ throw new Error("Opacity legend symbol must have type point.");
43
+ }
44
+ validatePositive(symbol.radius, "Opacity legend symbol radius");
45
+ if (typeof symbol.fill !== "string" || symbol.fill.length === 0) {
46
+ throw new TypeError("Opacity legend symbol fill must be non-empty.");
47
+ }
48
+ if (symbol.stroke !== undefined && (
49
+ typeof symbol.stroke !== "string" || symbol.stroke.length === 0
50
+ )) {
51
+ throw new TypeError("Opacity legend symbol stroke must be non-empty.");
52
+ }
53
+ if (symbol.strokeWidth !== undefined) {
54
+ validateNonNegative(
55
+ symbol.strokeWidth,
56
+ "Opacity legend symbol strokeWidth"
57
+ );
58
+ }
59
+ return symbol;
60
+ }
61
+
62
+ function resolveOpacityConfig(program, config) {
63
+ const layer = resolveContinuousPoint(program, config.target, "opacity");
64
+ const encoding = layer.encoding.opacity;
65
+ if (encoding.fieldType !== "quantitative") {
66
+ throw new Error("Opacity legend requires quantitative field opacity.");
67
+ }
68
+ const scale = requireResolvedLegendScale(program, encoding.scale, "linear");
69
+ return {
70
+ layer,
71
+ encoding,
72
+ scale,
73
+ config: {
74
+ ...config,
75
+ target: layer.id,
76
+ scale: encoding.scale,
77
+ title: config.inferredTitle ? encoding.field : config.title,
78
+ domain: scale.domain
79
+ }
80
+ };
81
+ }
82
+
83
+ function resolveOpacityLayout(program, config, scale) {
84
+ const { plot, canvas } = resolveContinuousBounds(program);
85
+ const vertical = ["right", "left"].includes(config.position);
86
+ const values = sampleContinuousValues(scale.domain, config.count);
87
+ const radius = config.symbol.radius;
88
+ let symbols;
89
+ let labels;
90
+ let title;
91
+ if (vertical) {
92
+ const baseX = config.position === "right"
93
+ ? plot.x + plot.width + config.offset
94
+ : plot.x - config.offset;
95
+ const y = values.map((_, index) => plot.y + 46 + index * config.itemGap);
96
+ symbols = y.map(itemY => ({
97
+ x: config.position === "right" ? baseX + radius : baseX - radius,
98
+ y: itemY
99
+ }));
100
+ labels = y.map(itemY => ({
101
+ x: config.position === "right"
102
+ ? baseX + radius * 2 + config.labels.offset - 2
103
+ : baseX - radius * 2 - config.labels.offset + 2,
104
+ y: itemY,
105
+ align: config.position === "right" ? "left" : "right"
106
+ }));
107
+ title = {
108
+ x: config.position === "right" ? baseX : baseX - radius * 2,
109
+ y: plot.y + 20,
110
+ align: config.position === "right" ? "left" : "right"
111
+ };
112
+ } else {
113
+ const width = (values.length - 1) * Math.max(56, config.itemGap * 2);
114
+ const startX = config.align === "left" ? plot.x
115
+ : config.align === "right" ? plot.x + plot.width - width
116
+ : plot.x + (plot.width - width) / 2;
117
+ const y = config.position === "top"
118
+ ? plot.y - config.offset - radius
119
+ : plot.y + plot.height + config.offset + radius;
120
+ symbols = values.map((_, index) => ({
121
+ x: startX + index * width / (values.length - 1),
122
+ y
123
+ }));
124
+ labels = symbols.map(symbol => ({
125
+ x: symbol.x,
126
+ y: config.position === "top"
127
+ ? symbol.y - radius - config.labels.offset
128
+ : symbol.y + radius + config.labels.offset,
129
+ align: "center"
130
+ }));
131
+ title = { x: startX + width / 2, y: y - 26, align: "center" };
132
+ }
133
+ assertLegendInsideCanvas(
134
+ [title, ...symbols, ...labels],
135
+ canvas,
136
+ "Opacity legend layout"
137
+ );
138
+ const background = resolveLegendBackgroundBounds([
139
+ title,
140
+ ...symbols.map(symbol => ({
141
+ x: symbol.x + radius,
142
+ y: symbol.y + radius
143
+ })),
144
+ ...symbols.map(symbol => ({
145
+ x: symbol.x - radius,
146
+ y: symbol.y - radius
147
+ })),
148
+ ...labels.map(label => ({
149
+ x: label.x + (
150
+ label.align === "left" ? 42 : label.align === "right" ? -42 : 0
151
+ ),
152
+ y: label.y
153
+ }))
154
+ ], config.border, canvas, "Opacity legend");
155
+ return { values, symbols, labels, title, background };
156
+ }
157
+
158
+ export const rematerializeOpacityLegend = action(
159
+ {
160
+ op: "rematerializeOpacityLegend",
161
+ description: "Rematerialize a field-opacity sample legend."
162
+ },
163
+ function (args = {}) {
164
+ validateKeys(args, [], "rematerializeOpacityLegend");
165
+ const stored = this.guideConfigs.legend?.opacity;
166
+ if (stored === undefined) {
167
+ throw new Error("Opacity legend requires stored configuration.");
168
+ }
169
+ const { encoding, scale, config } = resolveOpacityConfig(this, stored);
170
+ const layout = resolveOpacityLayout(this, config, scale);
171
+ const opacities = mapLinearValues(layout.values, scale.domain, scale.range, {
172
+ clamp: scale.clamp ?? false
173
+ });
174
+ let next = this
175
+ .editSemantic({
176
+ property: "guide.legend.opacity.scale",
177
+ value: encoding.scale
178
+ })
179
+ .editSemantic({
180
+ property: "guide.legend.opacity.title",
181
+ value: config.title
182
+ })
183
+ ._withLegendConfig("opacity", config)
184
+ .editGraphics({
185
+ target: "opacityLegendSymbols",
186
+ property: "length",
187
+ value: layout.values.length
188
+ })
189
+ .editGraphics({
190
+ target: "opacityLegendSymbols",
191
+ property: "x",
192
+ value: layout.symbols.map(symbol => symbol.x)
193
+ })
194
+ .editGraphics({
195
+ target: "opacityLegendSymbols",
196
+ property: "y",
197
+ value: layout.symbols.map(symbol => symbol.y)
198
+ })
199
+ .editGraphics({
200
+ target: "opacityLegendSymbols",
201
+ property: "radius",
202
+ value: config.symbol.radius
203
+ })
204
+ .editGraphics({
205
+ target: "opacityLegendSymbols",
206
+ property: "fill",
207
+ value: config.symbol.fill
208
+ })
209
+ .editGraphics({
210
+ target: "opacityLegendSymbols",
211
+ property: "opacity",
212
+ value: opacities
213
+ })
214
+ .editGraphics({
215
+ target: "opacityLegendLabels",
216
+ property: "length",
217
+ value: layout.values.length
218
+ })
219
+ .editGraphics({
220
+ target: "opacityLegendLabels",
221
+ property: "x",
222
+ value: layout.labels.map(label => label.x)
223
+ })
224
+ .editGraphics({
225
+ target: "opacityLegendLabels",
226
+ property: "y",
227
+ value: layout.labels.map(label => label.y)
228
+ })
229
+ .editGraphics({
230
+ target: "opacityLegendLabels",
231
+ property: "text",
232
+ value: formatContinuousValues(
233
+ layout.values,
234
+ scale.domain,
235
+ "quantitative"
236
+ )
237
+ });
238
+ next = editLegendBackground(
239
+ next,
240
+ "opacityLegendBackground",
241
+ layout.background,
242
+ config.border
243
+ );
244
+ if (config.symbol.stroke !== undefined) {
245
+ next = next.editGraphics({
246
+ target: "opacityLegendSymbols",
247
+ property: "stroke",
248
+ value: config.symbol.stroke
249
+ });
250
+ }
251
+ if (config.symbol.strokeWidth !== undefined) {
252
+ next = next.editGraphics({
253
+ target: "opacityLegendSymbols",
254
+ property: "strokeWidth",
255
+ value: config.symbol.strokeWidth
256
+ });
257
+ }
258
+ next = styleContinuousText(
259
+ next,
260
+ "opacityLegendLabels",
261
+ config.labels,
262
+ { align: layout.labels[0].align }
263
+ );
264
+ if (config.titleVisible === false) return next;
265
+ next = next
266
+ .editGraphics({
267
+ target: "opacityLegendTitle",
268
+ property: "x",
269
+ value: layout.title.x
270
+ })
271
+ .editGraphics({
272
+ target: "opacityLegendTitle",
273
+ property: "y",
274
+ value: layout.title.y
275
+ })
276
+ .editGraphics({
277
+ target: "opacityLegendTitle",
278
+ property: "text",
279
+ value: config.title
280
+ });
281
+ return styleContinuousText(
282
+ next,
283
+ "opacityLegendTitle",
284
+ config.titleStyle,
285
+ { align: layout.title.align }
286
+ );
287
+ }
288
+ );
289
+
290
+ export const createOpacityLegend = action(
291
+ {
292
+ op: "createOpacityLegend",
293
+ description: "Create a field-opacity sample legend."
294
+ },
295
+ function (args = {}) {
296
+ const config = normalizeContinuousLegend(args, "opacity");
297
+ if (args.channels !== undefined && (
298
+ !Array.isArray(args.channels) ||
299
+ args.channels.length !== 1 ||
300
+ args.channels[0] !== "opacity"
301
+ )) {
302
+ throw new Error('Opacity legend requires channels: ["opacity"].');
303
+ }
304
+ config.symbol = normalizeOpacitySymbol(args.symbol);
305
+ config.titleVisible = true;
306
+ const resolved = resolveOpacityConfig(this, config);
307
+ resolveOpacityLayout(this, resolved.config, resolved.scale);
308
+ if (this.graphicSpec.objects.opacityLegendSymbols !== undefined) {
309
+ throw new Error(
310
+ "createOpacityLegend requires a missing opacity legend."
311
+ );
312
+ }
313
+ let next = this
314
+ .editSemantic({
315
+ property: "guide.legend.opacity.scale",
316
+ value: resolved.encoding.scale
317
+ })
318
+ .editSemantic({
319
+ property: "guide.legend.opacity.title",
320
+ value: resolved.config.title
321
+ })
322
+ ._withLegendConfig("opacity", resolved.config);
323
+ if (resolved.config.border !== false) {
324
+ next = next.createGraphics({
325
+ id: "opacityLegendBackground",
326
+ type: "rect",
327
+ ...resolveLegendGraphicPlacement(next)
328
+ });
329
+ }
330
+ return next
331
+ .createGraphics({
332
+ id: "opacityLegendSymbols",
333
+ type: "circle",
334
+ length: 0,
335
+ ...resolveLegendGraphicPlacement(next),
336
+ ...(resolved.config.border === false
337
+ ? {}
338
+ : { after: "opacityLegendBackground" })
339
+ })
340
+ .createGraphics({
341
+ id: "opacityLegendLabels",
342
+ type: "text",
343
+ length: 0,
344
+ ...resolveLegendGraphicPlacement(next)
345
+ })
346
+ .createGraphics({
347
+ id: "opacityLegendTitle",
348
+ type: "text",
349
+ ...resolveLegendGraphicPlacement(next)
350
+ })
351
+ .rematerializeOpacityLegend();
352
+ }
353
+ );
354
+
355
+ export const removeOpacityLegend = action(
356
+ {
357
+ op: "removeOpacityLegend",
358
+ description: "Remove a field-opacity legend after switching to constant opacity."
359
+ },
360
+ function (args = {}) {
361
+ validateKeys(args, [], "removeOpacityLegend");
362
+ if (this.guideConfigs.legend?.opacity === undefined) return this;
363
+ const targets = [
364
+ "opacityLegendBackground",
365
+ "opacityLegendSymbols",
366
+ "opacityLegendLabels",
367
+ "opacityLegendTitle"
368
+ ];
369
+ let next = this.editSemantic({
370
+ property: "guide.legend.opacity",
371
+ remove: true
372
+ });
373
+ for (const target of targets) {
374
+ if (next.graphicSpec.objects[target] !== undefined) {
375
+ next = next.editGraphics({ target, remove: true });
376
+ }
377
+ }
378
+ return next._withoutMaterializationConfig([
379
+ "guides", "legend", "opacity"
380
+ ]);
381
+ }
382
+ );