ggaction 0.0.12 → 0.0.15

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 (378) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +20 -11
  3. package/knowledge/action-card.schema.json +1 -1
  4. package/knowledge/action-cards.json +1 -1
  5. package/knowledge/action-cards.schema.json +1 -1
  6. package/knowledge/extension-authoring.md +2 -1
  7. package/knowledge/intent-taxonomy.json +1 -1
  8. package/knowledge/mcp-resources.json +1 -1
  9. package/knowledge/task-resolver.js +1 -1
  10. package/package.json +10 -3
  11. package/src/actions/basic.js +4 -2
  12. package/src/actions/boxPlots/components.js +14 -4
  13. package/src/actions/boxPlots/edit.js +31 -5
  14. package/src/actions/boxPlots/materialize.js +13 -4
  15. package/src/actions/boxPlots/options.js +48 -16
  16. package/src/actions/canvas/actions.js +3 -1
  17. package/src/actions/canvas/fitting.js +211 -0
  18. package/src/actions/canvas/index.js +2 -0
  19. package/src/actions/categoryOrder/index.js +18 -6
  20. package/src/actions/charts/area.js +51 -0
  21. package/src/actions/charts/bar.js +13 -6
  22. package/src/actions/charts/beeswarm.js +70 -0
  23. package/src/actions/charts/density.js +48 -0
  24. package/src/actions/charts/ecdf.js +186 -0
  25. package/src/actions/charts/endpoints.js +473 -0
  26. package/src/actions/charts/heatmap.js +4 -3
  27. package/src/actions/charts/histogram.js +5 -4
  28. package/src/actions/charts/horizon.js +73 -0
  29. package/src/actions/charts/index.js +37 -0
  30. package/src/actions/charts/interval-regression.js +172 -0
  31. package/src/actions/charts/line.js +8 -6
  32. package/src/actions/charts/parallel.js +8 -2
  33. package/src/actions/charts/pie.js +52 -0
  34. package/src/actions/charts/polar.js +134 -0
  35. package/src/actions/charts/radar.js +305 -0
  36. package/src/actions/charts/radial.js +50 -0
  37. package/src/actions/charts/raincloud.js +422 -0
  38. package/src/actions/charts/rug-strip.js +283 -0
  39. package/src/actions/charts/scatter.js +11 -3
  40. package/src/actions/charts/shared.js +69 -10
  41. package/src/actions/composition/actions.js +129 -3
  42. package/src/actions/coordinates/actions.js +1 -1
  43. package/src/actions/coordinates/edit.js +76 -0
  44. package/src/actions/data/bin.js +29 -0
  45. package/src/actions/data/bin2d.js +57 -185
  46. package/src/actions/data/complete.js +26 -0
  47. package/src/actions/data/computed.js +24 -0
  48. package/src/actions/data/create.js +2 -2
  49. package/src/actions/data/density.js +12 -20
  50. package/src/actions/data/derived.js +111 -15
  51. package/src/actions/data/ecdf.js +38 -0
  52. package/src/actions/data/edit.js +539 -0
  53. package/src/actions/data/filter.js +226 -30
  54. package/src/actions/data/fold.js +24 -0
  55. package/src/actions/data/impute.js +26 -0
  56. package/src/actions/data/index.js +38 -0
  57. package/src/actions/data/interval.js +2 -0
  58. package/src/actions/data/intervalEdit.js +86 -4
  59. package/src/actions/data/normalize.js +27 -0
  60. package/src/actions/data/regression.js +19 -21
  61. package/src/actions/data/revisionBindings.js +145 -0
  62. package/src/actions/data/shared.js +20 -4
  63. package/src/actions/data/stack.js +26 -0
  64. package/src/actions/data/summary.js +26 -0
  65. package/src/actions/data/timeUnit.js +4 -1
  66. package/src/actions/data/window.js +1 -1
  67. package/src/actions/encodings/appearance.js +32 -13
  68. package/src/actions/encodings/barWidth.js +8 -0
  69. package/src/actions/encodings/basic.js +2 -0
  70. package/src/actions/encodings/channels.js +415 -0
  71. package/src/actions/encodings/color/continuous.js +19 -59
  72. package/src/actions/encodings/color/index.js +30 -23
  73. package/src/actions/encodings/color/layout.js +3 -97
  74. package/src/actions/encodings/color/policy.js +5 -8
  75. package/src/actions/encodings/density/resolve.js +37 -5
  76. package/src/actions/encodings/density.js +124 -34
  77. package/src/actions/encodings/histogram.js +3 -2
  78. package/src/actions/encodings/horizon/resolve.js +8 -4
  79. package/src/actions/encodings/horizon.js +18 -8
  80. package/src/actions/encodings/index.js +6 -0
  81. package/src/actions/encodings/offset.js +30 -29
  82. package/src/actions/encodings/parallel.js +2 -8
  83. package/src/actions/encodings/position/apply.js +63 -11
  84. package/src/actions/encodings/position/index.js +52 -6
  85. package/src/actions/encodings/position/policies/arc.js +14 -0
  86. package/src/actions/encodings/position/policies/area.js +4 -42
  87. package/src/actions/encodings/position/policies/bar.js +57 -13
  88. package/src/actions/encodings/position/resolve.js +88 -26
  89. package/src/actions/encodings/ranged.js +123 -97
  90. package/src/actions/encodings/remove.js +44 -34
  91. package/src/actions/encodings/ruleAppearance.js +24 -32
  92. package/src/actions/encodings/seriesLayout.js +105 -0
  93. package/src/actions/encodings/shared.js +107 -37
  94. package/src/actions/encodings/stroke.js +226 -0
  95. package/src/actions/encodings/strokeDash.js +9 -6
  96. package/src/actions/encodings/temporal.js +7 -0
  97. package/src/actions/encodings/text.js +30 -15
  98. package/src/actions/errorBands/create.js +41 -8
  99. package/src/actions/errorBands/edit.js +360 -25
  100. package/src/actions/errorBars/create.js +56 -17
  101. package/src/actions/errorBars/edit.js +372 -21
  102. package/src/actions/facets/actions.js +619 -41
  103. package/src/actions/facets/derive.js +139 -25
  104. package/src/actions/gradientPlots/components.js +43 -13
  105. package/src/actions/gradientPlots/edit.js +14 -4
  106. package/src/actions/gradientPlots/materialize.js +8 -8
  107. package/src/actions/gradientPlots/options.js +10 -2
  108. package/src/actions/guides/applicability.js +28 -19
  109. package/src/actions/guides/axes/axes.js +4 -3
  110. package/src/actions/guides/axes/axis.js +18 -18
  111. package/src/actions/guides/axes/components.js +43 -0
  112. package/src/actions/guides/axes/edit.js +16 -51
  113. package/src/actions/guides/axes/labels.js +184 -28
  114. package/src/actions/guides/axes/lines.js +13 -7
  115. package/src/actions/guides/axes/parallel/lifecycle.js +117 -0
  116. package/src/actions/guides/axes/parallel/policy.js +137 -0
  117. package/src/actions/guides/axes/parallel/resolve.js +50 -25
  118. package/src/actions/guides/axes/parallel.js +93 -217
  119. package/src/actions/guides/axes/policy.js +21 -67
  120. package/src/actions/guides/axes/tickGroups.js +15 -8
  121. package/src/actions/guides/axes/ticks.js +11 -6
  122. package/src/actions/guides/axes/titles.js +32 -11
  123. package/src/actions/guides/basic.js +2 -0
  124. package/src/actions/guides/facade.js +106 -0
  125. package/src/actions/guides/facadeAxes.js +200 -0
  126. package/src/actions/guides/facadeLegend.js +63 -0
  127. package/src/actions/guides/grids/grid.js +37 -33
  128. package/src/actions/guides/grids/resolve.js +17 -4
  129. package/src/actions/guides/guides.js +54 -31
  130. package/src/actions/guides/legends/blocks.js +426 -0
  131. package/src/actions/guides/legends/categorical/actions.js +309 -279
  132. package/src/actions/guides/legends/categorical/components.js +21 -17
  133. package/src/actions/guides/legends/categorical/index.js +37 -33
  134. package/src/actions/guides/legends/categorical/layout.js +123 -354
  135. package/src/actions/guides/legends/categorical/options.js +32 -16
  136. package/src/actions/guides/legends/categorical/recipes.js +114 -9
  137. package/src/actions/guides/legends/categorical/resolve.js +30 -15
  138. package/src/actions/guides/legends/categorical/symbols.js +87 -33
  139. package/src/actions/guides/legends/continuous/common.js +99 -26
  140. package/src/actions/guides/legends/continuous/gradient.js +120 -89
  141. package/src/actions/guides/legends/continuous/index.js +8 -2
  142. package/src/actions/guides/legends/continuous/interval.js +79 -95
  143. package/src/actions/guides/legends/continuous/opacity.js +113 -89
  144. package/src/actions/guides/legends/continuous/stroke.js +525 -0
  145. package/src/actions/guides/legends/creation.js +40 -0
  146. package/src/actions/guides/legends/edit.js +321 -90
  147. package/src/actions/guides/legends/focused.js +2 -2
  148. package/src/actions/guides/legends/index.js +2 -0
  149. package/src/actions/guides/legends/lane.js +149 -102
  150. package/src/actions/guides/legends/lifecycle.js +143 -0
  151. package/src/actions/guides/legends/remove.js +43 -78
  152. package/src/actions/guides/legends/sampling.js +140 -0
  153. package/src/actions/guides/legends/size.js +213 -116
  154. package/src/actions/guides/legends/strokeWidth.js +121 -73
  155. package/src/actions/guides/legends/target.js +123 -0
  156. package/src/actions/guides/legends/transition.js +192 -0
  157. package/src/actions/guides/polar/axes/facade.js +81 -66
  158. package/src/actions/guides/polar/axes/labels.js +28 -8
  159. package/src/actions/guides/polar/axes/lines.js +12 -5
  160. package/src/actions/guides/polar/axes/shared.js +22 -0
  161. package/src/actions/guides/polar/axes/ticks.js +6 -5
  162. package/src/actions/guides/polar/axes/titles.js +6 -5
  163. package/src/actions/guides/polar/grids.js +8 -8
  164. package/src/actions/guides/polar/resolve.js +12 -8
  165. package/src/actions/guides/reuse.js +44 -0
  166. package/src/actions/guides/tickValues.js +19 -21
  167. package/src/actions/index.js +15 -2
  168. package/src/actions/intervals/resolve.js +35 -15
  169. package/src/actions/marks/arc/actions.js +78 -20
  170. package/src/actions/marks/area/actions.js +51 -12
  171. package/src/actions/marks/area/materialize.js +22 -3
  172. package/src/actions/marks/bar/create.js +11 -2
  173. package/src/actions/marks/bar/edit.js +20 -3
  174. package/src/actions/marks/bar/materialize.js +33 -9
  175. package/src/actions/marks/index.js +14 -0
  176. package/src/actions/marks/line/actions.js +71 -113
  177. package/src/actions/marks/line/materialize.js +57 -56
  178. package/src/actions/marks/point/create.js +8 -2
  179. package/src/actions/marks/point/edit.js +29 -21
  180. package/src/actions/marks/point/index.js +1 -0
  181. package/src/actions/marks/point/jitter.js +3 -0
  182. package/src/actions/marks/point/materialize.js +154 -16
  183. package/src/actions/marks/point/packing.js +68 -0
  184. package/src/actions/marks/rect/actions.js +33 -12
  185. package/src/actions/marks/references.js +658 -0
  186. package/src/actions/marks/remove.js +87 -4
  187. package/src/actions/marks/rule/actions.js +77 -9
  188. package/src/actions/marks/rule/appearance.js +26 -0
  189. package/src/actions/marks/shared.js +13 -13
  190. package/src/actions/marks/text/actions.js +296 -13
  191. package/src/actions/marks/text/index.js +4 -0
  192. package/src/actions/marks/text/layout.js +156 -8
  193. package/src/actions/marks/text/removal.js +173 -0
  194. package/src/actions/marks/text/selection.js +73 -0
  195. package/src/actions/marks/tick/actions.js +22 -5
  196. package/src/actions/primitives/createGraphics.js +3 -1
  197. package/src/actions/primitives/editGraphics.js +26 -0
  198. package/src/actions/primitives/semantic.js +3 -2
  199. package/src/actions/primitives/semanticAction.js +120 -10
  200. package/src/actions/primitives/semanticValidation/guide.js +10 -0
  201. package/src/actions/primitives/semanticValidation/index.js +9 -1
  202. package/src/actions/primitives/semanticValidation/layer.js +23 -1
  203. package/src/actions/primitives/semanticValidation/scale.js +38 -8
  204. package/src/actions/regression/components.js +26 -11
  205. package/src/actions/regression/create.js +11 -3
  206. package/src/actions/regression/edit.js +40 -13
  207. package/src/actions/regression/resolve.js +1 -1
  208. package/src/actions/resources/index.js +7 -0
  209. package/src/actions/resources/remove.js +172 -0
  210. package/src/actions/scales/channels.js +212 -0
  211. package/src/actions/scales/consumers/common.js +21 -54
  212. package/src/actions/scales/consumers/families.js +28 -14
  213. package/src/actions/scales/consumers/index.js +42 -4
  214. package/src/actions/scales/consumers/seriesLayout.js +57 -12
  215. package/src/actions/scales/create.js +58 -26
  216. package/src/actions/scales/definitions.js +109 -39
  217. package/src/actions/scales/edit.js +101 -34
  218. package/src/actions/scales/editPolicy.js +95 -35
  219. package/src/actions/scales/index.js +6 -0
  220. package/src/actions/scales/materialize.js +14 -49
  221. package/src/actions/scales/offset.js +48 -0
  222. package/src/actions/scales/parallel.js +71 -0
  223. package/src/actions/scales/patch.js +1 -1
  224. package/src/actions/scales/preview.js +140 -0
  225. package/src/actions/scales/quantitativeColor.js +9 -5
  226. package/src/actions/selection/actions.js +154 -30
  227. package/src/actions/selection/index.js +3 -0
  228. package/src/actions/theme/actions.js +98 -0
  229. package/src/actions/theme/composition.js +238 -0
  230. package/src/actions/theme/index.js +16 -0
  231. package/src/actions/theme/reconcile.js +1130 -0
  232. package/src/actions/theme/state.js +160 -0
  233. package/src/actions/titles/actions.js +11 -10
  234. package/src/actions/titles/resolve.js +1 -40
  235. package/src/actions/violinPlots/create.js +90 -43
  236. package/src/actions/violinPlots/edit.js +192 -0
  237. package/src/actions/violinPlots/index.js +2 -0
  238. package/src/core/ChartProgram.js +7 -0
  239. package/src/core/action.js +32 -2
  240. package/src/core/compositionState.js +141 -8
  241. package/src/core/programState.js +20 -0
  242. package/src/core/resourceReferences.js +332 -0
  243. package/src/core/vocabulary.js +16 -3
  244. package/src/grammar/aggregate.js +66 -23
  245. package/src/grammar/arcs.js +58 -1
  246. package/src/grammar/areaEndpoints.js +46 -0
  247. package/src/grammar/areaSeries.js +87 -41
  248. package/src/grammar/bars/aggregate.js +37 -13
  249. package/src/grammar/bars/geometry.js +44 -0
  250. package/src/grammar/bars/policy.js +9 -7
  251. package/src/grammar/bin.js +226 -0
  252. package/src/grammar/categoryOrder.js +34 -0
  253. package/src/grammar/complete.js +279 -0
  254. package/src/grammar/computed.js +351 -0
  255. package/src/grammar/coordinates.js +37 -0
  256. package/src/grammar/density.js +239 -37
  257. package/src/grammar/displayLabels.js +57 -0
  258. package/src/grammar/ecdf.js +198 -0
  259. package/src/grammar/facets/dependencies.js +133 -1
  260. package/src/grammar/facets/guides.js +3 -0
  261. package/src/grammar/facets/index.js +123 -52
  262. package/src/grammar/facets/scales.js +15 -18
  263. package/src/grammar/fold.js +123 -0
  264. package/src/grammar/horizon.js +6 -4
  265. package/src/grammar/impute.js +340 -0
  266. package/src/grammar/interval.js +37 -20
  267. package/src/grammar/lineSeries.js +20 -63
  268. package/src/grammar/markFilter.js +22 -4
  269. package/src/grammar/markLabelSelection.js +57 -0
  270. package/src/grammar/markLabels.js +110 -0
  271. package/src/grammar/normalize.js +366 -0
  272. package/src/grammar/parallelAxisTitles.js +16 -0
  273. package/src/grammar/pathSeries.js +109 -0
  274. package/src/grammar/pointPacking.js +183 -0
  275. package/src/grammar/pointShapes.js +13 -4
  276. package/src/grammar/polar.js +90 -8
  277. package/src/grammar/positionDatum.js +12 -0
  278. package/src/grammar/rects.js +9 -1
  279. package/src/grammar/regression/derive.js +4 -0
  280. package/src/grammar/regression/index.js +8 -2
  281. package/src/grammar/regression/models.js +22 -7
  282. package/src/grammar/regression/parameters.js +63 -13
  283. package/src/grammar/rotation.js +29 -0
  284. package/src/grammar/roundedRect.js +180 -0
  285. package/src/grammar/rules.js +8 -21
  286. package/src/grammar/scales/color.js +23 -1
  287. package/src/grammar/scales/colorConsumers.js +57 -0
  288. package/src/grammar/scales/continuous.js +2 -2
  289. package/src/grammar/scales/definition.js +42 -5
  290. package/src/grammar/scales/discretized.js +11 -5
  291. package/src/grammar/scales/fields.js +38 -8
  292. package/src/grammar/scales/index.js +1 -0
  293. package/src/grammar/scales/mapping.js +2 -0
  294. package/src/grammar/scales/ordinal.js +12 -3
  295. package/src/grammar/scales/policies.js +2 -2
  296. package/src/grammar/scales/radial.js +55 -0
  297. package/src/grammar/scales/size.js +456 -0
  298. package/src/grammar/scales/types.js +1 -0
  299. package/src/grammar/schemas/concreteGraphic.js +14 -2
  300. package/src/grammar/schemas/graphic.js +15 -0
  301. package/src/grammar/schemas/graphicBounds.js +179 -30
  302. package/src/grammar/schemas/semanticPath.js +23 -4
  303. package/src/grammar/stack.js +170 -0
  304. package/src/grammar/statisticalReference.js +103 -0
  305. package/src/grammar/statistics/confidenceInterval.js +101 -0
  306. package/src/grammar/strokeStyle.js +77 -0
  307. package/src/grammar/summary.js +245 -0
  308. package/src/grammar/text.js +22 -16
  309. package/src/grammar/timeUnit.js +234 -13
  310. package/src/grammar/transformTopology.js +10 -0
  311. package/src/grammar/transforms.js +418 -16
  312. package/src/grammar/valueFormat.js +112 -0
  313. package/src/grammar/weightedStatistics.js +374 -0
  314. package/src/grammar/window.js +232 -21
  315. package/src/layout/aspect.js +80 -0
  316. package/src/layout/facets.js +100 -12
  317. package/src/layout/guideCollisions.js +12 -0
  318. package/src/layout/labels.js +343 -0
  319. package/src/layout/legend.js +2 -10
  320. package/src/layout/legendItems.js +102 -0
  321. package/src/layout/legendLane.js +100 -11
  322. package/src/materialization/bars/aggregate.js +56 -147
  323. package/src/materialization/bars/histogram.js +104 -20
  324. package/src/materialization/bars/ranged.js +19 -3
  325. package/src/materialization/bars/resolve.js +7 -4
  326. package/src/materialization/categorySlotOffset.js +40 -0
  327. package/src/materialization/composition.js +3 -1
  328. package/src/materialization/compositionSnapshot.js +1 -1
  329. package/src/materialization/coordinateBounds.js +204 -0
  330. package/src/materialization/dependencies.js +93 -16
  331. package/src/materialization/encodings.js +1 -1
  332. package/src/materialization/facetGuides/legacyCategorical.js +3 -0
  333. package/src/materialization/facetGuides/placement.js +8 -2
  334. package/src/materialization/facetGuides/preparation.js +12 -7
  335. package/src/materialization/facetHeaders.js +346 -0
  336. package/src/materialization/facets.js +23 -66
  337. package/src/materialization/guides/layout.js +57 -0
  338. package/src/materialization/guides/resources.js +28 -2
  339. package/src/materialization/legends.js +11 -2
  340. package/src/materialization/marks/capabilities.js +20 -5
  341. package/src/materialization/marks/grouping.js +52 -0
  342. package/src/materialization/marks/index.js +68 -27
  343. package/src/materialization/marks/policies.js +4 -1
  344. package/src/materialization/rect.js +55 -42
  345. package/src/materialization/rectConfig.js +5 -1
  346. package/src/materialization/rowEncoding.js +13 -13
  347. package/src/materialization/scaleGuideDependencies.js +8 -5
  348. package/src/materialization/scales/map.js +6 -3
  349. package/src/materialization/scales/policies/arc.js +42 -0
  350. package/src/materialization/scales/policies/binnedPosition.js +7 -4
  351. package/src/materialization/scales/policies/offset.js +50 -8
  352. package/src/materialization/scales/policies/series.js +5 -1
  353. package/src/materialization/scales/resolve.js +65 -17
  354. package/src/materialization/selection/compatibility.js +11 -0
  355. package/src/materialization/selection/items/arc.js +28 -4
  356. package/src/materialization/selection/items/bar.js +42 -71
  357. package/src/materialization/selection/items/common.js +27 -6
  358. package/src/materialization/selection/items/path.js +3 -13
  359. package/src/materialization/selection/items/rect.js +35 -8
  360. package/src/materialization/selection/path.js +1 -1
  361. package/src/materialization/selection/point.js +3 -1
  362. package/src/materialization/text.js +324 -14
  363. package/src/mcp/adapter.js +2 -2
  364. package/src/renderers/canvas/circle.js +2 -3
  365. package/src/renderers/canvas/line.js +2 -3
  366. package/src/renderers/canvas/native.js +4 -4
  367. package/src/renderers/canvas/path.js +2 -3
  368. package/src/renderers/canvas/rect.js +2 -3
  369. package/src/renderers/canvas/stroke.js +11 -0
  370. package/src/renderers/svg.js +19 -0
  371. package/src/selectors/datasets.js +44 -0
  372. package/src/selectors/layers.js +7 -2
  373. package/src/selectors/scales.js +6 -0
  374. package/src/theme/defaults.js +140 -0
  375. package/types/basic.d.ts +17 -2
  376. package/types/index.d.ts +211 -0
  377. package/types/program.d.ts +2182 -204
  378. package/src/materialization/bars/grouped.js +0 -144
