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,445 @@
1
+ import { isPlainObject } from "../../../../core/immutable.js";
2
+ import { noOptions } from "../../../../core/validation.js";
3
+ import { mapOrdinalValues } from "../../../../grammar/scales.js";
4
+ import { resolveGraphicBounds } from "../../../../layout/canvas.js";
5
+ import { unionConcreteGraphicBounds } from "../../../../grammar/schemas/graphicBounds.js";
6
+ import { DEFAULT_COLORS } from "../../../../theme/defaults.js";
7
+ import {
8
+ alignLegendStart,
9
+ measureLegendSymbolHeight,
10
+ measureLegendTextWidth,
11
+ resolveLegendGrid
12
+ } from "../../../../layout/legend.js";
13
+
14
+ export function activeConfig(program) {
15
+ const entries = ["series", "color"]
16
+ .filter(kind => program.guideConfigs.legend?.[kind] !== undefined)
17
+ .map(kind => [kind, program.guideConfigs.legend[kind]]);
18
+ if (entries.length !== 1) {
19
+ throw new Error("Legend component requires one categorical legend config.");
20
+ }
21
+ return { kind: entries[0][0], config: entries[0][1] };
22
+ }
23
+
24
+ function prefix(config) {
25
+ return config.kind === "series" ? "seriesLegend" : "colorLegend";
26
+ }
27
+
28
+ export function symbolGraphic(config, type) {
29
+ const onlyDefault = config.symbol.layers.length === 1 &&
30
+ ((config.kind === "series" && type === "line") ||
31
+ (config.kind === "color" && type === "swatch"));
32
+ if (onlyDefault) return `${prefix(config)}Symbols`;
33
+ const suffix = { line: "Lines", point: "Points", swatch: "Swatches" }[type];
34
+ return `${prefix(config)}Symbol${suffix}`;
35
+ }
36
+
37
+ export function graphic(config, component) {
38
+ return `${prefix(config)}${component}`;
39
+ }
40
+
41
+ export function symbolWidth(config) {
42
+ return Math.max(...config.symbol.layers.map(layer => {
43
+ if (layer.type === "line") return layer.length;
44
+ if (layer.type === "point") return layer.size * 2;
45
+ return layer.width;
46
+ }));
47
+ }
48
+
49
+ function leftGuideBoundary(program, bounds) {
50
+ const ids = ["yAxisLine", "yAxisTicks", "yAxisLabels", "yAxisTitle"]
51
+ .filter(id => program.graphicSpec.objects[id] !== undefined);
52
+ const occupied = unionConcreteGraphicBounds(program.graphicSpec, ids);
53
+ return occupied === undefined ? bounds.x : Math.min(bounds.x, occupied.left);
54
+ }
55
+
56
+ function resolveLeftSizeMetrics(program, sizeConfig) {
57
+ if (sizeConfig === undefined) return undefined;
58
+ const scale = program.resolvedScales[sizeConfig.scale];
59
+ if (scale?.type !== "linear") {
60
+ throw new Error(`Legend requires resolved linear scale "${sizeConfig.scale}".`);
61
+ }
62
+ const values = Array.from(
63
+ { length: sizeConfig.count },
64
+ (_, index) => scale.domain[0] +
65
+ index / (sizeConfig.count - 1) * (scale.domain[1] - scale.domain[0])
66
+ );
67
+ const labels = values.map(value => String(+value.toPrecision(3)));
68
+ const maximumRadius = Math.sqrt(Math.max(...scale.range) / Math.PI);
69
+ return { scale, values, labels, maximumRadius };
70
+ }
71
+
72
+ function resolveLeftLayout(program, bounds, canvas, config, width, count) {
73
+ const sizeConfig = program.guideConfigs.legend?.size;
74
+ const size = resolveLeftSizeMetrics(program, sizeConfig);
75
+ const padding = config.border === false ? 0 : config.border.padding;
76
+ const categoricalWidth = Math.max(
77
+ config.titleVisible === false ? 0 : String(config.title).length * 6,
78
+ ...config.domain.map(value =>
79
+ width + config.labels.offset + measureLegendTextWidth(value)
80
+ )
81
+ );
82
+ const sizeWidth = size === undefined
83
+ ? 0
84
+ : Math.max(
85
+ String(sizeConfig.title).length * 6,
86
+ ...size.labels.map(label => 44 + measureLegendTextWidth(label))
87
+ );
88
+ const contentWidth = Math.max(categoricalWidth, sizeWidth);
89
+ const occupiedRight = bounds.x - config.offset;
90
+ const contentRight = occupiedRight - padding;
91
+ const start = contentRight - contentWidth;
92
+ if (start < padding) {
93
+ throw new Error("Legend layout requires more left-margin space.");
94
+ }
95
+ if (occupiedRight >= leftGuideBoundary(program, bounds)) {
96
+ throw new Error("Left legend and y-axis guides require more left-margin space.");
97
+ }
98
+ const symbolX = Array(count).fill(start);
99
+ const itemY = Array.from(
100
+ { length: count },
101
+ (_, index) => bounds.y + 52 + index * config.itemGap
102
+ );
103
+ const labelX = symbolX.map(value => value + width + config.labels.offset);
104
+ const titleX = start;
105
+ const titleY = bounds.y + 20;
106
+ const sizeTitleY = bounds.y + 56 + count * 34 + 22;
107
+ const sizeItemY = size?.values.map((_, index) => sizeTitleY + 34 + index * 40);
108
+ const sizeSymbolX = size?.values.map(() => start + 16);
109
+ const sizeLabelX = size?.values.map(() => start + 44);
110
+ const categoricalTop = config.titleVisible === false
111
+ ? itemY[0] - Math.max(
112
+ config.labels.fontSize,
113
+ measureLegendSymbolHeight(config)
114
+ ) / 2
115
+ : titleY - config.titleStyle.fontSize / 2;
116
+ const sizeBottom = size === undefined
117
+ ? -Infinity
118
+ : sizeItemY.at(-1) + Math.max(size.maximumRadius, config.labels.fontSize / 2);
119
+ const categoricalBottom = itemY.at(-1) +
120
+ Math.max(config.labels.fontSize, measureLegendSymbolHeight(config)) / 2;
121
+ const blockTop = Math.min(categoricalTop, sizeTitleY - config.titleStyle.fontSize / 2);
122
+ const blockBottom = Math.max(categoricalBottom, sizeBottom);
123
+ if (blockTop < 0 || blockBottom > canvas.properties.height) {
124
+ throw new Error("Legend layout requires more vertical Canvas space.");
125
+ }
126
+ let background;
127
+ if (config.border !== false) {
128
+ const backgroundTop = Math.floor(
129
+ blockTop - config.border.padding - config.border.lineWidth
130
+ );
131
+ const backgroundBottom = Math.ceil(blockBottom + config.border.padding);
132
+ background = {
133
+ x: start - config.border.padding,
134
+ y: backgroundTop,
135
+ width: occupiedRight - (start - config.border.padding),
136
+ height: backgroundBottom - backgroundTop
137
+ };
138
+ if (
139
+ background.x < 0 || background.y < 0 ||
140
+ background.x + background.width > canvas.properties.width ||
141
+ background.y + background.height > canvas.properties.height
142
+ ) {
143
+ throw new Error("Legend background requires more left-margin space.");
144
+ }
145
+ }
146
+ return {
147
+ symbolX,
148
+ itemY,
149
+ labelX,
150
+ titleX,
151
+ titleY,
152
+ background,
153
+ blockTop,
154
+ blockBottom,
155
+ size: size === undefined ? undefined : {
156
+ ...size,
157
+ titleX: start,
158
+ titleY: sizeTitleY,
159
+ itemY: sizeItemY,
160
+ symbolX: sizeSymbolX,
161
+ labelX: sizeLabelX
162
+ }
163
+ };
164
+ }
165
+
166
+ function resolveTopLayout(program, bounds, canvas, config, width, count) {
167
+ const { cells, columnWidths, gridWidth, gridHeight, rowHeight } =
168
+ resolveLegendGrid(config, width, count);
169
+ const titleWidth = config.title.length * 7;
170
+ const titleGap = config.titlePosition === "left" ? 20 : 12;
171
+ const totalWidth = config.titlePosition === "left"
172
+ ? titleWidth + titleGap + gridWidth
173
+ : Math.max(titleWidth, gridWidth);
174
+ const start = alignLegendStart(bounds, totalWidth, config.align);
175
+ if (start < 0 || start + totalWidth > canvas.properties.width) {
176
+ throw new Error("Legend layout requires more horizontal Canvas space.");
177
+ }
178
+ const blockBottom = bounds.y - config.offset;
179
+ const gridStart = config.titlePosition === "left"
180
+ ? start + titleWidth + titleGap
181
+ : start + (totalWidth - gridWidth) / 2;
182
+ const gridTop = blockBottom - gridHeight;
183
+ const blockTop = config.titlePosition === "left"
184
+ ? Math.min(gridTop, blockBottom / 2 + gridTop / 2 - config.titleStyle.fontSize / 2)
185
+ : gridTop - titleGap - config.titleStyle.fontSize;
186
+ if (blockTop < 0) {
187
+ throw new Error("Legend layout requires more top-margin space.");
188
+ }
189
+ const titleGraphic = program.graphicSpec.objects.chartSubtitle ??
190
+ program.graphicSpec.objects.chartTitle;
191
+ if (
192
+ titleGraphic?.type === "text" &&
193
+ titleGraphic.properties.y + titleGraphic.properties.fontSize / 2 >= blockTop
194
+ ) {
195
+ throw new Error("Top legend and chart title require more top-margin space.");
196
+ }
197
+ const columnX = [];
198
+ let cursor = gridStart;
199
+ for (const columnWidth of columnWidths) {
200
+ columnX.push(cursor);
201
+ cursor += columnWidth + config.itemGap;
202
+ }
203
+ const symbolX = cells.map(cell => columnX[cell.column]);
204
+ const labelX = symbolX.map(value => value + width + config.labels.offset);
205
+ const itemY = cells.map(cell =>
206
+ gridTop + rowHeight / 2 + cell.row * (rowHeight + config.itemGap)
207
+ );
208
+ const titleX = config.titlePosition === "left"
209
+ ? start
210
+ : start + totalWidth / 2;
211
+ const titleY = config.titlePosition === "left"
212
+ ? gridTop + gridHeight / 2
213
+ : gridTop - titleGap - config.titleStyle.fontSize / 2;
214
+ let background;
215
+ if (config.border !== false) {
216
+ const x = start - config.border.padding;
217
+ const y = blockTop - config.border.padding;
218
+ const backgroundWidth = totalWidth + config.border.padding * 2;
219
+ const height = blockBottom - blockTop + config.border.padding * 2;
220
+ if (x < 0 || y < 0 || x + backgroundWidth > canvas.properties.width) {
221
+ throw new Error("Legend background requires more top or horizontal space.");
222
+ }
223
+ background = { x, y, width: backgroundWidth, height };
224
+ }
225
+ return {
226
+ symbolX,
227
+ itemY,
228
+ labelX,
229
+ titleX,
230
+ titleY,
231
+ background,
232
+ blockTop,
233
+ blockBottom
234
+ };
235
+ }
236
+
237
+ function resolveBottomLayout(program, bounds, canvas, config, width, count) {
238
+ const { cells, columnWidths, gridWidth, gridHeight, rowHeight } =
239
+ resolveLegendGrid(config, width, count);
240
+ const titleWidth = config.title.length * 7;
241
+ const titleGap = config.titlePosition === "left" ? 20 : 12;
242
+ const totalWidth = config.titlePosition === "left"
243
+ ? titleWidth + titleGap + gridWidth
244
+ : Math.max(titleWidth, gridWidth);
245
+ const start = alignLegendStart(bounds, totalWidth, config.align);
246
+ if (start < 0 || start + totalWidth > canvas.properties.width) {
247
+ throw new Error("Legend layout requires more horizontal Canvas space.");
248
+ }
249
+
250
+ const blockTop = bounds.y + bounds.height + config.offset;
251
+ const gridStart = config.titlePosition === "left"
252
+ ? start + titleWidth + titleGap
253
+ : start + (totalWidth - gridWidth) / 2;
254
+ const gridTop = config.titlePosition === "left"
255
+ ? blockTop
256
+ : blockTop + config.titleStyle.fontSize + titleGap;
257
+ const blockBottom = gridTop + gridHeight;
258
+ const titleX = config.titlePosition === "left"
259
+ ? start
260
+ : start + totalWidth / 2;
261
+ const titleY = config.titlePosition === "left"
262
+ ? gridTop + gridHeight / 2
263
+ : blockTop + config.titleStyle.fontSize / 2;
264
+
265
+ const axisTitle = program.graphicSpec.objects.xAxisTitle;
266
+ const occupiedTop = config.border === false
267
+ ? blockTop
268
+ : blockTop - config.border.padding;
269
+ if (
270
+ axisTitle?.type === "text" &&
271
+ axisTitle.properties.y + axisTitle.properties.fontSize / 2 >= occupiedTop
272
+ ) {
273
+ throw new Error("Bottom legend and x-axis title require more bottom-margin space.");
274
+ }
275
+ const occupiedBottom = config.border === false
276
+ ? blockBottom
277
+ : blockBottom + config.border.padding;
278
+ if (occupiedTop < 0 || occupiedBottom > canvas.properties.height) {
279
+ throw new Error("Legend layout requires more bottom-margin space.");
280
+ }
281
+
282
+ const columnX = [];
283
+ let cursor = gridStart;
284
+ for (const columnWidth of columnWidths) {
285
+ columnX.push(cursor);
286
+ cursor += columnWidth + config.itemGap;
287
+ }
288
+ const symbolX = cells.map(cell => columnX[cell.column]);
289
+ const labelX = symbolX.map(value => value + width + config.labels.offset);
290
+ const itemY = cells.map(cell =>
291
+ gridTop + rowHeight / 2 + cell.row * (rowHeight + config.itemGap)
292
+ );
293
+ let background;
294
+ if (config.border !== false) {
295
+ background = {
296
+ x: start - config.border.padding,
297
+ y: blockTop - config.border.padding,
298
+ width: totalWidth + config.border.padding * 2,
299
+ height: blockBottom - blockTop + config.border.padding * 2
300
+ };
301
+ if (
302
+ background.x < 0 ||
303
+ background.y < 0 ||
304
+ background.x + background.width > canvas.properties.width ||
305
+ background.y + background.height > canvas.properties.height
306
+ ) {
307
+ throw new Error("Legend background requires more bottom or horizontal space.");
308
+ }
309
+ }
310
+ return {
311
+ symbolX,
312
+ itemY,
313
+ labelX,
314
+ titleX,
315
+ titleY,
316
+ background,
317
+ blockTop,
318
+ blockBottom
319
+ };
320
+ }
321
+
322
+ function resolveCompactBottomLayout(bounds, canvas, config, width, count) {
323
+ const labels = config.domain.map(String);
324
+ const itemWidths = labels.map(
325
+ label => width + config.labels.offset + label.length * 7
326
+ );
327
+ const totalWidth = itemWidths.reduce((sum, value) => sum + value, 0) +
328
+ config.itemGap * Math.max(0, count - 1);
329
+ const start = config.align === "center"
330
+ ? (canvas.properties.width - totalWidth) / 2
331
+ : alignLegendStart(bounds, totalWidth, config.align);
332
+ if (start < 0 || start + totalWidth > canvas.properties.width) {
333
+ throw new Error("Legend layout requires more horizontal Canvas space.");
334
+ }
335
+ const symbolX = [];
336
+ const labelX = [];
337
+ let cursor = start;
338
+ for (let index = 0; index < count; index += 1) {
339
+ symbolX.push(cursor);
340
+ labelX.push(cursor + width + config.labels.offset);
341
+ cursor += itemWidths[index] + config.itemGap;
342
+ }
343
+ const itemY = Array(count).fill(canvas.properties.height - 28);
344
+ const titleX = start + totalWidth / 2;
345
+ const titleY = canvas.properties.height - 52;
346
+ if (titleY <= bounds.y + bounds.height) {
347
+ throw new Error("Legend layout requires more bottom-margin space.");
348
+ }
349
+ let background;
350
+ if (config.border !== false) {
351
+ const maxHeight = Math.max(
352
+ config.labels.fontSize,
353
+ measureLegendSymbolHeight(config)
354
+ );
355
+ const x = start - config.border.padding;
356
+ const y = titleY - config.titleStyle.fontSize / 2 - config.border.padding;
357
+ background = {
358
+ x,
359
+ y,
360
+ width: totalWidth + config.border.padding * 2,
361
+ height: itemY[0] + maxHeight / 2 + config.border.padding - y
362
+ };
363
+ }
364
+ return { symbolX, itemY, labelX, titleX, titleY, background };
365
+ }
366
+
367
+ export function resolveLayout(program, config) {
368
+ const bounds = resolveGraphicBounds(program);
369
+ const canvas = program.graphicSpec.objects.canvas;
370
+ if (
371
+ bounds === undefined ||
372
+ ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) ||
373
+ canvas?.type !== "canvas" ||
374
+ !Number.isFinite(canvas.properties.width) ||
375
+ !Number.isFinite(canvas.properties.height)
376
+ ) {
377
+ throw new Error("Legend layout requires Canvas bounds, width, and height.");
378
+ }
379
+
380
+ const count = config.domain.length;
381
+ const width = symbolWidth(config);
382
+ if (config.position === "right") {
383
+ const symbolX = Array(count).fill(bounds.x + bounds.width + 30);
384
+ const itemY = Array.from(
385
+ { length: count },
386
+ (_, index) => bounds.y + 52 + index * config.itemGap
387
+ );
388
+ const labelX = symbolX.map(value => value + width + config.labels.offset);
389
+ if (labelX.some(value => value >= canvas.properties.width)) {
390
+ throw new Error("Legend layout requires more right-margin space.");
391
+ }
392
+ const titleX = symbolX[0];
393
+ const titleY = bounds.y + 20;
394
+ let background;
395
+ if (config.border !== false) {
396
+ const x = symbolX[0] - config.border.padding;
397
+ const y = bounds.y + 8;
398
+ const backgroundWidth =
399
+ canvas.properties.width - x - config.border.padding;
400
+ const height = itemY.at(-1) - y + config.border.padding;
401
+ if (backgroundWidth <= 0 || height <= 0) {
402
+ throw new Error("Legend background requires positive width and height.");
403
+ }
404
+ background = { x, y, width: backgroundWidth, height };
405
+ }
406
+ return { symbolX, itemY, labelX, titleX, titleY, background };
407
+ }
408
+
409
+ if (config.position === "left") {
410
+ return resolveLeftLayout(program, bounds, canvas, config, width, count);
411
+ }
412
+
413
+ if (config.position === "top") {
414
+ return resolveTopLayout(program, bounds, canvas, config, width, count);
415
+ }
416
+
417
+ if (config.bottomGrid !== true) {
418
+ return resolveCompactBottomLayout(bounds, canvas, config, width, count);
419
+ }
420
+ return resolveBottomLayout(program, bounds, canvas, config, width, count);
421
+ }
422
+
423
+ export function resolveAppearance(program, config) {
424
+ let colors = config.domain.map(() => DEFAULT_COLORS.mark);
425
+ let dashes = config.domain.map(() => []);
426
+ let shapes = config.domain.map(() => "circle");
427
+ for (let index = 0; index < config.channels.length; index += 1) {
428
+ const scale = program.resolvedScales[config.scales[index]];
429
+ const values = mapOrdinalValues(config.domain, scale.domain, scale.range);
430
+ if (config.channels[index] === "color") colors = values;
431
+ if (config.channels[index] === "strokeDash") dashes = values;
432
+ if (config.channels[index] === "shape") shapes = values;
433
+ }
434
+ return { colors, dashes, shapes };
435
+ }
436
+
437
+ export { noOptions };
438
+
439
+ export function layerFor(config, type) {
440
+ const layer = config.symbol.layers.find(item => item.type === type);
441
+ if (layer === undefined) {
442
+ throw new Error(`Legend recipe does not contain a ${type} layer.`);
443
+ }
444
+ return layer;
445
+ }
@@ -0,0 +1,188 @@
1
+ import { isPlainObject } from "../../../../core/immutable.js";
2
+ import { CATEGORICAL_LEGEND_CHANNELS } from "../../../../core/vocabulary.js";
3
+ import { normalizeRecipe } from "./recipes.js";
4
+ import {
5
+ nonEmptyString,
6
+ nonNegative,
7
+ positive,
8
+ validateFontWeight,
9
+ validateKeys,
10
+ validateObject
11
+ } from "./validation.js";
12
+ import { DEFAULT_COLORS, DEFAULT_FONT_FAMILY } from
13
+ "../../../../theme/defaults.js";
14
+
15
+ export const CHANNELS = CATEGORICAL_LEGEND_CHANNELS;
16
+ const OPTIONS = Object.freeze([
17
+ "target",
18
+ "channels",
19
+ "position",
20
+ "align",
21
+ "direction",
22
+ "columns",
23
+ "offset",
24
+ "titlePosition",
25
+ "title",
26
+ "symbol",
27
+ "labels",
28
+ "titleStyle",
29
+ "itemGap",
30
+ "border"
31
+ ]);
32
+ const TEXT_OPTIONS = Object.freeze([
33
+ "offset",
34
+ "color",
35
+ "fontSize",
36
+ "fontFamily",
37
+ "fontWeight"
38
+ ]);
39
+ const TITLE_OPTIONS = Object.freeze([
40
+ "color",
41
+ "fontSize",
42
+ "fontFamily",
43
+ "fontWeight"
44
+ ]);
45
+ const BORDER_OPTIONS = Object.freeze([
46
+ "color",
47
+ "lineWidth",
48
+ "padding",
49
+ "background"
50
+ ]);
51
+ const COMMON_DEFAULTS = Object.freeze({
52
+ labels: Object.freeze({
53
+ color: DEFAULT_COLORS.text,
54
+ fontSize: 12,
55
+ fontFamily: DEFAULT_FONT_FAMILY,
56
+ fontWeight: "normal"
57
+ }),
58
+ titleStyle: Object.freeze({
59
+ color: DEFAULT_COLORS.text,
60
+ fontSize: 13,
61
+ fontFamily: DEFAULT_FONT_FAMILY,
62
+ fontWeight: 600
63
+ }),
64
+ border: Object.freeze({
65
+ color: DEFAULT_COLORS.border,
66
+ lineWidth: 1,
67
+ padding: 12,
68
+ background: "transparent"
69
+ })
70
+ });
71
+
72
+ function normalizeBorder(border) {
73
+ if (border === undefined || border === false) return false;
74
+ if (border !== true) {
75
+ validateObject(border, BORDER_OPTIONS, "createLegend.border");
76
+ }
77
+ const normalized = {
78
+ ...COMMON_DEFAULTS.border,
79
+ ...(border === true ? {} : border)
80
+ };
81
+ nonEmptyString(normalized.color, "Legend border color");
82
+ nonEmptyString(normalized.background, "Legend border background");
83
+ nonNegative(normalized.lineWidth, "Legend border lineWidth");
84
+ nonNegative(normalized.padding, "Legend border padding");
85
+ return normalized;
86
+ }
87
+
88
+ export function normalizeOptions(args, kind) {
89
+ if (!isPlainObject(args)) {
90
+ throw new TypeError("createLegend options must be a plain object.");
91
+ }
92
+ validateKeys(args, OPTIONS, "createLegend");
93
+ if (Object.hasOwn(args, "labels")) {
94
+ validateObject(args.labels, TEXT_OPTIONS, "createLegend.labels");
95
+ }
96
+ if (Object.hasOwn(args, "titleStyle")) {
97
+ validateObject(
98
+ args.titleStyle,
99
+ TITLE_OPTIONS,
100
+ "createLegend.titleStyle"
101
+ );
102
+ }
103
+
104
+ const defaults = {
105
+ position: "right",
106
+ align: "center",
107
+ offset: kind === "series" ? 10 : 8
108
+ };
109
+ const labels = {
110
+ ...COMMON_DEFAULTS.labels,
111
+ offset: defaults.offset,
112
+ ...(args.labels ?? {})
113
+ };
114
+ const titleStyle = {
115
+ ...COMMON_DEFAULTS.titleStyle,
116
+ ...(args.titleStyle ?? {})
117
+ };
118
+ const position = args.position ?? defaults.position;
119
+ const align = args.align ?? defaults.align;
120
+ const direction = args.direction ?? (position === "left" ? "vertical" : "horizontal");
121
+ const columns = args.columns;
122
+ const offset = args.offset ?? 8;
123
+ const titlePosition = args.titlePosition ?? "top";
124
+ const itemGap = args.itemGap ?? (["right", "left"].includes(position)
125
+ ? 28
126
+ : position === "top" ? 24 : 20);
127
+ const bottomGrid = position !== "bottom" || [
128
+ "columns",
129
+ "direction",
130
+ "offset",
131
+ "titlePosition",
132
+ "itemGap"
133
+ ].some(key => Object.hasOwn(args, key));
134
+
135
+ if (!["right", "left", "bottom", "top"].includes(position)) {
136
+ throw new Error(`Unsupported legend position "${position}".`);
137
+ }
138
+ if (!["left", "center", "right"].includes(align)) {
139
+ throw new Error(`Unsupported legend alignment "${align}".`);
140
+ }
141
+ if (["right", "left"].includes(position) && align !== "center") {
142
+ throw new Error("Side legends currently require center alignment.");
143
+ }
144
+ if (!["horizontal", "vertical"].includes(direction)) {
145
+ throw new Error(`Unsupported legend direction "${direction}".`);
146
+ }
147
+ if (position === "left" && direction !== "vertical") {
148
+ throw new Error("Left legends currently require vertical direction.");
149
+ }
150
+ if (position === "left" && columns !== undefined) {
151
+ throw new Error("Left legends do not accept columns.");
152
+ }
153
+ if (columns !== undefined && (!Number.isInteger(columns) || columns <= 0)) {
154
+ throw new RangeError("Legend columns must be a positive integer.");
155
+ }
156
+ nonNegative(offset, "Legend offset");
157
+ if (!["top", "left"].includes(titlePosition)) {
158
+ throw new Error(`Unsupported legend titlePosition "${titlePosition}".`);
159
+ }
160
+ nonNegative(labels.offset, "Legend label offset");
161
+ nonEmptyString(labels.color, "Legend label color");
162
+ positive(labels.fontSize, "Legend label fontSize");
163
+ nonEmptyString(labels.fontFamily, "Legend label fontFamily");
164
+ validateFontWeight(labels.fontWeight, "Legend label fontWeight");
165
+ nonEmptyString(titleStyle.color, "Legend title color");
166
+ positive(titleStyle.fontSize, "Legend title fontSize");
167
+ nonEmptyString(titleStyle.fontFamily, "Legend title fontFamily");
168
+ validateFontWeight(titleStyle.fontWeight, "Legend title fontWeight");
169
+ positive(itemGap, "Legend itemGap");
170
+
171
+ return {
172
+ target: args.target,
173
+ channels: args.channels,
174
+ position,
175
+ align,
176
+ direction,
177
+ columns,
178
+ offset,
179
+ titlePosition,
180
+ title: args.title,
181
+ symbol: normalizeRecipe(args.symbol, kind),
182
+ labels,
183
+ titleStyle,
184
+ itemGap,
185
+ bottomGrid,
186
+ border: normalizeBorder(args.border)
187
+ };
188
+ }