@genome-spy/core 0.77.0 → 0.79.0

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