@@ -19,6 +19,349 @@ const MAX_EXHAUSTIVE_OFFSET_STEPS = 28;
19
19
  const MAX_SEARCH_DISPLACEMENT = 1_000_000;
20
20
  const DISTANT_RING_COUNT = 16;
21
21
  const DISTANT_ANGLE_COUNT = 32;
22
+ const PLACEMENT_OPTIONS = Object.freeze([
23
+ "anchor", "gap", "overflow", "leader"
24
+ ]);
25
+ const PLACEMENT_LEADER_OPTIONS = Object.freeze(["stroke", "strokeWidth"]);
26
+ const SEMANTIC_ANCHORS = new Set([
27
+ "center", "insideStart", "insideEnd", "outsideStart", "outsideEnd"
28
+ ]);
29
+ const OVERFLOW_POLICIES = new Set(["hide", "outside", "allow"]);
30
+
31
+ export const DEFAULT_MARK_LABEL_PLACEMENT = cloneAndFreeze({
32
+ gap: 4,
33
+ overflow: "hide",
34
+ leader: false
35
+ });
36
+
37
+ export function markLabelPlacementLeaderId(target) {
38
+ return `${target}-placement-leaders`;
39
+ }
40
+
41
+ function normalizePlacementLeader(value) {
42
+ if (value === undefined || value === false) return false;
43
+ validateOptionObject(value, PLACEMENT_LEADER_OPTIONS, "mark label placement leader");
44
+ const leader = { stroke: "#94a3b8", strokeWidth: 1, ...value };
45
+ if (typeof leader.stroke !== "string" || leader.stroke.length === 0) {
46
+ throw new TypeError("Mark label placement leader stroke must be a non-empty string.");
47
+ }
48
+ validateNonNegativeFinite(
49
+ leader.strokeWidth,
50
+ "Mark label placement leader strokeWidth"
51
+ );
52
+ return leader;
53
+ }
54
+
55
+ export function normalizeMarkLabelPlacement(value) {
56
+ validateOptionObject(value, PLACEMENT_OPTIONS, "mark label placement", {
57
+ allowEmpty: false,
58
+ emptyError: TypeError
59
+ });
60
+ if (!SEMANTIC_ANCHORS.has(value.anchor)) {
61
+ throw new Error(`Unsupported mark label anchor "${value.anchor}".`);
62
+ }
63
+ const gap = value.gap ?? DEFAULT_MARK_LABEL_PLACEMENT.gap;
64
+ const overflowPolicy = value.overflow ?? DEFAULT_MARK_LABEL_PLACEMENT.overflow;
65
+ validateNonNegativeFinite(gap, "Mark label placement gap");
66
+ if (!OVERFLOW_POLICIES.has(overflowPolicy)) {
67
+ throw new Error(`Unsupported mark label overflow "${overflowPolicy}".`);
68
+ }
69
+ return cloneAndFreeze({
70
+ anchor: value.anchor,
71
+ gap,
72
+ overflow: overflowPolicy,
73
+ leader: normalizePlacementLeader(value.leader)
74
+ });
75
+ }
76
+
77
+ function finitePoint(value, label) {
78
+ if (
79
+ !isPlainObject(value) ||
80
+ !Number.isFinite(value.x) ||
81
+ !Number.isFinite(value.y)
82
+ ) {
83
+ throw new TypeError(`${label} requires finite x and y.`);
84
+ }
85
+ return value;
86
+ }
87
+
88
+ function unitVector(value, label) {
89
+ finitePoint(value, label);
90
+ const length = Math.hypot(value.x, value.y);
91
+ if (!Number.isFinite(length) || length <= 0) {
92
+ throw new RangeError(`${label} requires a non-zero direction.`);
93
+ }
94
+ return { x: value.x / length, y: value.y / length };
95
+ }
96
+
97
+ function validateIntervalBounds(bounds) {
98
+ if (
99
+ !isPlainObject(bounds) ||
100
+ ![bounds.left, bounds.right, bounds.top, bounds.bottom].every(Number.isFinite) ||
101
+ bounds.right < bounds.left ||
102
+ bounds.bottom < bounds.top
103
+ ) {
104
+ throw new RangeError("Interval label placement requires ordered finite bounds.");
105
+ }
106
+ }
107
+
108
+ function validatePlacementGeometry(geometry, anchor) {
109
+ if (!isPlainObject(geometry)) {
110
+ throw new TypeError("Mark label placement requires source geometry.");
111
+ }
112
+ if (geometry.kind === "interval") {
113
+ finitePoint(geometry.center, "Interval label center");
114
+ for (const role of ["start", "end"]) {
115
+ finitePoint(geometry[role]?.point, `Interval label ${role}`);
116
+ unitVector(geometry[role]?.outward, `Interval label ${role}`);
117
+ }
118
+ validateIntervalBounds(geometry.bounds);
119
+ return;
120
+ }
121
+ if (geometry.kind === "arc") {
122
+ if (![geometry.centerX, geometry.centerY, geometry.startTheta,
123
+ geometry.endTheta, geometry.innerRadius, geometry.outerRadius]
124
+ .every(Number.isFinite)) {
125
+ throw new TypeError("Arc label placement requires finite sector geometry.");
126
+ }
127
+ if (
128
+ geometry.innerRadius < 0 ||
129
+ geometry.outerRadius <= geometry.innerRadius ||
130
+ geometry.startTheta === geometry.endTheta ||
131
+ Math.abs(geometry.endTheta - geometry.startTheta) > 360
132
+ ) {
133
+ throw new RangeError("Arc label placement requires an ordered annular sector.");
134
+ }
135
+ return;
136
+ }
137
+ if (geometry.kind === "point") {
138
+ finitePoint(geometry.center, "Point label center");
139
+ if (anchor === "outsideEnd") {
140
+ finitePoint(geometry.end?.point, "Polar Point label boundary");
141
+ unitVector(geometry.end?.outward, "Polar Point label direction");
142
+ }
143
+ return;
144
+ }
145
+ throw new Error(`Unsupported mark label placement geometry "${geometry.kind}".`);
146
+ }
147
+
148
+ export function assertMarkLabelPlacementSupport({ markType, coordinateType, intervalAxis }, placement) {
149
+ const anchor = placement.anchor;
150
+ if (markType === "bar" || markType === "arc") return placement;
151
+ if (markType === "rect") {
152
+ if (anchor === "center" || intervalAxis !== undefined) return placement;
153
+ throw new Error("Rect semantic label placement requires one explicit interval axis.");
154
+ }
155
+ if (markType === "point") {
156
+ if (anchor === "center" || (coordinateType === "polar" && anchor === "outsideEnd")) {
157
+ return placement;
158
+ }
159
+ throw new Error(
160
+ coordinateType === "polar"
161
+ ? `Polar Point does not support mark label anchor "${anchor}".`
162
+ : `Point does not support mark label anchor "${anchor}".`
163
+ );
164
+ }
165
+ if (markType === "line") {
166
+ throw new Error("Line labels retain the endpoint API and do not accept semantic placement objects.");
167
+ }
168
+ if (markType === "rule") {
169
+ throw new Error("Rule labels do not support semantic placement objects.");
170
+ }
171
+ throw new Error(`Mark type "${markType}" does not support semantic label placement.`);
172
+ }
173
+
174
+ function localTextBounds(text) {
175
+ return resolveTextBounds({ ...text, x: 0, y: 0 });
176
+ }
177
+
178
+ function projectionRange(bounds, direction) {
179
+ const values = [
180
+ { x: bounds.left, y: bounds.top },
181
+ { x: bounds.right, y: bounds.top },
182
+ { x: bounds.right, y: bounds.bottom },
183
+ { x: bounds.left, y: bounds.bottom }
184
+ ].map(point => point.x * direction.x + point.y * direction.y);
185
+ return { minimum: Math.min(...values), maximum: Math.max(...values) };
186
+ }
187
+
188
+ function boundaryCandidate(boundary, direction, gap, text) {
189
+ const resolvedDirection = unitVector(direction, "Mark label placement direction");
190
+ const support = projectionRange(localTextBounds(text), resolvedDirection);
191
+ const distance = gap - support.minimum;
192
+ return {
193
+ x: boundary.x + resolvedDirection.x * distance + (text.dx ?? 0),
194
+ y: boundary.y + resolvedDirection.y * distance + (text.dy ?? 0),
195
+ sourceX: boundary.x,
196
+ sourceY: boundary.y
197
+ };
198
+ }
199
+
200
+ function centerCandidate(center, text) {
201
+ return {
202
+ x: center.x + (text.dx ?? 0),
203
+ y: center.y + (text.dy ?? 0),
204
+ sourceX: center.x,
205
+ sourceY: center.y
206
+ };
207
+ }
208
+
209
+ function candidateBounds(candidate, text) {
210
+ return resolveTextBounds({ ...text, x: candidate.x, y: candidate.y });
211
+ }
212
+
213
+ function intervalContains(bounds, container) {
214
+ const epsilon = 1e-9;
215
+ return bounds.left >= container.left - epsilon &&
216
+ bounds.right <= container.right + epsilon &&
217
+ bounds.top >= container.top - epsilon &&
218
+ bounds.bottom <= container.bottom + epsilon;
219
+ }
220
+
221
+ function distanceToRectangle(centerX, centerY, bounds) {
222
+ const x = Math.max(bounds.left, Math.min(centerX, bounds.right));
223
+ const y = Math.max(bounds.top, Math.min(centerY, bounds.bottom));
224
+ return Math.hypot(x - centerX, y - centerY);
225
+ }
226
+
227
+ function thetaForPoint(point, geometry) {
228
+ return Math.atan2(point.x - geometry.centerX, geometry.centerY - point.y) *
229
+ 180 / Math.PI;
230
+ }
231
+
232
+ function angleWithin(theta, start, end) {
233
+ const sweep = end - start;
234
+ if (Math.abs(sweep) >= 360 - 1e-9) return true;
235
+ const direction = Math.sign(sweep);
236
+ const progress = ((direction * (theta - start)) % 360 + 360) % 360;
237
+ return progress <= Math.abs(sweep) + 1e-9;
238
+ }
239
+
240
+ function arcContains(bounds, geometry) {
241
+ const points = [
242
+ { x: bounds.left, y: bounds.top },
243
+ { x: bounds.right, y: bounds.top },
244
+ { x: bounds.right, y: bounds.bottom },
245
+ { x: bounds.left, y: bounds.bottom },
246
+ { x: (bounds.left + bounds.right) / 2, y: bounds.top },
247
+ { x: bounds.right, y: (bounds.top + bounds.bottom) / 2 },
248
+ { x: (bounds.left + bounds.right) / 2, y: bounds.bottom },
249
+ { x: bounds.left, y: (bounds.top + bounds.bottom) / 2 }
250
+ ];
251
+ const minimumRadius = distanceToRectangle(
252
+ geometry.centerX,
253
+ geometry.centerY,
254
+ bounds
255
+ );
256
+ const maximumRadius = Math.max(...points.slice(0, 4).map(point =>
257
+ Math.hypot(point.x - geometry.centerX, point.y - geometry.centerY)
258
+ ));
259
+ const epsilon = 1e-9;
260
+ return minimumRadius >= geometry.innerRadius - epsilon &&
261
+ maximumRadius <= geometry.outerRadius + epsilon &&
262
+ points.every(point => angleWithin(
263
+ thetaForPoint(point, geometry),
264
+ geometry.startTheta,
265
+ geometry.endTheta
266
+ ));
267
+ }
268
+
269
+ function arcRole(geometry, role) {
270
+ const theta = geometry.startTheta +
271
+ (geometry.endTheta - geometry.startTheta) / 2;
272
+ const radians = theta * Math.PI / 180;
273
+ const radial = { x: Math.sin(radians), y: -Math.cos(radians) };
274
+ const radius = role === "start" ? geometry.innerRadius : geometry.outerRadius;
275
+ return {
276
+ point: {
277
+ x: geometry.centerX + radial.x * radius,
278
+ y: geometry.centerY + radial.y * radius
279
+ },
280
+ outward: role === "start"
281
+ ? { x: -radial.x, y: -radial.y }
282
+ : radial
283
+ };
284
+ }
285
+
286
+ function geometryRole(geometry, role) {
287
+ return geometry.kind === "arc" ? arcRole(geometry, role) : geometry[role];
288
+ }
289
+
290
+ function geometryCenter(geometry) {
291
+ if (geometry.kind !== "arc") return geometry.center;
292
+ const role = arcRole(geometry, "end");
293
+ const radius = (geometry.innerRadius + geometry.outerRadius) / 2;
294
+ return {
295
+ x: geometry.centerX + role.outward.x * radius,
296
+ y: geometry.centerY + role.outward.y * radius
297
+ };
298
+ }
299
+
300
+ function candidateForAnchor(anchor, geometry, placement, text) {
301
+ if (anchor === "center") return centerCandidate(geometryCenter(geometry), text);
302
+ const roleName = anchor.endsWith("Start") ? "start" : "end";
303
+ const role = geometryRole(geometry, roleName);
304
+ const outside = anchor.startsWith("outside");
305
+ const direction = outside
306
+ ? role.outward
307
+ : { x: -role.outward.x, y: -role.outward.y };
308
+ return boundaryCandidate(role.point, direction, placement.gap, text);
309
+ }
310
+
311
+ function arcOutsideStartCrossesCenter(candidate, geometry, text) {
312
+ if (geometry.kind !== "arc") return false;
313
+ const radial = arcRole(geometry, "end").outward;
314
+ const projections = projectionRange(candidateBounds(candidate, text), radial);
315
+ const centerProjection = geometry.centerX * radial.x + geometry.centerY * radial.y;
316
+ return projections.minimum < centerProjection - 1e-9;
317
+ }
318
+
319
+ function fitsSource(candidate, geometry, text) {
320
+ if (geometry.kind === "point") return true;
321
+ const bounds = candidateBounds(candidate, text);
322
+ return geometry.kind === "arc"
323
+ ? arcContains(bounds, geometry)
324
+ : intervalContains(bounds, geometry.bounds);
325
+ }
326
+
327
+ function outsideFallback(anchor) {
328
+ return anchor === "insideStart" ? "outsideStart" : "outsideEnd";
329
+ }
330
+
331
+ export function resolveMarkLabelPlacement({ placement, geometry, text } = {}) {
332
+ const normalized = normalizeMarkLabelPlacement(placement);
333
+ validatePlacementGeometry(geometry, normalized.anchor);
334
+ let anchor = normalized.anchor;
335
+ let candidate = candidateForAnchor(anchor, geometry, normalized, text);
336
+ const requiresFit = anchor === "center" || anchor.startsWith("inside");
337
+ if (requiresFit && !fitsSource(candidate, geometry, text)) {
338
+ if (normalized.overflow === "hide") {
339
+ return cloneAndFreeze({ visible: false, anchor, fallback: false });
340
+ }
341
+ if (normalized.overflow === "outside") {
342
+ anchor = outsideFallback(anchor);
343
+ candidate = candidateForAnchor(anchor, geometry, normalized, text);
344
+ }
345
+ }
346
+ if (
347
+ anchor === "outsideStart" &&
348
+ normalized.overflow !== "allow" &&
349
+ arcOutsideStartCrossesCenter(candidate, geometry, text)
350
+ ) {
351
+ return cloneAndFreeze({
352
+ visible: false,
353
+ anchor,
354
+ fallback: anchor !== normalized.anchor
355
+ });
356
+ }
357
+ return cloneAndFreeze({
358
+ visible: true,
359
+ anchor,
360
+ fallback: anchor !== normalized.anchor,
361
+ ...candidate,
362
+ bounds: candidateBounds(candidate, text)
363
+ });
364
+ }
22
365
 
