@genome-spy/core 0.78.0 → 0.79.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 (292) hide show
  1. package/dist/bundle/{browser-KWU9rWZT.js → browser-CETrb2cm.js} +53 -33
  2. package/dist/bundle/esm-BdLYkz-m.js +248 -0
  3. package/dist/bundle/esm-BwiDsqSb.js +1367 -0
  4. package/dist/bundle/esm-CDFd1cjk.js +441 -0
  5. package/dist/bundle/{esm-DVOHLB1e.js → esm-CTUHLDbv.js} +30 -30
  6. package/dist/bundle/{esm-NIYEaYkc.js → esm-Cx-EbkOj.js} +13 -13
  7. package/dist/bundle/esm-DlYGqi79.js +128 -0
  8. package/dist/bundle/{esm-BygJiwh0.js → esm-k9p3oHkt.js} +133 -158
  9. package/dist/bundle/{esm-CT3ygiMq.js → esm-zAZJQO6D.js} +226 -212
  10. package/dist/bundle/index.es.js +14879 -11656
  11. package/dist/bundle/index.js +119 -108
  12. package/dist/bundle/{parquetRead-DG_-F5j5.js → parquetRead-Cad1SOVV.js} +473 -399
  13. package/dist/schema.json +18940 -6914
  14. package/dist/src/config/axisConfig.d.ts +2 -2
  15. package/dist/src/config/axisConfig.d.ts.map +1 -1
  16. package/dist/src/config/axisConfig.js +28 -44
  17. package/dist/src/config/configLayers.d.ts +45 -0
  18. package/dist/src/config/configLayers.d.ts.map +1 -0
  19. package/dist/src/config/configLayers.js +110 -0
  20. package/dist/src/config/defaultConfig.d.ts.map +1 -1
  21. package/dist/src/config/defaultConfig.js +8 -1
  22. package/dist/src/config/defaults/legendDefaults.d.ts +14 -0
  23. package/dist/src/config/defaults/legendDefaults.d.ts.map +1 -0
  24. package/dist/src/config/defaults/legendDefaults.js +46 -0
  25. package/dist/src/config/defaults/titleDefaults.d.ts.map +1 -1
  26. package/dist/src/config/defaults/titleDefaults.js +26 -18
  27. package/dist/src/config/legendConfig.d.ts +11 -0
  28. package/dist/src/config/legendConfig.d.ts.map +1 -0
  29. package/dist/src/config/legendConfig.js +63 -0
  30. package/dist/src/config/styleUtils.d.ts +8 -2
  31. package/dist/src/config/styleUtils.d.ts.map +1 -1
  32. package/dist/src/config/styleUtils.js +25 -1
  33. package/dist/src/config/themes.d.ts.map +1 -1
  34. package/dist/src/config/themes.js +21 -2
  35. package/dist/src/config/titleConfig.d.ts.map +1 -1
  36. package/dist/src/config/titleConfig.js +2 -18
  37. package/dist/src/data/collector.d.ts.map +1 -1
  38. package/dist/src/data/collector.js +40 -18
  39. package/dist/src/data/flowInit.d.ts +6 -0
  40. package/dist/src/data/flowInit.d.ts.map +1 -1
  41. package/dist/src/data/flowInit.js +1 -1
  42. package/dist/src/data/flowNode.d.ts +32 -0
  43. package/dist/src/data/flowNode.d.ts.map +1 -1
  44. package/dist/src/data/flowNode.js +59 -0
  45. package/dist/src/data/sources/lazy/bamSource.d.ts +0 -1
  46. package/dist/src/data/sources/lazy/bamSource.d.ts.map +1 -1
  47. package/dist/src/data/sources/lazy/bamSource.js +39 -30
  48. package/dist/src/data/sources/lazy/bigBedSource.d.ts +0 -10
  49. package/dist/src/data/sources/lazy/bigBedSource.d.ts.map +1 -1
  50. package/dist/src/data/sources/lazy/bigBedSource.js +127 -62
  51. package/dist/src/data/sources/lazy/bigWigSource.d.ts +2 -2
  52. package/dist/src/data/sources/lazy/bigWigSource.d.ts.map +1 -1
  53. package/dist/src/data/sources/lazy/bigWigSource.js +234 -81
  54. package/dist/src/data/sources/lazy/gff3Source.d.ts +7 -3
  55. package/dist/src/data/sources/lazy/gff3Source.d.ts.map +1 -1
  56. package/dist/src/data/sources/lazy/gff3Source.js +7 -8
  57. package/dist/src/data/sources/lazy/indexedFastaSource.d.ts +1 -1
  58. package/dist/src/data/sources/lazy/indexedFastaSource.d.ts.map +1 -1
  59. package/dist/src/data/sources/lazy/indexedFastaSource.js +28 -19
  60. package/dist/src/data/sources/lazy/legendEntriesSource.d.ts +24 -0
  61. package/dist/src/data/sources/lazy/legendEntriesSource.d.ts.map +1 -0
  62. package/dist/src/data/sources/lazy/legendEntriesSource.js +218 -0
  63. package/dist/src/data/sources/lazy/legendGradientSource.d.ts +30 -0
  64. package/dist/src/data/sources/lazy/legendGradientSource.d.ts.map +1 -0
  65. package/dist/src/data/sources/lazy/legendGradientSource.js +388 -0
  66. package/dist/src/data/sources/lazy/mockLazySource.d.ts +4 -1
  67. package/dist/src/data/sources/lazy/mockLazySource.d.ts.map +1 -1
  68. package/dist/src/data/sources/lazy/mockLazySource.js +49 -4
  69. package/dist/src/data/sources/lazy/registerCoreLazySources.js +2 -0
  70. package/dist/src/data/sources/lazy/singleAxisWindowedSource.d.ts.map +1 -1
  71. package/dist/src/data/sources/lazy/singleAxisWindowedSource.js +3 -4
  72. package/dist/src/data/sources/lazy/tabixSource.d.ts +9 -4
  73. package/dist/src/data/sources/lazy/tabixSource.d.ts.map +1 -1
  74. package/dist/src/data/sources/lazy/tabixSource.js +201 -70
  75. package/dist/src/data/sources/lazy/tabixTsvSource.d.ts +2 -3
  76. package/dist/src/data/sources/lazy/tabixTsvSource.d.ts.map +1 -1
  77. package/dist/src/data/sources/lazy/tabixTsvSource.js +14 -12
  78. package/dist/src/data/sources/lazy/vcfSource.d.ts +7 -3
  79. package/dist/src/data/sources/lazy/vcfSource.d.ts.map +1 -1
  80. package/dist/src/data/sources/lazy/vcfSource.js +7 -8
  81. package/dist/src/data/sources/urlDescriptor.d.ts +165 -0
  82. package/dist/src/data/sources/urlDescriptor.d.ts.map +1 -0
  83. package/dist/src/data/sources/urlDescriptor.js +473 -0
  84. package/dist/src/data/sources/urlDescriptorController.d.ts +25 -0
  85. package/dist/src/data/sources/urlDescriptorController.d.ts.map +1 -0
  86. package/dist/src/data/sources/urlDescriptorController.js +72 -0
  87. package/dist/src/data/sources/urlDescriptorState.d.ts +47 -0
  88. package/dist/src/data/sources/urlDescriptorState.d.ts.map +1 -0
  89. package/dist/src/data/sources/urlDescriptorState.js +129 -0
  90. package/dist/src/data/sources/urlSource.d.ts.map +1 -1
  91. package/dist/src/data/sources/urlSource.js +101 -61
  92. package/dist/src/data/transforms/packLegendLabels.d.ts +21 -0
  93. package/dist/src/data/transforms/packLegendLabels.d.ts.map +1 -0
  94. package/dist/src/data/transforms/packLegendLabels.js +189 -0
  95. package/dist/src/data/transforms/transformFactory.d.ts.map +1 -1
  96. package/dist/src/data/transforms/transformFactory.js +4 -0
  97. package/dist/src/data/transforms/truncateText.d.ts +27 -0
  98. package/dist/src/data/transforms/truncateText.d.ts.map +1 -0
  99. package/dist/src/data/transforms/truncateText.js +94 -0
  100. package/dist/src/debug/dataflowDebugSnapshot.d.ts +58 -0
  101. package/dist/src/debug/dataflowDebugSnapshot.d.ts.map +1 -0
  102. package/dist/src/debug/dataflowDebugSnapshot.js +159 -0
  103. package/dist/src/debug/markDebugSnapshot.d.ts +54 -0
  104. package/dist/src/debug/markDebugSnapshot.d.ts.map +1 -0
  105. package/dist/src/debug/markDebugSnapshot.js +100 -0
  106. package/dist/src/debug/paramDebugSnapshot.d.ts +53 -0
  107. package/dist/src/debug/paramDebugSnapshot.d.ts.map +1 -0
  108. package/dist/src/debug/paramDebugSnapshot.js +86 -0
  109. package/dist/src/debug/resolutionDebugSnapshot.d.ts +155 -0
  110. package/dist/src/debug/resolutionDebugSnapshot.d.ts.map +1 -0
  111. package/dist/src/debug/resolutionDebugSnapshot.js +291 -0
  112. package/dist/src/debug/valuePreview.d.ts +9 -0
  113. package/dist/src/debug/valuePreview.d.ts.map +1 -0
  114. package/dist/src/debug/valuePreview.js +57 -0
  115. package/dist/src/debug/viewDebugSnapshot.d.ts +131 -0
  116. package/dist/src/debug/viewDebugSnapshot.d.ts.map +1 -0
  117. package/dist/src/debug/viewDebugSnapshot.js +390 -0
  118. package/dist/src/embedFactory.d.ts.map +1 -1
  119. package/dist/src/embedFactory.js +6 -1
  120. package/dist/src/encoder/encoder.d.ts +2 -2
  121. package/dist/src/encoder/encoder.d.ts.map +1 -1
  122. package/dist/src/encoder/encoder.js +5 -4
  123. package/dist/src/fonts/bmFontManager.d.ts +1 -1
  124. package/dist/src/fonts/bmFontManager.d.ts.map +1 -1
  125. package/dist/src/fonts/bmFontManager.js +45 -10
  126. package/dist/src/fonts/textMetrics.d.ts +69 -0
  127. package/dist/src/fonts/textMetrics.d.ts.map +1 -0
  128. package/dist/src/fonts/textMetrics.js +73 -0
  129. package/dist/src/genomeSpy/headlessBootstrap.d.ts.map +1 -1
  130. package/dist/src/genomeSpy/headlessBootstrap.js +6 -0
  131. package/dist/src/genomeSpy/renderCoordinator.d.ts.map +1 -1
  132. package/dist/src/genomeSpy/renderCoordinator.js +25 -3
  133. package/dist/src/genomeSpy/viewDataInit.d.ts +14 -0
  134. package/dist/src/genomeSpy/viewDataInit.d.ts.map +1 -1
  135. package/dist/src/genomeSpy/viewDataInit.js +45 -8
  136. package/dist/src/genomeSpyBase.d.ts +6 -0
  137. package/dist/src/genomeSpyBase.d.ts.map +1 -1
  138. package/dist/src/genomeSpyBase.js +20 -3
  139. package/dist/src/gl/glslScaleGenerator.d.ts +17 -0
  140. package/dist/src/gl/glslScaleGenerator.d.ts.map +1 -1
  141. package/dist/src/gl/glslScaleGenerator.js +39 -2
  142. package/dist/src/gl/includes/common.glsl.js +1 -1
  143. package/dist/src/gl/vertexRangeIndex.d.ts.map +1 -1
  144. package/dist/src/gl/vertexRangeIndex.js +4 -2
  145. package/dist/src/gl/webGLHelper.d.ts +1 -1
  146. package/dist/src/gl/webGLHelper.d.ts.map +1 -1
  147. package/dist/src/gl/webGLHelper.js +13 -8
  148. package/dist/src/marks/__snapshots__/shaderSnapshot.test.js.snap +140 -3
  149. package/dist/src/marks/mark.d.ts +47 -4
  150. package/dist/src/marks/mark.d.ts.map +1 -1
  151. package/dist/src/marks/mark.js +158 -54
  152. package/dist/src/marks/point.d.ts.map +1 -1
  153. package/dist/src/marks/point.js +4 -0
  154. package/dist/src/marks/point.vertex.glsl.js +1 -1
  155. package/dist/src/marks/text.d.ts +1 -1
  156. package/dist/src/marks/text.d.ts.map +1 -1
  157. package/dist/src/marks/text.js +2 -7
  158. package/dist/src/marks/text.vertex.glsl.js +1 -1
  159. package/dist/src/paramRuntime/paramUtils.d.ts +43 -9
  160. package/dist/src/paramRuntime/paramUtils.d.ts.map +1 -1
  161. package/dist/src/paramRuntime/paramUtils.js +61 -1
  162. package/dist/src/paramRuntime/viewParamRuntime.d.ts +32 -0
  163. package/dist/src/paramRuntime/viewParamRuntime.d.ts.map +1 -1
  164. package/dist/src/paramRuntime/viewParamRuntime.js +63 -0
  165. package/dist/src/scales/axisResolution.d.ts +35 -0
  166. package/dist/src/scales/axisResolution.d.ts.map +1 -1
  167. package/dist/src/scales/axisResolution.js +115 -7
  168. package/dist/src/scales/legendResolution.d.ts +83 -0
  169. package/dist/src/scales/legendResolution.d.ts.map +1 -0
  170. package/dist/src/scales/legendResolution.js +461 -0
  171. package/dist/src/scales/scaleResolution.d.ts +36 -0
  172. package/dist/src/scales/scaleResolution.d.ts.map +1 -1
  173. package/dist/src/scales/scaleResolution.js +59 -0
  174. package/dist/src/scales/viewLevelGuideConfig.d.ts +53 -0
  175. package/dist/src/scales/viewLevelGuideConfig.d.ts.map +1 -0
  176. package/dist/src/scales/viewLevelGuideConfig.js +224 -0
  177. package/dist/src/scales/viewLevelScaleConfig.d.ts.map +1 -1
  178. package/dist/src/scales/viewLevelScaleConfig.js +13 -2
  179. package/dist/src/spec/axis.d.ts +109 -3
  180. package/dist/src/spec/channel.d.ts +23 -4
  181. package/dist/src/spec/config.d.ts +59 -4
  182. package/dist/src/spec/data.d.ts +177 -17
  183. package/dist/src/spec/legend.d.ts +246 -0
  184. package/dist/src/spec/mark.d.ts +16 -4
  185. package/dist/src/spec/title.d.ts +58 -1
  186. package/dist/src/spec/transform.d.ts +149 -0
  187. package/dist/src/spec/view.d.ts +39 -6
  188. package/dist/src/types/embedApi.d.ts +262 -6
  189. package/dist/src/types/rendering.d.ts +19 -3
  190. package/dist/src/types/viewContext.d.ts +18 -2
  191. package/dist/src/utils/arrayUtils.d.ts +11 -0
  192. package/dist/src/utils/arrayUtils.d.ts.map +1 -1
  193. package/dist/src/utils/arrayUtils.js +23 -0
  194. package/dist/src/utils/suspension.d.ts +17 -0
  195. package/dist/src/utils/suspension.d.ts.map +1 -0
  196. package/dist/src/utils/suspension.js +41 -0
  197. package/dist/src/view/axisGridView.d.ts.map +1 -1
  198. package/dist/src/view/axisGridView.js +1 -4
  199. package/dist/src/view/axisView.d.ts +18 -2
  200. package/dist/src/view/axisView.d.ts.map +1 -1
  201. package/dist/src/view/axisView.js +180 -75
  202. package/dist/src/view/concatView.d.ts +10 -2
  203. package/dist/src/view/concatView.d.ts.map +1 -1
  204. package/dist/src/view/concatView.js +46 -9
  205. package/dist/src/view/containerMutationHelper.d.ts +20 -1
  206. package/dist/src/view/containerMutationHelper.d.ts.map +1 -1
  207. package/dist/src/view/containerMutationHelper.js +196 -33
  208. package/dist/src/view/facetView.d.ts +1 -1
  209. package/dist/src/view/gridView/gridChild.d.ts +54 -4
  210. package/dist/src/view/gridView/gridChild.d.ts.map +1 -1
  211. package/dist/src/view/gridView/gridChild.js +301 -120
  212. package/dist/src/view/gridView/gridChildLegends.d.ts +57 -0
  213. package/dist/src/view/gridView/gridChildLegends.d.ts.map +1 -0
  214. package/dist/src/view/gridView/gridChildLegends.js +503 -0
  215. package/dist/src/view/gridView/gridView.d.ts +25 -0
  216. package/dist/src/view/gridView/gridView.d.ts.map +1 -1
  217. package/dist/src/view/gridView/gridView.js +490 -78
  218. package/dist/src/view/gridView/legendLayout.d.ts +30 -0
  219. package/dist/src/view/gridView/legendLayout.d.ts.map +1 -0
  220. package/dist/src/view/gridView/legendLayout.js +115 -0
  221. package/dist/src/view/gridView/scrollbar.d.ts.map +1 -1
  222. package/dist/src/view/gridView/scrollbar.js +1 -4
  223. package/dist/src/view/gridView/selectionRect.d.ts.map +1 -1
  224. package/dist/src/view/gridView/selectionRect.js +1 -4
  225. package/dist/src/view/gridView/separatorView.d.ts.map +1 -1
  226. package/dist/src/view/gridView/separatorView.js +1 -4
  227. package/dist/src/view/layerView.d.ts +9 -2
  228. package/dist/src/view/layerView.d.ts.map +1 -1
  229. package/dist/src/view/layerView.js +18 -1
  230. package/dist/src/view/layout/flexLayout.d.ts +20 -4
  231. package/dist/src/view/layout/flexLayout.d.ts.map +1 -1
  232. package/dist/src/view/layout/flexLayout.js +331 -31
  233. package/dist/src/view/layout/rectangle.d.ts +14 -0
  234. package/dist/src/view/layout/rectangle.d.ts.map +1 -1
  235. package/dist/src/view/layout/rectangle.js +40 -0
  236. package/dist/src/view/legend/legendEntries.d.ts +20 -0
  237. package/dist/src/view/legend/legendEntries.d.ts.map +1 -0
  238. package/dist/src/view/legend/legendEntries.js +21 -0
  239. package/dist/src/view/legendView.d.ts +137 -0
  240. package/dist/src/view/legendView.d.ts.map +1 -0
  241. package/dist/src/view/legendView.js +1654 -0
  242. package/dist/src/view/renderingContext/bufferedViewRenderingContext.d.ts.map +1 -1
  243. package/dist/src/view/renderingContext/bufferedViewRenderingContext.js +26 -4
  244. package/dist/src/view/renderingContext/clipOptions.d.ts +44 -0
  245. package/dist/src/view/renderingContext/clipOptions.d.ts.map +1 -0
  246. package/dist/src/view/renderingContext/clipOptions.js +140 -0
  247. package/dist/src/view/renderingContext/simpleViewRenderingContext.d.ts.map +1 -1
  248. package/dist/src/view/renderingContext/simpleViewRenderingContext.js +12 -1
  249. package/dist/src/view/resolutionPlanner.d.ts +2 -1
  250. package/dist/src/view/resolutionPlanner.d.ts.map +1 -1
  251. package/dist/src/view/resolutionPlanner.js +89 -25
  252. package/dist/src/view/testUtils.d.ts +4 -2
  253. package/dist/src/view/testUtils.d.ts.map +1 -1
  254. package/dist/src/view/testUtils.js +60 -7
  255. package/dist/src/view/titleView.d.ts +37 -0
  256. package/dist/src/view/titleView.d.ts.map +1 -0
  257. package/dist/src/view/titleView.js +584 -0
  258. package/dist/src/view/unitView.d.ts +3 -3
  259. package/dist/src/view/unitView.d.ts.map +1 -1
  260. package/dist/src/view/unitView.js +3 -2
  261. package/dist/src/view/view.d.ts +25 -24
  262. package/dist/src/view/view.d.ts.map +1 -1
  263. package/dist/src/view/view.js +126 -16
  264. package/dist/src/view/viewChrome.d.ts +33 -0
  265. package/dist/src/view/viewChrome.d.ts.map +1 -0
  266. package/dist/src/view/viewChrome.js +64 -0
  267. package/dist/src/view/viewFactory.d.ts +2 -5
  268. package/dist/src/view/viewFactory.d.ts.map +1 -1
  269. package/dist/src/view/viewFactory.js +1 -2
  270. package/dist/src/view/viewIdentityRegistry.d.ts +37 -0
  271. package/dist/src/view/viewIdentityRegistry.d.ts.map +1 -0
  272. package/dist/src/view/viewIdentityRegistry.js +71 -0
  273. package/dist/src/view/viewMutationAcidTestUtils.d.ts +112 -0
  274. package/dist/src/view/viewMutationAcidTestUtils.d.ts.map +1 -0
  275. package/dist/src/view/viewMutationAcidTestUtils.js +234 -0
  276. package/dist/src/view/viewMutationApi.d.ts +42 -0
  277. package/dist/src/view/viewMutationApi.d.ts.map +1 -0
  278. package/dist/src/view/viewMutationApi.js +811 -0
  279. package/dist/src/view/viewSelectors.d.ts +11 -9
  280. package/dist/src/view/viewSelectors.d.ts.map +1 -1
  281. package/dist/src/view/viewSelectors.js +28 -17
  282. package/package.json +4 -4
  283. package/dist/bundle/esm-CuMSzCHy.js +0 -298
  284. package/dist/bundle/esm-DAnOffpD.js +0 -1426
  285. package/dist/bundle/esm-DMXpJXM4.js +0 -369
  286. package/dist/bundle/esm-DNtC3H80.js +0 -121
  287. package/dist/src/view/title.d.ts +0 -13
  288. package/dist/src/view/title.d.ts.map +0 -1
  289. package/dist/src/view/title.js +0 -154
  290. /package/dist/bundle/{AbortablePromiseCache-3gHJdF3E.js → AbortablePromiseCache-BTmAcN-t.js} +0 -0
  291. /package/dist/bundle/{esm-CuVa5T98.js → esm-VvpZ9hsq.js} +0 -0
  292. /package/dist/bundle/{chunk-DmhlhrBa.js → rolldown-runtime-Dy4uBu1J.js} +0 -0