23
366
  export const DEFAULT_LABEL_LAYOUT_GEOMETRY = cloneAndFreeze({
24
367
  axis: "both",
@@ -3,19 +3,11 @@ import {
3
3
  measureTextWidth
4
4
  } from "../core/textMetrics.js";
5
5
 
6
- export function measureLegendSymbolHeight(config) {
7
- return Math.max(...config.symbol.layers.map(layer => {
8
- if (layer.type === "swatch") return layer.height;
9
- if (layer.type === "point") return layer.size * 2;
10
- return layer.lineWidth;
11
- }));
12
- }
13
-
14
6
  export function measureLegendTextWidth(value, style = { fontSize: 12 }) {
15
7
  return measureTextWidth(formatVisibleText(value), style);
16
8
  }
17
9
 
18
- export function resolveLegendGrid(config, width, count) {
10
+ export function resolveLegendGrid(config, width, count, symbolHeight) {
19
11
  const labels = config.domain.map(formatVisibleText);
20
12
  const itemWidths = labels.map(
21
13
  label => width + config.labels.offset +
@@ -39,7 +31,7 @@ export function resolveLegendGrid(config, width, count) {
39
31
  config.itemGap * Math.max(0, actualColumns - 1);
40
32
  const rowHeight = Math.max(
41
33
  config.labels.fontSize,
42
- measureLegendSymbolHeight(config)
34
+ symbolHeight
43
35
  );
44
36
  const gridHeight = rowHeight * rowCount +
45
37
  config.itemGap * Math.max(0, rowCount - 1);
@@ -0,0 +1,102 @@
1
+ import { measureTextWidth, resolveTextBounds } from "../core/textMetrics.js";
2
+ import { alignLegendStart, resolveLegendGrid } from "./legend.js";
3
+
4
+ // Measure item content before choosing its edge. Family owners supply the
5
+ // sample dimensions and formatted labels; this module does not inspect scales.
6
+ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas) {
7
+ const strokes = labels.map((_, index) => Array.isArray(symbol.strokeWidth)
8
+ ? symbol.strokeWidth[index] : symbol.strokeWidth ?? 0);
9
+ const sampleBounds = labels.map((_, index) => symbol.itemBounds?.[index] ?? {
10
+ left: -strokes[index] / 2, right: symbol.width + strokes[index] / 2,
11
+ top: -symbol.height / 2 - strokes[index] / 2,
12
+ bottom: symbol.height / 2 + strokes[index] / 2
13
+ });
14
+ // Retain a family's minimum slot while reserving its complete painted extent.
15
+ const sampleLeft = Math.min(0, ...sampleBounds.map(bounds => bounds.left));
16
+ const sampleRight = Math.max(symbol.width, ...sampleBounds.map(bounds => bounds.right));
17
+ const sampleWidth = sampleRight - sampleLeft;
18
+ const sampleHeight = Math.max(symbol.height,
19
+ ...sampleBounds.map(bounds => 2 * Math.max(-bounds.top, bounds.bottom)));
20
+ const side = ["left", "right"].includes(config.position);
21
+ const titleVisible = config.titleVisible !== false;
22
+ const titleWidth = titleVisible ? measureTextWidth(config.title, config.titleStyle) : 0;
23
+ const titleHeight = titleVisible ? config.titleStyle.fontSize : 0;
24
+ const itemWidth = sampleWidth + config.labels.offset + Math.max(...labels.map(
25
+ text => measureTextWidth(text, config.labels)
26
+ ));
27
+ let symbolX;
28
+ let labelX;
29
+ let itemY;
30
+ let title;
31
+ if (config.layout === "legacy-bottom") {
32
+ const widths = labels.map(text => sampleWidth + config.labels.offset + measureTextWidth(text, config.labels));
33
+ const width = widths.reduce((sum, value) => sum + value, 0) + config.itemGap * (labels.length - 1);
34
+ const x = config.align === "center" ? (canvas.width - width) / 2 : alignLegendStart(plot, width, config.align);
35
+ let cursor = x;
36
+ symbolX = widths.map(width => {
37
+ const start = cursor - sampleLeft;
38
+ cursor += width + config.itemGap;
39
+ return start;
40
+ });
41
+ labelX = symbolX.map(value => value + sampleRight + config.labels.offset);
42
+ itemY = labels.map(() => canvas.height - 28);
43
+ title = { x: x + width / 2, y: canvas.height - 52, align: "center" };
44
+ } else if (side) {
45
+ const width = Math.max(itemWidth, titleWidth);
46
+ const x = config.position === "right" ? plot.x + plot.width + config.offset
47
+ : plot.x - config.offset - width;
48
+ const itemHeight = Math.max(sampleHeight, config.labels.fontSize);
49
+ const pitch = Math.max(config.itemGap, itemHeight);
50
+ symbolX = labels.map(() => x - sampleLeft);
51
+ labelX = labels.map(() => x + sampleWidth + config.labels.offset);
52
+ const firstY = Math.max(plot.y + 52, titleVisible
53
+ ? plot.y + 20 + titleHeight / 2 + 12 + itemHeight / 2 : plot.y + 52);
54
+ itemY = labels.map((_, index) => firstY + index * pitch);
55
+ title = { x, y: plot.y + 20, align: "left" };
56
+ } else {
57
+ const grid = resolveLegendGrid({ ...config, domain: labels }, sampleWidth, labels.length, sampleHeight);
58
+ const inline = config.titlePosition === "left" && titleVisible;
59
+ const prefix = inline ? titleWidth + 20 : 0;
60
+ const width = inline ? prefix + grid.gridWidth : Math.max(titleWidth, grid.gridWidth);
61
+ const top = !inline && titleVisible ? titleHeight + 12 : 0;
62
+ const height = inline ? Math.max(titleHeight, grid.gridHeight) : top + grid.gridHeight;
63
+ const x = alignLegendStart(plot, width, config.align);
64
+ const y = config.position === "top" ? plot.y - config.offset - height
65
+ : plot.y + plot.height + config.offset;
66
+ const gridX = inline ? x + prefix : x + (width - grid.gridWidth) / 2;
67
+ const gridY = inline ? y + (height - grid.gridHeight) / 2 : y + top;
68
+ let cursor = 0;
69
+ const columns = grid.columnWidths.map(width => {
70
+ const start = cursor;
71
+ cursor += width + config.itemGap;
72
+ return start;
73
+ });
74
+ symbolX = grid.cells.map(cell => gridX + columns[cell.column] - sampleLeft);
75
+ labelX = symbolX.map(value => value + sampleRight + config.labels.offset);
76
+ itemY = grid.cells.map(cell => gridY + grid.rowHeight / 2 + cell.row * (grid.rowHeight + config.itemGap));
77
+ title = inline ? { x, y: y + height / 2, align: "left" }
78
+ : { x: x + width / 2, y: y + titleHeight / 2, align: "center" };
79
+ }
80
+ const textBounds = (x, y, text, style, align = "left") => resolveTextBounds({
81
+ x, y, text, ...style, textAlign: align, textBaseline: "middle"
82
+ });
83
+ const bounds = [
84
+ ...(titleVisible ? [textBounds(title.x, title.y, config.title, config.titleStyle, title.align)] : []),
85
+ ...labels.map((label, index) => textBounds(labelX[index], itemY[index], label, config.labels)),
86
+ ...symbolX.map((x, index) => {
87
+ const local = sampleBounds[index];
88
+ return { left: x + local.left, right: x + local.right,
89
+ top: itemY[index] + local.top, bottom: itemY[index] + local.bottom };
90
+ })
91
+ ];
92
+ if (config.layout === "legacy-bottom") {
93
+ const itemBounds = titleVisible ? bounds.slice(1) : bounds;
94
+ if (titleVisible && itemBounds.some(bound => bound.top < bounds[0].bottom)) {
95
+ throw new Error("Legacy bottom legend requires more space between its fixed title and item rows.");
96
+ }
97
+ if (bounds.some(bound => bound.top <= plot.y + plot.height)) {
98
+ throw new Error("Legacy bottom legend requires more bottom-margin space.");
99
+ }
100
+ }
101
+ return { symbolX, labelX, itemY, title, bounds };
102
+ }
@@ -4,6 +4,28 @@ export const SIDE_LEGEND_SYMBOL_CENTER = 16;
4
4
  export const SIDE_LEGEND_LABEL_START = 44;
5
5
  export const HORIZONTAL_LEGEND_TITLE_ELEMENT_GAP = 12;
6
6
 
7
+ export function isHorizontalEdgeLegend(config) {
8
+ return config !== undefined &&
9
+ ["top", "bottom"].includes(config.position) &&
10
+ config.layout !== "legacy-bottom";
11
+ }
12
+
13
+ export function resolveSingleHorizontalLegendPlacement({ plot, canvas, config, bounds }) {
14
+ if (!isHorizontalEdgeLegend(config)) {
15
+ throw new Error("Single legend placement requires a horizontal edge layout.");
16
+ }
17
+ const dx = config.align === "left" ? plot.x - bounds.left
18
+ : config.align === "right" ? plot.x + plot.width - bounds.right
19
+ : plot.x + plot.width / 2 - midpoint(bounds.left, bounds.right);
20
+ const dy = config.position === "top" ? plot.y - config.offset - bounds.bottom
21
+ : plot.y + plot.height + config.offset - bounds.top;
22
+ const occupied = translateBounds(bounds, dx, dy);
23
+ if (occupied.left < 0 || occupied.right > canvas.width || occupied.top < 0 || occupied.bottom > canvas.height) {
24
+ throw new Error(`Legend layout requires more ${config.position}-margin or Canvas space.`);
25
+ }
26
+ return { dx, dy, occupied };
27
+ }
28
+
7
29
  function midpoint(start, end) {
8
30
  const sum = start + end;
9
31
  const result = Number.isFinite(sum) ? sum / 2 : start / 2 + end / 2;
@@ -25,24 +47,25 @@ function decoration(border) {
25
47
  : border.padding + border.lineWidth / 2;
26
48
  }
27
49
 
28
- function horizontalExtent(blocks) {
50
+ function horizontalExtent(blocks, labelStart) {
29
51
  let left = 0;
30
52
  let right = 0;
31
53
  for (const block of blocks) {
32
- if (block.title !== undefined) right = Math.max(right, block.title.width);
54
+ const inset = block.inset ?? 0;
55
+ if (block.title !== undefined) right = Math.max(right, block.title.width + inset);
33
56
  left = Math.min(left,
34
- SIDE_LEGEND_SYMBOL_CENTER + block.symbol.left - block.symbol.centerX
57
+ SIDE_LEGEND_SYMBOL_CENTER + block.symbol.left - block.symbol.centerX - inset
35
58
  );
36
59
  right = Math.max(
37
60
  right,
38
- SIDE_LEGEND_SYMBOL_CENTER + block.symbol.right - block.symbol.centerX,
39
- SIDE_LEGEND_LABEL_START + block.labels.width
61
+ SIDE_LEGEND_SYMBOL_CENTER + block.symbol.right - block.symbol.centerX + inset,
62
+ labelStart + block.labels.width + inset
40
63
  );
41
64
  }
42
65
  return { left, right };
43
66
  }
44
67
 
45
- function placeBlock(block, cursor) {
68
+ function placeBlock(block, cursor, labelStart) {
46
69
  let dy;
47
70
  if (block.title === undefined) {
48
71
  dy = cursor - block.bounds.top;
@@ -65,7 +88,7 @@ function placeBlock(block, cursor) {
65
88
  ? undefined
66
89
  : -block.title.x,
67
90
  symbolDx: SIDE_LEGEND_SYMBOL_CENTER - block.symbol.centerX,
68
- labelDx: SIDE_LEGEND_LABEL_START - block.labels.x,
91
+ labelDx: labelStart - block.labels.x,
69
92
  bounds
70
93
  };
71
94
  }
@@ -87,7 +110,10 @@ export function resolveSideLegendLane({
87
110
  }
88
111
  const blocks = groups.flatMap(group => group.blocks);
89
112
  if (blocks.length < 2) return undefined;
90
- const laneExtent = horizontalExtent(blocks);
113
+ const labelStart = Math.max(SIDE_LEGEND_LABEL_START, ...blocks.map(
114
+ block => SIDE_LEGEND_SYMBOL_CENTER + Math.abs(block.labels.x - block.symbol.centerX)
115
+ ));
116
+ const laneExtent = horizontalExtent(blocks, labelStart);
91
117
  const offset = blocks.reduce(
92
118
  (maximum, block) => Math.max(maximum, block.offset),
93
119
  -Infinity
@@ -96,7 +122,7 @@ export function resolveSideLegendLane({
96
122
  ? plot.x + plot.width + offset
97
123
  : plot.x - offset - laneExtent.right;
98
124
  const symbolCenterX = titleStartX + SIDE_LEGEND_SYMBOL_CENTER;
99
- const labelStartX = titleStartX + SIDE_LEGEND_LABEL_START;
125
+ const labelStartX = titleStartX + labelStart;
100
126
  const first = blocks[0];
101
127
  let cursor = first.title === undefined
102
128
  ? plot.y + 12
@@ -110,7 +136,7 @@ export function resolveSideLegendLane({
110
136
  if (occupiedGroups.length > 0) cursor += inset;
111
137
  const groupPlacements = [];
112
138
  for (const block of group.blocks) {
113
- const placement = placeBlock(block, cursor);
139
+ const placement = placeBlock(block, cursor, labelStart);
114
140
  groupPlacements.push(placement);
115
141
  placements.push({
116
142
  ...placement,
@@ -123,7 +149,7 @@ export function resolveSideLegendLane({
123
149
  cursor = placement.bounds.bottom + SIDE_LEGEND_BLOCK_GAP;
124
150
  }
125
151
  const vertical = unionBounds(groupPlacements.map(item => item.bounds));
126
- const groupExtent = horizontalExtent(group.blocks);
152
+ const groupExtent = horizontalExtent(group.blocks, labelStart);
127
153
  const foreground = {
128
154
  left: titleStartX + groupExtent.left,
129
155
  right: titleStartX + groupExtent.right,
@@ -232,6 +258,13 @@ function normalizeHorizontalRow(entries) {
232
258
  )
233
259
  : commonTitleY + titleDescent + HORIZONTAL_LEGEND_TITLE_ELEMENT_GAP;
234
260
  return entries.map(({ group, dx }) => {
261
+ if (group.atomic) {
262
+ const contentDy = elementAnchor - group.element.top;
263
+ const foreground = translateBounds(group.content, dx, contentDy);
264
+ return { id: group.id, dx, titleDy: contentDy, contentDy, foreground,
265
+ occupied: expandBounds(foreground, group.inset), padding: group.padding,
266
+ backgroundId: group.backgroundId };
267
+ }
235
268
  const contentDy = commonTitleY === undefined
236
269
  ? elementAnchor - midpoint(
237
270
  group.element.top,
@@ -350,3 +383,59 @@ export function resolveHorizontalLegendLane({
350
383
  rowCount: packed.length
351
384
  };
352
385
  }
386
+
387
+ // Compose independently measured content blocks before the outer edge lane
388
+ // treats the whole group as one indivisible item.
389
+ export function resolveHorizontalLegendGroup({ edge, plot, canvas, groups,
390
+ align, offset, border, backgroundId, collisionBounds = [] }) {
391
+ const inset = decoration(border);
392
+ const innerPlot = { ...plot, x: plot.x + inset, width: plot.width - inset * 2 };
393
+ const packed = packHorizontalRows(groups, innerPlot);
394
+ const rows = packed.map(normalizeHorizontalRow);
395
+ let cursor = 0;
396
+ const placements = [];
397
+ for (const row of rows) {
398
+ const bounds = unionBounds(row.map(item => item.occupied));
399
+ const dy = edge === "top" ? cursor - bounds.bottom : cursor - bounds.top;
400
+ const translated = row.map(item => translateHorizontalPlacement(item, dy));
401
+ placements.push(...translated);
402
+ const occupied = unionBounds(translated.map(item => item.occupied));
403
+ cursor = edge === "top" ? occupied.top - HORIZONTAL_LEGEND_BLOCK_GAP
404
+ : occupied.bottom + HORIZONTAL_LEGEND_BLOCK_GAP;
405
+ }
406
+ const foreground = unionBounds(placements.map(item => item.occupied));
407
+ const occupied = expandBounds(foreground, inset);
408
+ const width = occupied.right - occupied.left;
409
+ const x = align === "left" ? plot.x : align === "right" ? plot.x + plot.width - width
410
+ : plot.x + (plot.width - width) / 2;
411
+ const dx = x - occupied.left;
412
+ const dy = edge === "top" ? plot.y - offset - occupied.bottom
413
+ : plot.y + plot.height + offset - occupied.top;
414
+ const finalBounds = translateBounds(occupied, dx, dy);
415
+ if (finalBounds.left < 0 || finalBounds.right > canvas.width ||
416
+ finalBounds.top < 0 || finalBounds.bottom > canvas.height) {
417
+ throw new Error(`Combined legend requires more ${edge}-margin or Canvas space.`);
418
+ }
419
+ if (collisionBounds.some(bounds => overlap(finalBounds, bounds))) {
420
+ throw new Error(`Combined ${edge} legend and ${edge === "top" ? "chart titles" : "x-axis guides"} require more margin space.`);
421
+ }
422
+ return {
423
+ placements: placements.map(item => ({ ...item, dx: item.dx + dx,
424
+ titleDy: item.titleDy + dy, contentDy: item.contentDy + dy,
425
+ ...(item.backgroundId === undefined ? {} : { background: {
426
+ id: item.backgroundId,
427
+ x: item.foreground.left + dx - item.padding,
428
+ y: item.foreground.top + dy - item.padding,
429
+ width: item.foreground.right - item.foreground.left + item.padding * 2,
430
+ height: item.foreground.bottom - item.foreground.top + item.padding * 2
431
+ } }) })),
432
+ occupied: finalBounds,
433
+ background: backgroundId === undefined ? undefined : {
434
+ id: backgroundId,
435
+ x: foreground.left + dx - border.padding,
436
+ y: foreground.top + dy - border.padding,
437
+ width: foreground.right - foreground.left + border.padding * 2,
438
+ height: foreground.bottom - foreground.top + border.padding * 2
439
+ }
440
+ };
441
+ }