@@ -0,0 +1,1654 @@
1
+ /**
2
+ * Legend generation is largely modeled after Vega and Vega-Lite legends,
3
+ * including the public properties, default values, and legend-type/entry
4
+ * logic. The most relevant references are:
5
+ *
6
+ * - vega-lite/src/legend.ts
7
+ * - vega-lite/src/compile/legend/*
8
+ * - vega/packages/vega-parser/src/parsers/legend.js
9
+ * - vega/packages/vega-parser/src/parsers/guides/legend-*.js
10
+ * - vega/packages/vega-view-transforms/src/layout/legend.js
11
+ *
12
+ * GenomeSpy's implementation is intentionally different. Vega emits a Vega
13
+ * legend definition and relies on the scenegraph plus legend layout transform
14
+ * for final mark bounds and placement. GenomeSpy instead builds legends from
15
+ * ordinary internal views and marks, uses `measureText`/`packLegendLabels` for
16
+ * symbol entry layout, and lets `GridChild` own local side/corner placement and
17
+ * stacked legend regions.
18
+ */
19
+
20
+ import ContainerView from "./containerView.js";
21
+ import {
22
+ FlexDimensions,
23
+ getLargestSize,
24
+ getSizeDefMaxPx,
25
+ getSizeDefMinPx,
26
+ mapToPixelCoords,
27
+ sumSizeDefs,
28
+ } from "./layout/flexLayout.js";
29
+ import Rectangle from "./layout/rectangle.js";
30
+ import UnitView from "./unitView.js";
31
+ import { markViewAsChrome, markViewAsNonAddressable } from "./viewSelectors.js";
32
+ import { truncateText } from "../data/transforms/truncateText.js";
33
+ import { measureText, requestFont } from "../fonts/textMetrics.js";
34
+
35
+ const LABEL_WIDTH_FIELD = "_legendLabelWidth";
36
+ const SYMBOL_SIZE_FIELD = "_legendSymbolSize";
37
+ const SYMBOL_STROKE_WIDTH_FIELD = "_legendStrokeWidth";
38
+ const DEFAULT_GRADIENT_LEGEND_LENGTH = 200;
39
+ const DEFAULT_GRADIENT_SAMPLE_COUNT = 64;
40
+ const DEFAULT_GRADIENT_TICK_COUNT = 5;
41
+ const DEFAULT_GRADIENT_THICKNESS = 12;
42
+ const DEFAULT_GRADIENT_TICK_SIZE = 4;
43
+ const MIN_GRADIENT_LEGEND_LENGTH = 40;
44
+ const AUTO_EXTENT_GROW_THRESHOLD_PX = 2;
45
+ /** @type {import("../spec/view.js").ViewBackground} */
46
+ const LEGEND_VIEW_BACKGROUND = {
47
+ fillOpacity: 0,
48
+ shadowOpacity: 0,
49
+ strokeOpacity: 0,
50
+ };
51
+
52
+ /**
53
+ * Legend internals use scale-backed helper marks but must not create their own
54
+ * axes or legends from inherited configuration.
55
+ *
56
+ * This must be applied to every generated child spec, not just the LegendView
57
+ * root. The generated subtree contains ordinary unit views, including text
58
+ * views and helper marks with internal x/y placement channels. Those channels
59
+ * are implementation details of the legend, even when they are value-backed and
60
+ * not semantically scale-backed data encodings.
61
+ *
62
+ * Without this exclusion, a generated child can still end up with an axis
63
+ * resolution and inherit axis defaults such as `grid: true`, creating an
64
+ * AxisGridView inside the legend. This has been observed with the title view:
65
+ * the authored title encoding only had `text`, but the unexcluded generated
66
+ * title still received an x-axis resolution through the normal guide machinery.
67
+ *
68
+ * The legend may still force the represented source scale, e.g. color/size, but
69
+ * generated helper x/y channels and nested legend channels must stay out of the
70
+ * normal guide resolution machinery.
71
+ *
72
+ * See:
73
+ * - https://github.com/genome-spy/genome-spy/issues/412
74
+ * - https://github.com/genome-spy/genome-spy/issues/413
75
+ *
76
+ * @template {import("../spec/view.js").ViewSpec & {
77
+ * resolve?: any,
78
+ * layer?: any[],
79
+ * vconcat?: any[],
80
+ * hconcat?: any[]
81
+ * }} T
82
+ * @param {T} spec
83
+ * @returns {T}
84
+ */
85
+ function excludeLegendGuideResolutions(spec) {
86
+ spec.resolve = {
87
+ ...spec.resolve,
88
+ axis: { default: "excluded", ...spec.resolve?.axis },
89
+ legend: { default: "excluded", ...spec.resolve?.legend },
90
+ };
91
+
92
+ for (const children of [spec.layer, spec.vconcat, spec.hconcat]) {
93
+ children?.forEach(excludeLegendGuideResolutions);
94
+ }
95
+
96
+ return spec;
97
+ }
98
+
99
+ /**
100
+ * @typedef {import("../spec/legend.js").LegendConfig} LegendConfig
101
+ * @typedef {import("./legend/legendEntries.js").LegendEntry} LegendEntry
102
+ * @typedef {(import("../spec/view.js").VConcatSpec | import("../spec/view.js").HConcatSpec) & {
103
+ * view: import("../spec/view.js").ViewBackground
104
+ * }} LegendRootSpec
105
+ * @typedef {{
106
+ * mark?: Partial<import("../spec/mark.js").PointProps>,
107
+ * encoding?: Partial<import("../spec/channel.js").Encoding>
108
+ * }} SymbolLegendStyle
109
+ */
110
+
111
+ /**
112
+ * @param {LegendConfig} legend
113
+ * @returns {import("../spec/view.js").ViewBackground}
114
+ */
115
+ function createLegendViewBackground(legend) {
116
+ return {
117
+ fill: legend.backgroundFill,
118
+ fillOpacity: legend.backgroundFill
119
+ ? (legend.backgroundFillOpacity ?? 1)
120
+ : 0,
121
+ stroke: legend.backgroundStroke,
122
+ strokeWidth: legend.backgroundStrokeWidth,
123
+ strokeOpacity: legend.backgroundStroke
124
+ ? (legend.backgroundStrokeOpacity ?? 1)
125
+ : 0,
126
+ shadowOpacity: 0,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * @param {LegendConfig} legend
132
+ * @param {import("../types/viewContext.js").default} context
133
+ * @returns {import("../spec/view.js").UnitSpec | undefined}
134
+ */
135
+ function createLegendTitleSpec(legend, context) {
136
+ const title = legend.title;
137
+
138
+ if (!title) {
139
+ return undefined;
140
+ }
141
+
142
+ const titleFontSize = legend.titleFontSize ?? 11;
143
+ const titlePadding = legend.titlePadding ?? 5;
144
+ const sideTitle = isSideTitle(legend);
145
+ const titleWidth = Math.ceil(getTitleWidth(legend, context));
146
+ const sideTitleWidth = titleWidth + titlePadding;
147
+
148
+ return {
149
+ name: "title",
150
+ width: sideTitle ? sideTitleWidth : undefined,
151
+ height: sideTitle ? { grow: 1 } : titleFontSize + titlePadding,
152
+ view: LEGEND_VIEW_BACKGROUND,
153
+ data: {
154
+ values: [{ label: title }],
155
+ },
156
+ transform: [
157
+ {
158
+ type: "truncateText",
159
+ field: "label",
160
+ limit: legend.titleLimit,
161
+ fontSize: titleFontSize,
162
+ font: legend.titleFont,
163
+ fontStyle: legend.titleFontStyle,
164
+ fontWeight: legend.titleFontWeight,
165
+ },
166
+ ],
167
+ mark: {
168
+ type: "text",
169
+ clip: false,
170
+ x:
171
+ getTitleOrient(legend) == "right" && sideTitleWidth > 0
172
+ ? titlePadding / sideTitleWidth
173
+ : 0,
174
+ y: 0.5,
175
+ align: "left",
176
+ baseline: "middle",
177
+ color: legend.titleColor,
178
+ font: legend.titleFont,
179
+ fontStyle: legend.titleFontStyle,
180
+ fontWeight: legend.titleFontWeight,
181
+ size: titleFontSize,
182
+ },
183
+ encoding: {
184
+ text: { field: "label" },
185
+ },
186
+ };
187
+ }
188
+
189
+ /**
190
+ * @param {LegendConfig} legend
191
+ * @param {import("../spec/view.js").ViewSpec} body
192
+ * @param {import("../types/viewContext.js").default} context
193
+ * @param {import("../spec/channel.js").ChannelWithScale[]} [forcedScaleChannels]
194
+ * @returns {LegendRootSpec}
195
+ */
196
+ function createLegendRootSpec(legend, body, context, forcedScaleChannels = []) {
197
+ const title = createLegendTitleSpec(legend, context);
198
+ /** @type {{ vconcat: import("../spec/view.js").ViewSpec[] } | { hconcat: import("../spec/view.js").ViewSpec[] }} */
199
+ let children;
200
+
201
+ if (!title) {
202
+ children = { vconcat: [body] };
203
+ } else if (getTitleOrient(legend) == "bottom") {
204
+ children = { vconcat: [body, title] };
205
+ } else if (getTitleOrient(legend) == "left") {
206
+ children = { hconcat: [title, body] };
207
+ } else if (getTitleOrient(legend) == "right") {
208
+ children = { hconcat: [body, title] };
209
+ } else {
210
+ children = { vconcat: [title, body] };
211
+ }
212
+
213
+ return excludeLegendGuideResolutions({
214
+ name: "legend_" + (legend.orient ?? "right"),
215
+ padding: legend.padding,
216
+ view: createLegendViewBackground(legend),
217
+ resolve: {
218
+ scale: Object.fromEntries(
219
+ forcedScaleChannels.map((channel) => [channel, "forced"])
220
+ ),
221
+ },
222
+ spacing: 0,
223
+ ...children,
224
+ });
225
+ }
226
+
227
+ /**
228
+ * @param {LegendConfig} legend
229
+ */
230
+ function isTopBottomLegend(legend) {
231
+ return legend.orient == "top" || legend.orient == "bottom";
232
+ }
233
+
234
+ /**
235
+ * @param {LegendConfig} legend
236
+ */
237
+ function isHorizontalLegend(legend) {
238
+ return isTopBottomLegend(legend) || legend.direction == "horizontal";
239
+ }
240
+
241
+ /**
242
+ * @param {LegendConfig} legend
243
+ * @returns {import("../spec/legend.js").LegendTitleOrient}
244
+ */
245
+ function getTitleOrient(legend) {
246
+ return legend.titleOrient ?? "top";
247
+ }
248
+
249
+ /**
250
+ * @param {LegendConfig} legend
251
+ */
252
+ function isSideTitle(legend) {
253
+ const titleOrient = getTitleOrient(legend);
254
+
255
+ return titleOrient == "left" || titleOrient == "right";
256
+ }
257
+
258
+ /**
259
+ * @param {string | undefined} value
260
+ * @returns {import("../spec/channel.js").ValueDef | undefined}
261
+ */
262
+ function createBaseColorEncoding(value) {
263
+ if (value === undefined) {
264
+ return undefined;
265
+ } else if (value == "transparent") {
266
+ return { value: null };
267
+ } else {
268
+ return { value };
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Rule and link marks use the `size` channel as stroke width. Vega/Vega-Lite
274
+ * still represent comparable guides as symbol legends; GenomeSpy renders this
275
+ * variant as a short rule so the legend explains stroke width directly.
276
+ *
277
+ * @param {object} options
278
+ * @param {import("../spec/channel.js").ChannelWithScale} options.channel
279
+ * @param {import("../spec/channel.js").Type} options.dataType
280
+ * @param {import("../spec/scale.js").Scale} options.horizontalPixelScale
281
+ * @param {import("../spec/scale.js").Scale} options.verticalPixelScale
282
+ * @param {LegendConfig} options.legend
283
+ * @param {SymbolLegendStyle} options.symbolStyle
284
+ * @returns {import("../spec/view.js").UnitSpec}
285
+ */
286
+ function createStrokeSymbolLayer({
287
+ channel,
288
+ dataType,
289
+ horizontalPixelScale,
290
+ verticalPixelScale,
291
+ legend,
292
+ symbolStyle,
293
+ }) {
294
+ const styleEncoding =
295
+ /** @type {Partial<import("../spec/channel.js").Encoding>} */ (
296
+ symbolStyle.encoding ?? {}
297
+ );
298
+ const color =
299
+ styleEncoding.color ??
300
+ styleEncoding.stroke ??
301
+ styleEncoding.fill ??
302
+ createBaseColorEncoding(legend.symbolBaseStrokeColor);
303
+
304
+ return {
305
+ name: "symbols",
306
+ mark: {
307
+ type: "rule",
308
+ clip: false,
309
+ cullByVisibleRange: false,
310
+ opacity: symbolStyle.mark?.opacity,
311
+ },
312
+ encoding: {
313
+ x: {
314
+ field: "symbolX",
315
+ type: "quantitative",
316
+ scale: horizontalPixelScale,
317
+ axis: null,
318
+ buildIndex: false,
319
+ },
320
+ x2: {
321
+ field: "symbolX2",
322
+ type: "quantitative",
323
+ scale: horizontalPixelScale,
324
+ axis: null,
325
+ },
326
+ y: {
327
+ field: "labelY2",
328
+ type: "quantitative",
329
+ scale: verticalPixelScale,
330
+ axis: null,
331
+ },
332
+ y2: {
333
+ field: "labelY2",
334
+ type: "quantitative",
335
+ scale: verticalPixelScale,
336
+ axis: null,
337
+ },
338
+ [channel]: {
339
+ field: "value",
340
+ type: dataType,
341
+ domainInert: true,
342
+ },
343
+ ...(color ? { color } : {}),
344
+ ...(styleEncoding.opacity
345
+ ? { opacity: styleEncoding.opacity }
346
+ : {}),
347
+ },
348
+ };
349
+ }
350
+
351
+ /**
352
+ * @param {object} options
353
+ * @param {LegendEntry[]} [options.entries]
354
+ * @param {import("../spec/channel.js").ChannelWithScale} options.channel
355
+ * @param {Partial<Record<import("../spec/channel.js").ChannelWithScale, string>>} [options.symbolChannels]
356
+ * @param {SymbolLegendStyle} [options.symbolStyle]
357
+ * @param {"point" | "stroke"} [options.symbolGeometry]
358
+ * @param {LegendConfig} options.legend
359
+ * @param {string} [options.format]
360
+ * @param {import("../spec/channel.js").Type} options.dataType
361
+ * @param {import("../types/viewContext.js").default} options.context
362
+ * @returns {LegendRootSpec}
363
+ */
364
+ export function createSymbolLegendSpec({
365
+ entries,
366
+ channel,
367
+ symbolChannels = {},
368
+ symbolStyle = {},
369
+ symbolGeometry = "point",
370
+ legend,
371
+ format,
372
+ dataType,
373
+ context,
374
+ }) {
375
+ const strokeSymbol = symbolGeometry == "stroke";
376
+ const horizontalLegend = isHorizontalLegend(legend);
377
+ const labelAlign = legend.labelAlign ?? "left";
378
+ const labelBaseline = legend.labelBaseline ?? "middle";
379
+ const labelFontSize = legend.labelFontSize ?? 10;
380
+ const scaledSymbolChannels = new Set([
381
+ channel,
382
+ ...Object.keys(symbolChannels),
383
+ ]);
384
+ const isBaseColorChannelScaled = (
385
+ /** @type {import("../spec/channel.js").ChannelWithScale} */ channel
386
+ ) => scaledSymbolChannels.has(channel) || scaledSymbolChannels.has("color");
387
+ const baseSymbolEncoding = Object.fromEntries(
388
+ /** @type {const} */ ([
389
+ ["fill", createBaseColorEncoding(legend.symbolBaseFillColor)],
390
+ ["stroke", createBaseColorEncoding(legend.symbolBaseStrokeColor)],
391
+ ])
392
+ .filter(
393
+ ([channel, encoding]) =>
394
+ !isBaseColorChannelScaled(channel) && encoding !== undefined
395
+ )
396
+ .map(([channel, encoding]) => [channel, encoding])
397
+ );
398
+ const horizontalPixelScale = {
399
+ domain: [0, { expr: "width" }],
400
+ zero: false,
401
+ nice: false,
402
+ };
403
+ const verticalPixelScale = {
404
+ domain: [0, { expr: "height" }],
405
+ zero: false,
406
+ nice: false,
407
+ };
408
+
409
+ /** @type {import("../spec/view.js").UnitSpec[]} */
410
+ const layer = [
411
+ strokeSymbol
412
+ ? createStrokeSymbolLayer({
413
+ channel,
414
+ dataType,
415
+ horizontalPixelScale,
416
+ verticalPixelScale,
417
+ legend,
418
+ symbolStyle,
419
+ })
420
+ : {
421
+ name: "symbols",
422
+ mark: {
423
+ type: "point",
424
+ clip: false,
425
+ cullByVisibleRange: false,
426
+ filled: channel == "fill",
427
+ shape: legend.symbolType,
428
+ size: legend.symbolSize,
429
+ strokeWidth: legend.symbolStrokeWidth,
430
+ ...symbolStyle.mark,
431
+ },
432
+ encoding: {
433
+ x: {
434
+ field: "entryX",
435
+ type: "quantitative",
436
+ scale: horizontalPixelScale,
437
+ axis: null,
438
+ buildIndex: false,
439
+ },
440
+ y: {
441
+ field: "labelY2",
442
+ type: "quantitative",
443
+ scale: verticalPixelScale,
444
+ axis: null,
445
+ },
446
+ [channel]: {
447
+ field: "value",
448
+ type: dataType,
449
+ domainInert: true,
450
+ },
451
+ ...baseSymbolEncoding,
452
+ ...symbolStyle.encoding,
453
+ ...Object.fromEntries(
454
+ Object.entries(symbolChannels).map(([channel]) => [
455
+ channel,
456
+ {
457
+ field: "value",
458
+ type: dataType,
459
+ domainInert: true,
460
+ },
461
+ ])
462
+ ),
463
+ },
464
+ },
465
+ ];
466
+
467
+ layer.push({
468
+ name: "labels",
469
+ mark: {
470
+ type: "text",
471
+ clip: false,
472
+ cullByVisibleRange: false,
473
+ align: labelAlign,
474
+ baseline: labelBaseline,
475
+ color: legend.labelColor,
476
+ font: legend.labelFont,
477
+ fontStyle: legend.labelFontStyle,
478
+ fontWeight: legend.labelFontWeight,
479
+ size: labelFontSize,
480
+ },
481
+ encoding: {
482
+ x: {
483
+ field: "labelX",
484
+ type: "quantitative",
485
+ scale: horizontalPixelScale,
486
+ axis: null,
487
+ buildIndex: false,
488
+ },
489
+ y: {
490
+ field: "labelY2",
491
+ type: "quantitative",
492
+ scale: verticalPixelScale,
493
+ axis: null,
494
+ },
495
+ text: { field: "label" },
496
+ },
497
+ });
498
+
499
+ return createLegendRootSpec(
500
+ legend,
501
+ {
502
+ name: "legendBody",
503
+ height: { grow: 1 },
504
+ view: LEGEND_VIEW_BACKGROUND,
505
+ resolve: {
506
+ scale: { x: "excluded", y: "excluded" },
507
+ axis: { x: "excluded", y: "excluded" },
508
+ },
509
+ data: entries
510
+ ? { values: entries }
511
+ : {
512
+ lazy: {
513
+ type: "legendEntries",
514
+ channel,
515
+ dataType,
516
+ format,
517
+ values: legend.values,
518
+ sizeMode: strokeSymbol ? "strokeWidth" : "area",
519
+ },
520
+ },
521
+ transform: [
522
+ {
523
+ type: "truncateText",
524
+ field: "label",
525
+ limit: legend.labelLimit,
526
+ fontSize: labelFontSize,
527
+ font: legend.labelFont,
528
+ fontStyle: legend.labelFontStyle,
529
+ fontWeight: legend.labelFontWeight,
530
+ },
531
+ {
532
+ type: "measureText",
533
+ field: "label",
534
+ as: LABEL_WIDTH_FIELD,
535
+ fontSize: labelFontSize,
536
+ font: legend.labelFont,
537
+ fontStyle: legend.labelFontStyle,
538
+ fontWeight: legend.labelFontWeight,
539
+ },
540
+ {
541
+ type: "packLegendLabels",
542
+ labelWidth: LABEL_WIDTH_FIELD,
543
+ columns: legend.columns,
544
+ symbolSize: strokeSymbol
545
+ ? legend.symbolSize
546
+ : channel == "size"
547
+ ? SYMBOL_SIZE_FIELD
548
+ : legend.symbolSize,
549
+ symbolStrokeWidth: strokeSymbol
550
+ ? SYMBOL_STROKE_WIDTH_FIELD
551
+ : legend.symbolStrokeWidth,
552
+ labelOffset: legend.labelOffset,
553
+ fontSize: labelFontSize,
554
+ rowPadding: legend.rowPadding,
555
+ columnPadding: legend.columnPadding,
556
+ symbolOffset: legend.symbolOffset,
557
+ yOffset: 0,
558
+ yExtent: { expr: "height" },
559
+ direction: horizontalLegend
560
+ ? "horizontal"
561
+ : (legend.direction ?? "vertical"),
562
+ },
563
+ ],
564
+ layer,
565
+ },
566
+ context,
567
+ [channel, ...Object.keys(symbolChannels)]
568
+ );
569
+ }
570
+
571
+ /**
572
+ * @param {object} options
573
+ * @param {import("../spec/channel.js").ChannelWithScale} options.channel
574
+ * @param {LegendConfig} options.legend
575
+ * @param {string} [options.format]
576
+ * @param {import("../types/viewContext.js").default} options.context
577
+ * @returns {LegendRootSpec}
578
+ */
579
+ export function createGradientLegendSpec({ channel, legend, format, context }) {
580
+ const h = isHorizontalLegend(legend);
581
+ const labelAlign = h ? "center" : (legend.labelAlign ?? "left");
582
+ const labelBaseline = h ? "top" : (legend.labelBaseline ?? "middle");
583
+ const labelFontSize = legend.labelFontSize ?? 10;
584
+ const labelOffset = legend.labelOffset ?? 4;
585
+ const xs = {
586
+ domain: [0, { expr: "width" }],
587
+ zero: false,
588
+ nice: false,
589
+ };
590
+ const ys = {
591
+ domain: [0, { expr: "height" }],
592
+ zero: false,
593
+ nice: false,
594
+ };
595
+ const ps = {
596
+ domain: [0, 1],
597
+ domainTransition: false,
598
+ zero: false,
599
+ nice: false,
600
+ };
601
+ const labelY = labelFontSize;
602
+ const tickY = labelY + labelOffset;
603
+ const tickY2 = tickY + DEFAULT_GRADIENT_TICK_SIZE;
604
+ const gradientY = tickY2;
605
+ const gradientY2 = gradientY + DEFAULT_GRADIENT_THICKNESS;
606
+ const tickX = DEFAULT_GRADIENT_THICKNESS;
607
+ const tickX2 = DEFAULT_GRADIENT_THICKNESS + DEFAULT_GRADIENT_TICK_SIZE;
608
+ const labelX = tickX2 + labelOffset;
609
+ // p is the gradient axis; q is the cross axis.
610
+ const p = h ? "x" : "y";
611
+ const p2 = /** @type {"x2" | "y2"} */ (p + "2");
612
+ const q = h ? "y" : "x";
613
+ const q2 = /** @type {"x2" | "y2"} */ (q + "2");
614
+ const qs = h ? ys : xs;
615
+ const band0 = "_legendGradientBandStart";
616
+ const band1 = "_legendGradientBandStop";
617
+ const tick0 = "_legendGradientTickStart";
618
+ const tick1 = "_legendGradientTickStop";
619
+ const label = "_legendGradientLabelPosition";
620
+ /**
621
+ * @param {string} field
622
+ * @param {import("../spec/scale.js").Scale} scale
623
+ * @param {boolean} indexed
624
+ * @returns {import("../spec/channel.js").PositionFieldDef}
625
+ */
626
+ const enc = (field, scale, indexed) =>
627
+ /** @type {import("../spec/channel.js").PositionFieldDef} */ ({
628
+ field,
629
+ type: "quantitative",
630
+ scale,
631
+ axis: null,
632
+ ...(indexed ? { buildIndex: false } : {}),
633
+ });
634
+ /** @type {import("../spec/data.js").Data} */
635
+ const tickData = {
636
+ lazy: {
637
+ type: "legendGradientTicks",
638
+ channel,
639
+ count: DEFAULT_GRADIENT_TICK_COUNT,
640
+ format,
641
+ values: legend.values,
642
+ },
643
+ };
644
+ /** @type {import("../spec/transform.js").FormulaParams[]} */
645
+ const tickTransform = [
646
+ {
647
+ type: "formula",
648
+ expr: "" + (h ? tickY : tickX),
649
+ as: tick0,
650
+ },
651
+ {
652
+ type: "formula",
653
+ expr: "" + (h ? tickY2 : tickX2),
654
+ as: tick1,
655
+ },
656
+ {
657
+ type: "formula",
658
+ expr: "" + (h ? labelY : labelX),
659
+ as: label,
660
+ },
661
+ ];
662
+ /** @type {import("../spec/transform.js").FormulaParams[]} */
663
+ const bandTransform = [
664
+ {
665
+ type: "formula",
666
+ expr: "" + (h ? gradientY : 0),
667
+ as: band0,
668
+ },
669
+ {
670
+ type: "formula",
671
+ expr: "" + (h ? gradientY2 : DEFAULT_GRADIENT_THICKNESS),
672
+ as: band1,
673
+ },
674
+ ];
675
+
676
+ /** @type {(import("../spec/view.js").UnitSpec | import("../spec/view.js").LayerSpec)[]} */
677
+ const bodyLayer = [
678
+ {
679
+ name: "gradientRamp",
680
+ transform: bandTransform,
681
+ mark: {
682
+ type: "rect",
683
+ clip: false,
684
+ },
685
+ encoding: {
686
+ [p]: enc(h ? "position0" : "position1", ps, p == "x"),
687
+ [p2]: {
688
+ field: h ? "position1" : "position0",
689
+ type: "quantitative",
690
+ scale: ps,
691
+ },
692
+ [q]: enc(band0, qs, q == "x"),
693
+ [q2]: {
694
+ field: band1,
695
+ type: "quantitative",
696
+ scale: qs,
697
+ },
698
+ [channel]: {
699
+ field: "value",
700
+ type: "quantitative",
701
+ domainInert: true,
702
+ },
703
+ },
704
+ },
705
+ {
706
+ name: "gradientGuide",
707
+ data: tickData,
708
+ transform: tickTransform,
709
+ layer: [
710
+ {
711
+ name: "gradientTicks",
712
+ mark: {
713
+ type: "rule",
714
+ clip: false,
715
+ },
716
+ encoding: {
717
+ [p]: enc("position", ps, p == "x"),
718
+ [p2]: {
719
+ field: "position",
720
+ type: "quantitative",
721
+ scale: ps,
722
+ },
723
+ [q]: enc(tick0, qs, q == "x"),
724
+ [q2]: {
725
+ field: tick1,
726
+ type: "quantitative",
727
+ scale: qs,
728
+ },
729
+ },
730
+ },
731
+ {
732
+ name: "gradientLabels",
733
+ transform: [
734
+ {
735
+ type: "truncateText",
736
+ field: "label",
737
+ limit: legend.labelLimit,
738
+ fontSize: labelFontSize,
739
+ font: legend.labelFont,
740
+ fontStyle: legend.labelFontStyle,
741
+ fontWeight: legend.labelFontWeight,
742
+ },
743
+ {
744
+ type: "measureText",
745
+ field: "label",
746
+ as: LABEL_WIDTH_FIELD,
747
+ fontSize: labelFontSize,
748
+ font: legend.labelFont,
749
+ fontStyle: legend.labelFontStyle,
750
+ fontWeight: legend.labelFontWeight,
751
+ },
752
+ ],
753
+ mark: {
754
+ type: "text",
755
+ clip: false,
756
+ align: labelAlign,
757
+ baseline: labelBaseline,
758
+ color: legend.labelColor,
759
+ font: legend.labelFont,
760
+ fontStyle: legend.labelFontStyle,
761
+ fontWeight: legend.labelFontWeight,
762
+ size: labelFontSize,
763
+ },
764
+ encoding: {
765
+ [p]: enc("position", ps, p == "x"),
766
+ [q]: enc(label, qs, q == "x"),
767
+ text: { field: "label" },
768
+ },
769
+ },
770
+ ],
771
+ },
772
+ ];
773
+
774
+ return createLegendRootSpec(
775
+ legend,
776
+ {
777
+ name: "gradientBody",
778
+ width: h
779
+ ? { grow: 1, minPx: MIN_GRADIENT_LEGEND_LENGTH }
780
+ : undefined,
781
+ height: h
782
+ ? { grow: 1 }
783
+ : { grow: 1, minPx: MIN_GRADIENT_LEGEND_LENGTH },
784
+ view: LEGEND_VIEW_BACKGROUND,
785
+ resolve: {
786
+ scale: { x: "excluded", y: "excluded" },
787
+ axis: { x: "excluded", y: "excluded" },
788
+ },
789
+ data: {
790
+ lazy: {
791
+ type: "legendGradient",
792
+ channel,
793
+ count: DEFAULT_GRADIENT_SAMPLE_COUNT,
794
+ },
795
+ },
796
+ layer: bodyLayer,
797
+ },
798
+ context,
799
+ [channel]
800
+ );
801
+ }
802
+
803
+ /**
804
+ * @param {{
805
+ * getPerpendicularSize: () => number,
806
+ * getOffset: () => number
807
+ * } | undefined} legendView
808
+ * @returns {number}
809
+ */
810
+ export function getExternalLegendOverhang(legendView) {
811
+ return legendView
812
+ ? legendView.getPerpendicularSize() + legendView.getOffset()
813
+ : 0;
814
+ }
815
+
816
+ export class LegendRegionView extends ContainerView {
817
+ /** @type {import("./view.js").default | undefined} */
818
+ #child;
819
+
820
+ /** @type {LegendView[]} */
821
+ #legendViews = [];
822
+
823
+ #stackSpacing;
824
+
825
+ /**
826
+ * @param {import("../spec/legend.js").LegendOrient} orient
827
+ * @param {number} stackSpacing
828
+ * @param {import("../types/viewContext.js").default} context
829
+ * @param {import("./containerView.js").default} layoutParent
830
+ * @param {import("./view.js").default} dataParent
831
+ */
832
+ constructor(orient, stackSpacing, context, layoutParent, dataParent) {
833
+ super(
834
+ {
835
+ name: "legend_region_" + orient,
836
+ spacing: stackSpacing,
837
+ vconcat: [],
838
+ },
839
+ context,
840
+ layoutParent,
841
+ dataParent,
842
+ "legend_region_" + orient
843
+ );
844
+
845
+ this.needsAxes = { x: false, y: false };
846
+ this.orient = orient;
847
+ this.#stackSpacing = stackSpacing;
848
+
849
+ markViewAsNonAddressable(this, { skipSubtree: true });
850
+ markViewAsChrome(this, { skipSubtree: true });
851
+ }
852
+
853
+ async initializeChildren() {
854
+ this.#child = await this.context.createOrImportView(
855
+ {
856
+ name: "legendStack",
857
+ spacing: this.#stackSpacing,
858
+ vconcat: [],
859
+ },
860
+ this,
861
+ this,
862
+ this.getNextAutoName("legendStack")
863
+ );
864
+
865
+ markViewAsNonAddressable(this.#child, { skipSubtree: true });
866
+ markViewAsChrome(this.#child, { skipSubtree: true });
867
+ }
868
+
869
+ /**
870
+ * @param {LegendView} legendView
871
+ */
872
+ addLegendView(legendView) {
873
+ if (!this.#child) {
874
+ throw new Error("Legend region has not been initialized!");
875
+ }
876
+
877
+ legendView.layoutParent =
878
+ /** @type {import("./containerView.js").default} */ (this.#child);
879
+ this.#legendViews.push(legendView);
880
+ /** @type {any} */ (this.#child).appendChildView(legendView);
881
+ }
882
+
883
+ /**
884
+ * @returns {LegendView[]}
885
+ */
886
+ #getVisibleLegendViews() {
887
+ return this.#legendViews.filter((legendView) => legendView.isActive());
888
+ }
889
+
890
+ /**
891
+ * @returns {IterableIterator<import("./view.js").default>}
892
+ */
893
+ *[Symbol.iterator]() {
894
+ if (this.#child) {
895
+ yield this.#child;
896
+ }
897
+ }
898
+
899
+ getSize() {
900
+ const mainSize = this.#getParallelSizeDef();
901
+ const perpendicularSize = { px: this.getPerpendicularSize() };
902
+
903
+ if (this.orient == "top" || this.orient == "bottom") {
904
+ return new FlexDimensions(mainSize, perpendicularSize);
905
+ } else {
906
+ return new FlexDimensions(perpendicularSize, mainSize);
907
+ }
908
+ }
909
+
910
+ /**
911
+ * @returns {import("./layout/flexLayout.js").SizeDef}
912
+ */
913
+ #getParallelSizeDef() {
914
+ // Legend disable is reactive and separate from configured view
915
+ // visibility, so the internal legendStack concat cannot be used
916
+ // directly for region sizing.
917
+ const legendViews = this.#getVisibleLegendViews();
918
+
919
+ if (!legendViews.length) {
920
+ return { grow: 1 };
921
+ }
922
+
923
+ if (this.orient == "top" || this.orient == "bottom") {
924
+ return getLargestSize(
925
+ legendViews.map((legendView) => legendView.getSize().width)
926
+ );
927
+ } else {
928
+ /** @type {import("./layout/flexLayout.js").SizeDef[]} */
929
+ const sizeDefs = [];
930
+ for (const [index, legendView] of legendViews.entries()) {
931
+ if (index > 0) {
932
+ sizeDefs.push({ px: this.#stackSpacing, grow: 0 });
933
+ }
934
+ sizeDefs.push(legendView.getSize().height);
935
+ }
936
+ return sumSizeDefs(sizeDefs);
937
+ }
938
+ }
939
+
940
+ getPerpendicularSize() {
941
+ if (!this.#child) {
942
+ return 0;
943
+ }
944
+
945
+ const legendViews = this.#getVisibleLegendViews();
946
+
947
+ if (this.orient == "top" || this.orient == "bottom") {
948
+ return legendViews.reduce(
949
+ (sum, legendView, index) =>
950
+ sum +
951
+ legendView.getPerpendicularSize() +
952
+ (index > 0 ? this.#stackSpacing : 0),
953
+ 0
954
+ );
955
+ } else {
956
+ return Math.max(
957
+ 0,
958
+ ...legendViews.map((legendView) =>
959
+ legendView.getPerpendicularSize()
960
+ )
961
+ );
962
+ }
963
+ }
964
+
965
+ getWidth() {
966
+ return Math.max(
967
+ 0,
968
+ ...this.#getVisibleLegendViews().map((legendView) =>
969
+ getSizeDefMinPx(legendView.getSize().width)
970
+ )
971
+ );
972
+ }
973
+
974
+ getHeight() {
975
+ return this.#getVisibleLegendViews().reduce(
976
+ (sum, legendView, index) =>
977
+ sum +
978
+ getSizeDefMinPx(legendView.getSize().height) +
979
+ (index > 0 ? this.#stackSpacing : 0),
980
+ 0
981
+ );
982
+ }
983
+
984
+ getOffset() {
985
+ return Math.max(
986
+ 0,
987
+ ...this.#getVisibleLegendViews().map((legendView) =>
988
+ legendView.getOffset()
989
+ )
990
+ );
991
+ }
992
+
993
+ getParallelSize() {
994
+ const legendViews = this.#getVisibleLegendViews();
995
+ if (!legendViews.length) {
996
+ return 0;
997
+ }
998
+
999
+ const parallelSize = this.#getParallelSizeDef();
1000
+ // Undefined asks legendLayout to stretch the region to the available
1001
+ // viewport size. Fixed stacks return a numeric natural extent.
1002
+ if (getSizeDefMaxPx(parallelSize) === undefined) {
1003
+ return undefined;
1004
+ }
1005
+
1006
+ return getSizeDefMinPx(parallelSize);
1007
+ }
1008
+
1009
+ isPickingSupported() {
1010
+ return false;
1011
+ }
1012
+
1013
+ /**
1014
+ * @param {import("./renderingContext/viewRenderingContext.js").default} context
1015
+ * @param {import("./layout/rectangle.js").default} coords
1016
+ * @param {import("../types/rendering.js").RenderingOptions} [options]
1017
+ */
1018
+ render(context, coords, options = {}) {
1019
+ super.render(context, coords, options);
1020
+
1021
+ if (!this.isConfiguredVisible()) {
1022
+ return;
1023
+ }
1024
+
1025
+ context.pushView(this, coords);
1026
+ const legendViews = this.#getVisibleLegendViews();
1027
+ const legendSizes = legendViews.map(
1028
+ (legendView) => legendView.getSize().height
1029
+ );
1030
+ const legendLocSizes = mapToPixelCoords(legendSizes, coords.height, {
1031
+ spacing: this.#stackSpacing,
1032
+ devicePixelRatio: context.getDevicePixelRatio(),
1033
+ });
1034
+
1035
+ for (const [index, legendView] of legendViews.entries()) {
1036
+ const locSize = legendLocSizes[index];
1037
+ const legendCoords = new Rectangle(
1038
+ () => coords.x,
1039
+ () => coords.y + locSize.location,
1040
+ () => coords.width,
1041
+ () => locSize.size
1042
+ );
1043
+ legendView.render(context, legendCoords, options);
1044
+ }
1045
+ context.popView(this);
1046
+ }
1047
+
1048
+ /**
1049
+ * @param {import("../utils/interaction.js").default} event
1050
+ */
1051
+ propagateInteraction(event) {
1052
+ this.handleInteraction(event, true);
1053
+ this.#child?.propagateInteraction(event);
1054
+ this.handleInteraction(event, false);
1055
+ }
1056
+ }
1057
+
1058
+ export default class LegendView extends ContainerView {
1059
+ #effectiveExtent;
1060
+
1061
+ /** @type {import("./view.js").default | undefined} */
1062
+ #child;
1063
+
1064
+ /** @type {"symbol" | "gradient"} */
1065
+ #type;
1066
+
1067
+ /** @type {() => boolean} */
1068
+ #activePredicate = () => true;
1069
+
1070
+ /** @type {MeasuredLabels | undefined} */
1071
+ #measuredLabels;
1072
+
1073
+ #stackedParallelSize = 0;
1074
+
1075
+ /** @type {UnitView[]} */
1076
+ #labelViews = [];
1077
+
1078
+ /** @type {Set<import("../data/collector.js").default>} */
1079
+ #observedCollectors = new Set();
1080
+
1081
+ #measurementScheduled = false;
1082
+
1083
+ /**
1084
+ * @param {object} props
1085
+ * @param {LegendEntry[]} [props.entries]
1086
+ * @param {import("../spec/channel.js").ChannelWithScale} props.channel
1087
+ * @param {Partial<Record<import("../spec/channel.js").ChannelWithScale, string>>} [props.symbolChannels]
1088
+ * @param {SymbolLegendStyle} [props.symbolStyle]
1089
+ * @param {"point" | "stroke"} [props.symbolGeometry]
1090
+ * @param {"symbol" | "gradient"} [props.type]
1091
+ * @param {LegendConfig} props.legend
1092
+ * @param {string} [props.format]
1093
+ * @param {import("../spec/channel.js").Type} props.dataType
1094
+ * @param {import("../types/viewContext.js").default} context
1095
+ * @param {import("./containerView.js").default} layoutParent
1096
+ * @param {import("./view.js").default} dataParent
1097
+ * @param {import("./view.js").ViewOptions} [options]
1098
+ */
1099
+ constructor(
1100
+ {
1101
+ entries,
1102
+ channel,
1103
+ symbolChannels,
1104
+ symbolStyle,
1105
+ symbolGeometry,
1106
+ type,
1107
+ legend,
1108
+ format,
1109
+ dataType,
1110
+ },
1111
+ context,
1112
+ layoutParent,
1113
+ dataParent,
1114
+ options
1115
+ ) {
1116
+ const spec =
1117
+ type == "gradient"
1118
+ ? createGradientLegendSpec({
1119
+ channel,
1120
+ legend,
1121
+ format,
1122
+ context,
1123
+ })
1124
+ : createSymbolLegendSpec({
1125
+ entries,
1126
+ channel,
1127
+ symbolChannels,
1128
+ symbolStyle,
1129
+ symbolGeometry,
1130
+ legend,
1131
+ format,
1132
+ dataType,
1133
+ context,
1134
+ });
1135
+
1136
+ super(
1137
+ spec,
1138
+ context,
1139
+ layoutParent,
1140
+ dataParent,
1141
+ "legend_" + (legend.orient ?? "right"),
1142
+ options
1143
+ );
1144
+
1145
+ this.needsAxes = { x: false, y: false };
1146
+ this.legendProps = legend;
1147
+ this.#type = type ?? "symbol";
1148
+ this.#effectiveExtent = getMinimumLegendExtent(
1149
+ this.#type,
1150
+ this.legendProps
1151
+ );
1152
+
1153
+ markViewAsNonAddressable(this, { skipSubtree: true });
1154
+ markViewAsChrome(this, { skipSubtree: true });
1155
+ }
1156
+
1157
+ async initializeChildren() {
1158
+ const childSpec = { ...this.spec };
1159
+ delete childSpec.name;
1160
+
1161
+ this.#child = await this.context.createOrImportView(
1162
+ childSpec,
1163
+ this,
1164
+ this,
1165
+ this.getNextAutoName("legend"),
1166
+ undefined,
1167
+ {
1168
+ // Generated legend internals use `width`/`height` expressions
1169
+ // for pixel-aware helper transforms and scales. Force local
1170
+ // layout params so authored specs that intentionally overload
1171
+ // these names, such as SampleView's sample-facet `height`, do
1172
+ // not leak into packLegendLabels or gradient scales. This should
1173
+ // become unnecessary once unit coordinate space has been
1174
+ // migrated to pixel space.
1175
+ layoutSizeParams: "force",
1176
+ }
1177
+ );
1178
+
1179
+ markViewAsNonAddressable(this.#child, { skipSubtree: true });
1180
+ markViewAsChrome(this.#child, { skipSubtree: true });
1181
+
1182
+ this.#labelViews = [];
1183
+ for (const view of this.getDescendants()) {
1184
+ if (
1185
+ view instanceof UnitView &&
1186
+ (view.name === "labels" || view.name === "gradientLabels")
1187
+ ) {
1188
+ this.#labelViews.push(view);
1189
+ }
1190
+ }
1191
+
1192
+ if (this.#labelViews.length > 0) {
1193
+ this.registerDisposer(
1194
+ this._addBroadcastHandler("subtreeDataReady", () =>
1195
+ this.#ensureLabelObservers()
1196
+ )
1197
+ );
1198
+ }
1199
+
1200
+ this.#stackedParallelSize = this.getStackedParallelSize();
1201
+ }
1202
+
1203
+ /**
1204
+ * @returns {IterableIterator<import("./view.js").default>}
1205
+ */
1206
+ *[Symbol.iterator]() {
1207
+ if (this.#child) {
1208
+ yield this.#child;
1209
+ }
1210
+ }
1211
+
1212
+ getSize() {
1213
+ if (!this.isActive()) {
1214
+ return new FlexDimensions({ px: 0, grow: 0 }, { px: 0, grow: 0 });
1215
+ }
1216
+
1217
+ const contentHorizontal = isHorizontalLegend(this.legendProps);
1218
+ const mainSize = { grow: 1 };
1219
+ const perpendicularSize = { px: this.getPerpendicularSize() };
1220
+ const parallelSize = this.#hasFlexibleParallelSize()
1221
+ ? this.#getFlexibleStackedParallelSize()
1222
+ : { px: this.getStackedParallelSize() };
1223
+
1224
+ if (isTopBottomLegend(this.legendProps)) {
1225
+ return new FlexDimensions(mainSize, perpendicularSize);
1226
+ } else if (contentHorizontal) {
1227
+ return new FlexDimensions(parallelSize, perpendicularSize);
1228
+ } else {
1229
+ return new FlexDimensions(perpendicularSize, parallelSize);
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * @returns {import("./layout/flexLayout.js").SizeDef}
1235
+ */
1236
+ #getFlexibleStackedParallelSize() {
1237
+ if (!this.#child) {
1238
+ return { grow: 1, minPx: MIN_GRADIENT_LEGEND_LENGTH };
1239
+ }
1240
+
1241
+ // The generated child concat includes title, padding, and the gradient
1242
+ // body minimum, so use it as the public stacked parallel contract.
1243
+ const childSize = this.#child.getSize();
1244
+ return isHorizontalLegend(this.legendProps)
1245
+ ? childSize.width
1246
+ : childSize.height;
1247
+ }
1248
+
1249
+ #hasFlexibleParallelSize() {
1250
+ return (
1251
+ this.#type == "gradient" && !isHorizontalLegend(this.legendProps)
1252
+ );
1253
+ }
1254
+
1255
+ getPerpendicularSize() {
1256
+ return this.#effectiveExtent;
1257
+ }
1258
+
1259
+ getOffset() {
1260
+ return this.legendProps.offset ?? 0;
1261
+ }
1262
+
1263
+ /**
1264
+ * @param {() => boolean} predicate
1265
+ */
1266
+ setActivePredicate(predicate) {
1267
+ this.#activePredicate = predicate;
1268
+ }
1269
+
1270
+ /*
1271
+ * Keep active visibility separate from configured visibility. View data
1272
+ * initialization uses isConfiguredVisible(), so reactive legend.disable
1273
+ * must not make the legend subtree look unconfigured. Otherwise a legend
1274
+ * that starts hidden would not initialize its marks and could not reappear.
1275
+ */
1276
+ isActive() {
1277
+ return super.isConfiguredVisible() && this.#activePredicate();
1278
+ }
1279
+
1280
+ getStackedParallelSize() {
1281
+ const measuredLabels =
1282
+ this.#measuredLabels ?? getMeasuredLabels(this.#labelViews);
1283
+
1284
+ return getStackedLegendParallelSize(
1285
+ this.legendProps,
1286
+ this.#type,
1287
+ measuredLabels,
1288
+ this.context
1289
+ );
1290
+ }
1291
+
1292
+ #scheduleAutoExtentMeasurement() {
1293
+ if (this.#measurementScheduled) {
1294
+ return;
1295
+ }
1296
+
1297
+ this.#measurementScheduled = true;
1298
+ queueMicrotask(() => {
1299
+ this.#measurementScheduled = false;
1300
+ this.#updateAutoExtent();
1301
+ });
1302
+ }
1303
+
1304
+ #ensureLabelObservers() {
1305
+ for (const labelsView of this.#labelViews) {
1306
+ const collector = labelsView.getCollector();
1307
+ if (!collector || this.#observedCollectors.has(collector)) {
1308
+ continue;
1309
+ }
1310
+
1311
+ this.#observedCollectors.add(collector);
1312
+ this.registerDisposer(
1313
+ collector.observe(() => this.#scheduleAutoExtentMeasurement())
1314
+ );
1315
+
1316
+ if (collector.completed) {
1317
+ this.#scheduleAutoExtentMeasurement();
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ #updateAutoExtent() {
1323
+ const measuredLabels = getMeasuredLabels(this.#labelViews);
1324
+ if (measuredLabels === undefined) {
1325
+ return;
1326
+ }
1327
+
1328
+ const previousStackedParallelSize = this.#stackedParallelSize;
1329
+ this.#measuredLabels = measuredLabels;
1330
+
1331
+ const nextExtent = getLegendExtent(
1332
+ this.legendProps,
1333
+ this.#type,
1334
+ measuredLabels,
1335
+ this.context
1336
+ );
1337
+ const willGrow =
1338
+ nextExtent >= this.#effectiveExtent + AUTO_EXTENT_GROW_THRESHOLD_PX;
1339
+ const nextStackedParallelSize = this.getStackedParallelSize();
1340
+ const willResizeStack =
1341
+ Math.abs(nextStackedParallelSize - previousStackedParallelSize) >=
1342
+ AUTO_EXTENT_GROW_THRESHOLD_PX;
1343
+
1344
+ if (!willGrow && !willResizeStack) {
1345
+ return;
1346
+ }
1347
+
1348
+ if (willGrow) {
1349
+ this.#effectiveExtent = nextExtent;
1350
+ }
1351
+ this.#stackedParallelSize = nextStackedParallelSize;
1352
+ this.invalidateSizeCache();
1353
+ this.context.requestLayoutReflow();
1354
+ }
1355
+
1356
+ /**
1357
+ * Defers legend data updates that only serve layout packing. Scale-backed
1358
+ * legend marks keep using the live scale while callers run smooth layout
1359
+ * transitions.
1360
+ *
1361
+ * TODO: Prefer optimizing layout and render batching to support partial
1362
+ * updates, so transition callers do not need to suppress legend dataflow.
1363
+ *
1364
+ * @returns {() => void}
1365
+ */
1366
+ suspendLayoutDataUpdates() {
1367
+ /** @type {Set<{suspendRangeUpdates: () => () => void}>} */
1368
+ const dataSources = new Set();
1369
+ for (const descendant of this.getDescendants()) {
1370
+ const dataSource = descendant.flowHandle?.dataSource;
1371
+ if (dataSource && "suspendRangeUpdates" in dataSource) {
1372
+ dataSources.add(
1373
+ /** @type {{suspendRangeUpdates: () => () => void}} */ (
1374
+ dataSource
1375
+ )
1376
+ );
1377
+ }
1378
+ }
1379
+
1380
+ const releases = Array.from(dataSources).map((dataSource) =>
1381
+ dataSource.suspendRangeUpdates()
1382
+ );
1383
+
1384
+ return () => {
1385
+ for (const release of releases) {
1386
+ release();
1387
+ }
1388
+ };
1389
+ }
1390
+
1391
+ isPickingSupported() {
1392
+ return false;
1393
+ }
1394
+
1395
+ /**
1396
+ * @param {import("./renderingContext/viewRenderingContext.js").default} context
1397
+ * @param {import("./layout/rectangle.js").default} coords
1398
+ * @param {import("../types/rendering.js").RenderingOptions} [options]
1399
+ */
1400
+ render(context, coords, options = {}) {
1401
+ super.render(context, coords, options);
1402
+
1403
+ if (!this.isActive()) {
1404
+ return;
1405
+ }
1406
+
1407
+ context.pushView(this, coords);
1408
+ this.#child?.render(context, coords, options);
1409
+ context.popView(this);
1410
+ }
1411
+
1412
+ /**
1413
+ * @param {import("../utils/interaction.js").default} event
1414
+ */
1415
+ propagateInteraction(event) {
1416
+ this.handleInteraction(event, true);
1417
+ this.#child?.propagateInteraction(event);
1418
+ this.handleInteraction(event, false);
1419
+ }
1420
+ }
1421
+
1422
+ /**
1423
+ * @typedef {object} MeasuredLabels
1424
+ * @prop {number} maxWidth
1425
+ * @prop {number} maxEntryWidth
1426
+ * @prop {number} maxX
1427
+ * @prop {number} maxY
1428
+ */
1429
+
1430
+ /**
1431
+ * @param {UnitView[]} labelViews
1432
+ * @returns {MeasuredLabels | undefined}
1433
+ */
1434
+ function getMeasuredLabels(labelViews) {
1435
+ let maxWidth = 0;
1436
+ let maxEntryWidth = 0;
1437
+ let maxX = 0;
1438
+ let maxY = 0;
1439
+ let completed = false;
1440
+
1441
+ for (const labelsView of labelViews) {
1442
+ const collector = labelsView.getCollector();
1443
+ if (!collector?.completed) {
1444
+ return undefined;
1445
+ }
1446
+
1447
+ completed = true;
1448
+ collector.visitData((datum) => {
1449
+ maxWidth = Math.max(
1450
+ maxWidth,
1451
+ Number(datum[LABEL_WIDTH_FIELD]) || 0
1452
+ );
1453
+ maxEntryWidth = Math.max(
1454
+ maxEntryWidth,
1455
+ Number(datum.entryWidth) || 0
1456
+ );
1457
+ maxX = Math.max(
1458
+ maxX,
1459
+ (Number(datum.labelX) || 0) +
1460
+ (Number(datum[LABEL_WIDTH_FIELD]) || 0)
1461
+ );
1462
+ maxY = Math.max(maxY, Number(datum.labelY) || 0);
1463
+ });
1464
+ }
1465
+
1466
+ return completed
1467
+ ? {
1468
+ maxWidth: Math.ceil(maxWidth),
1469
+ maxEntryWidth: Math.ceil(maxEntryWidth),
1470
+ maxX: Math.ceil(maxX),
1471
+ maxY: Math.ceil(maxY),
1472
+ }
1473
+ : undefined;
1474
+ }
1475
+
1476
+ /**
1477
+ * @param {LegendConfig} legend
1478
+ * @param {"symbol" | "gradient"} type
1479
+ * @param {MeasuredLabels} measuredLabels
1480
+ * @param {import("../types/viewContext.js").default} context
1481
+ */
1482
+ function getLegendExtent(legend, type, measuredLabels, context) {
1483
+ if (isHorizontalLegend(legend)) {
1484
+ return getHorizontalLegendExtent(legend, type, measuredLabels);
1485
+ }
1486
+
1487
+ const titleWidth = isSideTitle(legend)
1488
+ ? getTitleWidthWithPadding(legend, context)
1489
+ : getTitleWidth(legend, context);
1490
+ const labelOffset = legend.labelOffset ?? 4;
1491
+ const labelExtent =
1492
+ type == "gradient"
1493
+ ? DEFAULT_GRADIENT_THICKNESS +
1494
+ DEFAULT_GRADIENT_TICK_SIZE +
1495
+ labelOffset +
1496
+ measuredLabels.maxWidth
1497
+ : measuredLabels.maxEntryWidth ||
1498
+ Math.sqrt(legend.symbolSize ?? 100) +
1499
+ (legend.symbolStrokeWidth ?? 1.5) +
1500
+ labelOffset +
1501
+ measuredLabels.maxWidth;
1502
+
1503
+ return Math.ceil(
1504
+ Math.max(
1505
+ getMinimumLegendExtent(type, legend),
1506
+ isSideTitle(legend) ? labelExtent + titleWidth : labelExtent,
1507
+ titleWidth
1508
+ )
1509
+ );
1510
+ }
1511
+
1512
+ /**
1513
+ * @param {"symbol" | "gradient"} type
1514
+ * @param {LegendConfig} legend
1515
+ */
1516
+ function getMinimumLegendExtent(type, legend) {
1517
+ if (isHorizontalLegend(legend)) {
1518
+ return type == "gradient" ? 32 : 0;
1519
+ } else {
1520
+ return 0;
1521
+ }
1522
+ }
1523
+
1524
+ /**
1525
+ * @param {LegendConfig} legend
1526
+ * @param {"symbol" | "gradient"} type
1527
+ * @param {MeasuredLabels | undefined} measuredLabels
1528
+ * @param {import("../types/viewContext.js").default} context
1529
+ */
1530
+ function getStackedLegendParallelSize(legend, type, measuredLabels, context) {
1531
+ const titleExtent = getParallelTitleExtent(legend, context);
1532
+ const titleSideBySide = isSideTitle(legend);
1533
+ const combine = (/** @type {number} */ bodyExtent) =>
1534
+ isHorizontalLegend(legend) == titleSideBySide
1535
+ ? Math.ceil(titleExtent + bodyExtent)
1536
+ : Math.ceil(Math.max(titleExtent, bodyExtent));
1537
+
1538
+ if (type == "gradient") {
1539
+ return combine(DEFAULT_GRADIENT_LEGEND_LENGTH);
1540
+ } else if (measuredLabels) {
1541
+ const labelFontSize = legend.labelFontSize ?? 10;
1542
+ const bodyExtent = isHorizontalLegend(legend)
1543
+ ? measuredLabels.maxX
1544
+ : measuredLabels.maxY + labelFontSize / 2;
1545
+
1546
+ return combine(bodyExtent);
1547
+ } else {
1548
+ return combine(
1549
+ Math.sqrt(legend.symbolSize ?? 100) +
1550
+ (legend.symbolStrokeWidth ?? 1.5)
1551
+ );
1552
+ }
1553
+ }
1554
+
1555
+ /**
1556
+ * @param {LegendConfig} legend
1557
+ * @param {"symbol" | "gradient"} type
1558
+ * @param {MeasuredLabels} measuredLabels
1559
+ */
1560
+ function getHorizontalLegendExtent(legend, type, measuredLabels) {
1561
+ const labelFontSize = legend.labelFontSize ?? 10;
1562
+ const labelOffset = legend.labelOffset ?? 4;
1563
+ const titleExtent = getPerpendicularTitleExtent(legend);
1564
+ const bodyExtent =
1565
+ type == "gradient"
1566
+ ? labelFontSize +
1567
+ labelOffset +
1568
+ DEFAULT_GRADIENT_TICK_SIZE +
1569
+ DEFAULT_GRADIENT_THICKNESS +
1570
+ 2
1571
+ : measuredLabels.maxY + labelFontSize / 2;
1572
+
1573
+ return Math.ceil(
1574
+ Math.max(
1575
+ getMinimumLegendExtent(type, legend),
1576
+ isSideTitle(legend)
1577
+ ? Math.max(titleExtent, bodyExtent)
1578
+ : titleExtent + bodyExtent
1579
+ )
1580
+ );
1581
+ }
1582
+
1583
+ /**
1584
+ * @param {LegendConfig} legend
1585
+ */
1586
+ function getTitleHeight(legend) {
1587
+ return legend.title ? (legend.titleFontSize ?? 11) : 0;
1588
+ }
1589
+
1590
+ /**
1591
+ * @param {LegendConfig} legend
1592
+ */
1593
+ function getPerpendicularTitleExtent(legend) {
1594
+ return legend.title
1595
+ ? getTitleHeight(legend) +
1596
+ (isSideTitle(legend) ? 0 : (legend.titlePadding ?? 5))
1597
+ : 0;
1598
+ }
1599
+
1600
+ /**
1601
+ * @param {LegendConfig} legend
1602
+ * @param {import("../types/viewContext.js").default} context
1603
+ */
1604
+ function getParallelTitleExtent(legend, context) {
1605
+ return isSideTitle(legend)
1606
+ ? getTitleWidthWithPadding(legend, context)
1607
+ : getPerpendicularTitleExtent(legend);
1608
+ }
1609
+
1610
+ /**
1611
+ * @param {LegendConfig} legend
1612
+ * @param {import("../types/viewContext.js").default} context
1613
+ */
1614
+ function getTitleWidthWithPadding(legend, context) {
1615
+ return legend.title
1616
+ ? getTitleWidth(legend, context) + (legend.titlePadding ?? 5)
1617
+ : 0;
1618
+ }
1619
+
1620
+ /**
1621
+ * @param {LegendConfig} legend
1622
+ * @param {import("../types/viewContext.js").default} context
1623
+ */
1624
+ function getTitleWidth(legend, context) {
1625
+ if (!legend.title) {
1626
+ return 0;
1627
+ }
1628
+
1629
+ const font = requestFont(context.fontManager, {
1630
+ font: legend.titleFont,
1631
+ fontStyle: legend.titleFontStyle,
1632
+ fontWeight: legend.titleFontWeight,
1633
+ });
1634
+ // Generated legend title width is materialized into the child spec before
1635
+ // asynchronous font loading has necessarily completed. Use fallback metrics
1636
+ // to avoid a padding-only title extent. TODO: Recompute generated legend
1637
+ // specs or make title extent lazy when requested font metrics become ready.
1638
+ const metrics =
1639
+ font.metrics ?? context.fontManager.getDefaultFont().metrics;
1640
+ if (!metrics) {
1641
+ return 0;
1642
+ }
1643
+
1644
+ const fontSize = legend.titleFontSize ?? 11;
1645
+ const text = truncateText(
1646
+ legend.title,
1647
+ legend.titleLimit,
1648
+ (text, fontSize) => measureText(metrics, text, fontSize).width,
1649
+ fontSize,
1650
+ "..."
1651
+ );
1652
+
1653
+ return measureText(metrics, text, fontSize).width;
1654
+ }