react-msaview 6.5.0 → 8.0.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 (414) hide show
  1. package/README.md +13 -11
  2. package/bundle/index.js +122 -112
  3. package/bundle/index.js.map +4 -4
  4. package/dist/colorSchemes.js +1 -2
  5. package/dist/colorSchemes.js.map +1 -1
  6. package/dist/columnStats.d.ts +28 -0
  7. package/dist/columnStats.js +34 -0
  8. package/dist/columnStats.js.map +1 -0
  9. package/dist/components/DragHandle.d.ts +3 -2
  10. package/dist/components/DragHandle.js +11 -5
  11. package/dist/components/DragHandle.js.map +1 -1
  12. package/dist/components/Loading.js +2 -2
  13. package/dist/components/Loading.js.map +1 -1
  14. package/dist/components/MSAView.js +17 -6
  15. package/dist/components/MSAView.js.map +1 -1
  16. package/dist/components/MSAViewer.d.ts +69 -4
  17. package/dist/components/MSAViewer.js +201 -8
  18. package/dist/components/MSAViewer.js.map +1 -1
  19. package/dist/components/PortalTooltip.d.ts +11 -0
  20. package/dist/components/PortalTooltip.js +29 -0
  21. package/dist/components/PortalTooltip.js.map +1 -0
  22. package/dist/components/ResizeHandles.d.ts +3 -3
  23. package/dist/components/ResizeHandles.js +13 -24
  24. package/dist/components/ResizeHandles.js.map +1 -1
  25. package/dist/components/SequenceTextArea.js +3 -3
  26. package/dist/components/SequenceTextArea.js.map +1 -1
  27. package/dist/components/Track.js +41 -22
  28. package/dist/components/Track.js.map +1 -1
  29. package/dist/components/dialogs/{InterProScanDialog.d.ts → AnnotationFileDialog.d.ts} +2 -2
  30. package/dist/components/dialogs/AnnotationFileDialog.js +92 -0
  31. package/dist/components/dialogs/AnnotationFileDialog.js.map +1 -0
  32. package/dist/components/dialogs/ExportSVGDialog.js +5 -3
  33. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  34. package/dist/components/dialogs/FeatureDialog.js +13 -9
  35. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  36. package/dist/components/dialogs/TrackInfoDialog.js +1 -1
  37. package/dist/components/dialogs/TrackInfoDialog.js.map +1 -1
  38. package/dist/components/header/GappynessSlider.js +6 -11
  39. package/dist/components/header/GappynessSlider.js.map +1 -1
  40. package/dist/components/header/Header.js +7 -0
  41. package/dist/components/header/Header.js.map +1 -1
  42. package/dist/components/header/HeaderStatusArea.js +0 -2
  43. package/dist/components/header/HeaderStatusArea.js.map +1 -1
  44. package/dist/components/header/LoadWarnings.d.ts +6 -0
  45. package/dist/components/header/LoadWarnings.js +35 -0
  46. package/dist/components/header/LoadWarnings.js.map +1 -0
  47. package/dist/components/header/MultiAlignmentSelector.js +0 -0
  48. package/dist/components/header/MultiAlignmentSelector.js.map +1 -1
  49. package/dist/components/header/ScrollZoomIcons.d.ts +6 -0
  50. package/dist/components/header/ScrollZoomIcons.js +35 -0
  51. package/dist/components/header/ScrollZoomIcons.js.map +1 -0
  52. package/dist/components/header/TreeSettingsMenu.js +7 -1
  53. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  54. package/dist/components/header/UnshareableDataWarning.d.ts +6 -0
  55. package/dist/components/header/UnshareableDataWarning.js +40 -0
  56. package/dist/components/header/UnshareableDataWarning.js.map +1 -0
  57. package/dist/components/header/ZoomControls.js +49 -6
  58. package/dist/components/header/ZoomControls.js.map +1 -1
  59. package/dist/components/header/getAnnotationsMenu.js +11 -14
  60. package/dist/components/header/getAnnotationsMenu.js.map +1 -1
  61. package/dist/components/header/settingsMenuItems.js +10 -7
  62. package/dist/components/header/settingsMenuItems.js.map +1 -1
  63. package/dist/components/import/ImportForm.js +1 -1
  64. package/dist/components/import/ImportForm.js.map +1 -1
  65. package/dist/components/import/ImportFormExamples.js +2 -6
  66. package/dist/components/import/ImportFormExamples.js.map +1 -1
  67. package/dist/components/import/util.js +2 -0
  68. package/dist/components/import/util.js.map +1 -1
  69. package/dist/components/minimap/Minimap.js +1 -2
  70. package/dist/components/minimap/Minimap.js.map +1 -1
  71. package/dist/components/msa/AnnotationLegend.js +27 -19
  72. package/dist/components/msa/AnnotationLegend.js.map +1 -1
  73. package/dist/components/msa/MSACanvas.js +1 -1
  74. package/dist/components/msa/MSACanvas.js.map +1 -1
  75. package/dist/components/msa/MSACanvasBlock.js +17 -20
  76. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  77. package/dist/components/msa/MSAMouseoverCanvas.js +2 -1
  78. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  79. package/dist/components/msa/domainBandCursor.d.ts +7 -11
  80. package/dist/components/msa/domainBandCursor.js +7 -11
  81. package/dist/components/msa/domainBandCursor.js.map +1 -1
  82. package/dist/components/msa/drawFeatureSpans.d.ts +53 -0
  83. package/dist/components/msa/drawFeatureSpans.js +78 -0
  84. package/dist/components/msa/drawFeatureSpans.js.map +1 -0
  85. package/dist/components/msa/legendRows.d.ts +8 -0
  86. package/dist/components/msa/legendRows.js +16 -0
  87. package/dist/components/msa/legendRows.js.map +1 -0
  88. package/dist/components/msa/msaRaster.d.ts +30 -27
  89. package/dist/components/msa/msaRaster.js +193 -90
  90. package/dist/components/msa/msaRaster.js.map +1 -1
  91. package/dist/components/msa/packDomainLanes.d.ts +20 -0
  92. package/dist/components/msa/packDomainLanes.js +33 -0
  93. package/dist/components/msa/packDomainLanes.js.map +1 -0
  94. package/dist/components/msa/renderBoxFeatureCanvasBlock.d.ts +3 -1
  95. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +51 -87
  96. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  97. package/dist/components/msa/renderHighlights.d.ts +44 -4
  98. package/dist/components/msa/renderHighlights.js +99 -11
  99. package/dist/components/msa/renderHighlights.js.map +1 -1
  100. package/dist/components/msa/renderMSABlock.d.ts +1 -2
  101. package/dist/components/msa/renderMSABlock.js +28 -31
  102. package/dist/components/msa/renderMSABlock.js.map +1 -1
  103. package/dist/components/msa/renderMSAMouseover.js +9 -30
  104. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  105. package/dist/components/msa/tileColor.js +4 -7
  106. package/dist/components/msa/tileColor.js.map +1 -1
  107. package/dist/components/msa/useMsaBlockMouse.d.ts +5 -3
  108. package/dist/components/msa/useMsaBlockMouse.js +6 -6
  109. package/dist/components/msa/useMsaBlockMouse.js.map +1 -1
  110. package/dist/components/renderCtx.d.ts +1 -1
  111. package/dist/components/rowpanels/RowPanelBlock.d.ts +9 -0
  112. package/dist/components/rowpanels/RowPanelBlock.js +86 -0
  113. package/dist/components/rowpanels/RowPanelBlock.js.map +1 -0
  114. package/dist/components/rowpanels/RowPanelHeaders.d.ts +12 -0
  115. package/dist/components/rowpanels/RowPanelHeaders.js +38 -0
  116. package/dist/components/rowpanels/RowPanelHeaders.js.map +1 -0
  117. package/dist/components/{msa/ColumnStats.d.ts → rowpanels/RowPanels.d.ts} +2 -2
  118. package/dist/components/rowpanels/RowPanels.js +32 -0
  119. package/dist/components/rowpanels/RowPanels.js.map +1 -0
  120. package/dist/components/rowpanels/headerLayout.d.ts +1 -0
  121. package/dist/components/rowpanels/headerLayout.js +5 -0
  122. package/dist/components/rowpanels/headerLayout.js.map +1 -0
  123. package/dist/components/rowpanels/renderFeaturePanel.d.ts +21 -0
  124. package/dist/components/rowpanels/renderFeaturePanel.js +37 -0
  125. package/dist/components/rowpanels/renderFeaturePanel.js.map +1 -0
  126. package/dist/components/rowpanels/renderRowPanel.d.ts +23 -0
  127. package/dist/components/rowpanels/renderRowPanel.js +22 -0
  128. package/dist/components/rowpanels/renderRowPanel.js.map +1 -0
  129. package/dist/components/rowpanels/renderStrip.d.ts +20 -0
  130. package/dist/components/rowpanels/renderStrip.js +26 -0
  131. package/dist/components/rowpanels/renderStrip.js.map +1 -0
  132. package/dist/components/tracks/TrackBlocks.js +13 -16
  133. package/dist/components/tracks/TrackBlocks.js.map +1 -1
  134. package/dist/components/tracks/TrackTooltipContent.d.ts +15 -0
  135. package/dist/components/tracks/TrackTooltipContent.js +109 -0
  136. package/dist/components/tracks/TrackTooltipContent.js.map +1 -0
  137. package/dist/components/tracks/drawTracks.d.ts +30 -12
  138. package/dist/components/tracks/drawTracks.js +127 -34
  139. package/dist/components/tracks/drawTracks.js.map +1 -1
  140. package/dist/components/tracks/useTrackHover.d.ts +22 -0
  141. package/dist/components/tracks/useTrackHover.js +30 -0
  142. package/dist/components/tracks/useTrackHover.js.map +1 -0
  143. package/dist/components/tree/CladeLabels.d.ts +12 -0
  144. package/dist/components/tree/CladeLabels.js +48 -0
  145. package/dist/components/tree/CladeLabels.js.map +1 -0
  146. package/dist/components/tree/TreeCanvas.js +6 -4
  147. package/dist/components/tree/TreeCanvas.js.map +1 -1
  148. package/dist/components/tree/TreeCanvasBlock.js +6 -21
  149. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  150. package/dist/components/tree/TreeOverview.d.ts +14 -0
  151. package/dist/components/tree/TreeOverview.js +75 -0
  152. package/dist/components/tree/TreeOverview.js.map +1 -0
  153. package/dist/components/tree/TreeRuler.js +11 -5
  154. package/dist/components/tree/TreeRuler.js.map +1 -1
  155. package/dist/components/tree/cladeBrackets.d.ts +25 -0
  156. package/dist/components/tree/cladeBrackets.js +51 -0
  157. package/dist/components/tree/cladeBrackets.js.map +1 -0
  158. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js +2 -2
  159. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js.map +1 -1
  160. package/dist/components/tree/renderTreeCanvas.d.ts +0 -1
  161. package/dist/components/tree/renderTreeCanvas.js +221 -47
  162. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  163. package/dist/components/tree/renderTreeOverview.d.ts +52 -0
  164. package/dist/components/tree/renderTreeOverview.js +138 -0
  165. package/dist/components/tree/renderTreeOverview.js.map +1 -0
  166. package/dist/components/tree/scaleBar.d.ts +10 -0
  167. package/dist/components/tree/scaleBar.js +24 -0
  168. package/dist/components/tree/scaleBar.js.map +1 -0
  169. package/dist/components/tree/useTreeHover.js +2 -4
  170. package/dist/components/tree/useTreeHover.js.map +1 -1
  171. package/dist/constants.d.ts +18 -1
  172. package/dist/constants.js +51 -8
  173. package/dist/constants.js.map +1 -1
  174. package/dist/createPaletteMap.d.ts +7 -6
  175. package/dist/createPaletteMap.js +10 -9
  176. package/dist/createPaletteMap.js.map +1 -1
  177. package/dist/element.d.ts +13 -0
  178. package/dist/element.js +269 -0
  179. package/dist/element.js.map +1 -0
  180. package/dist/featureFields.d.ts +11 -0
  181. package/dist/featureFields.js +17 -0
  182. package/dist/featureFields.js.map +1 -0
  183. package/dist/fetchUtils.d.ts +7 -7
  184. package/dist/fetchUtils.js +4 -32
  185. package/dist/fetchUtils.js.map +1 -1
  186. package/dist/flatToTree.js +1 -2
  187. package/dist/flatToTree.js.map +1 -1
  188. package/dist/ggplotPalettes.d.ts +10 -0
  189. package/dist/ggplotPalettes.js +58 -0
  190. package/dist/ggplotPalettes.js.map +1 -1
  191. package/dist/hierarchy.d.ts +29 -1
  192. package/dist/hierarchy.js +82 -19
  193. package/dist/hierarchy.js.map +1 -1
  194. package/dist/index.d.ts +10 -1
  195. package/dist/index.js +10 -2
  196. package/dist/index.js.map +1 -1
  197. package/dist/model/DataModel.js +4 -4
  198. package/dist/model/DataModel.js.map +1 -1
  199. package/dist/model/msaModel.d.ts +3 -2
  200. package/dist/model/msaModel.js +3 -2
  201. package/dist/model/msaModel.js.map +1 -1
  202. package/dist/model/treeModel.d.ts +30 -0
  203. package/dist/model/treeModel.js +37 -1
  204. package/dist/model/treeModel.js.map +1 -1
  205. package/dist/model.d.ts +792 -259
  206. package/dist/model.js +2092 -675
  207. package/dist/model.js.map +1 -1
  208. package/dist/mount.d.ts +11 -0
  209. package/dist/mount.js +22 -0
  210. package/dist/mount.js.map +1 -0
  211. package/dist/neighborJoining.js +8 -32
  212. package/dist/neighborJoining.js.map +1 -1
  213. package/dist/renderToStaticMarkup.d.ts +15 -13
  214. package/dist/renderToStaticMarkup.js +37 -25
  215. package/dist/renderToStaticMarkup.js.map +1 -1
  216. package/dist/renderToSvg.js +198 -68
  217. package/dist/renderToSvg.js.map +1 -1
  218. package/dist/rowCoordinateCalculations.d.ts +26 -28
  219. package/dist/rowCoordinateCalculations.js +13 -39
  220. package/dist/rowCoordinateCalculations.js.map +1 -1
  221. package/dist/scales.d.ts +22 -0
  222. package/dist/scales.js +30 -0
  223. package/dist/scales.js.map +1 -0
  224. package/dist/seqPosToGlobalCol.d.ts +2 -3
  225. package/dist/seqPosToGlobalCol.js +5 -4
  226. package/dist/seqPosToGlobalCol.js.map +1 -1
  227. package/dist/sequenceLogo.d.ts +6 -11
  228. package/dist/sequenceLogo.js +6 -11
  229. package/dist/sequenceLogo.js.map +1 -1
  230. package/dist/stripDefault.d.ts +7 -14
  231. package/dist/stripDefault.js +7 -14
  232. package/dist/stripDefault.js.map +1 -1
  233. package/dist/svgTestUtil.d.ts +306 -124
  234. package/dist/types.d.ts +295 -3
  235. package/dist/umd.d.ts +2 -0
  236. package/dist/umd.js +4 -2
  237. package/dist/umd.js.map +1 -1
  238. package/dist/useCanvasAutorun.d.ts +5 -10
  239. package/dist/useCanvasAutorun.js +5 -10
  240. package/dist/useCanvasAutorun.js.map +1 -1
  241. package/dist/useMsaSvgFigure.d.ts +20 -0
  242. package/dist/useMsaSvgFigure.js +46 -0
  243. package/dist/useMsaSvgFigure.js.map +1 -0
  244. package/dist/util.d.ts +10 -4
  245. package/dist/util.js +37 -10
  246. package/dist/util.js.map +1 -1
  247. package/dist/version.d.ts +1 -1
  248. package/dist/version.js +1 -1
  249. package/dist/wuss.d.ts +14 -0
  250. package/dist/wuss.js +55 -0
  251. package/dist/wuss.js.map +1 -0
  252. package/package.json +23 -14
  253. package/src/colorSchemes.ts +1 -2
  254. package/src/columnStats.ts +58 -0
  255. package/src/components/DragHandle.tsx +18 -7
  256. package/src/components/Loading.tsx +14 -8
  257. package/src/components/MSAView.tsx +19 -5
  258. package/src/components/MSAViewer.tsx +326 -10
  259. package/src/components/PortalTooltip.tsx +47 -0
  260. package/src/components/ResizeHandles.tsx +21 -36
  261. package/src/components/SequenceTextArea.tsx +3 -3
  262. package/src/components/Track.tsx +55 -18
  263. package/src/components/dialogs/AnnotationFileDialog.tsx +162 -0
  264. package/src/components/dialogs/ExportSVGDialog.tsx +7 -2
  265. package/src/components/dialogs/FeatureDialog.tsx +13 -9
  266. package/src/components/dialogs/TrackInfoDialog.tsx +1 -1
  267. package/src/components/header/GappynessSlider.tsx +6 -11
  268. package/src/components/header/Header.tsx +10 -0
  269. package/src/components/header/HeaderStatusArea.tsx +0 -5
  270. package/src/components/header/LoadWarnings.tsx +48 -0
  271. package/src/components/header/MultiAlignmentSelector.tsx +0 -0
  272. package/src/components/header/ScrollZoomIcons.tsx +52 -0
  273. package/src/components/header/TreeSettingsMenu.tsx +7 -1
  274. package/src/components/header/UnshareableDataWarning.tsx +56 -0
  275. package/src/components/header/ZoomControls.tsx +74 -14
  276. package/src/components/header/getAnnotationsMenu.ts +13 -17
  277. package/src/components/header/settingsMenuItems.ts +11 -6
  278. package/src/components/import/ImportForm.tsx +2 -2
  279. package/src/components/import/ImportFormExamples.tsx +4 -8
  280. package/src/components/import/util.ts +2 -0
  281. package/src/components/minimap/Minimap.tsx +1 -2
  282. package/src/components/msa/AnnotationLegend.tsx +48 -38
  283. package/src/components/msa/MSACanvas.tsx +3 -5
  284. package/src/components/msa/MSACanvasBlock.tsx +17 -21
  285. package/src/components/msa/MSAMouseoverCanvas.tsx +2 -1
  286. package/src/components/msa/domainBandCursor.ts +7 -11
  287. package/src/components/msa/drawFeatureSpans.ts +150 -0
  288. package/src/components/msa/legendRows.ts +24 -0
  289. package/src/components/msa/msaRaster.ts +236 -88
  290. package/src/components/msa/packDomainLanes.ts +36 -0
  291. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +69 -123
  292. package/src/components/msa/renderHighlights.ts +141 -10
  293. package/src/components/msa/renderMSABlock.ts +29 -38
  294. package/src/components/msa/renderMSAMouseover.ts +9 -39
  295. package/src/components/msa/tileColor.ts +4 -7
  296. package/src/components/msa/useMsaBlockMouse.ts +6 -6
  297. package/src/components/renderCtx.ts +1 -0
  298. package/src/components/rowpanels/RowPanelBlock.tsx +139 -0
  299. package/src/components/rowpanels/RowPanelHeaders.tsx +57 -0
  300. package/src/components/rowpanels/RowPanels.tsx +77 -0
  301. package/src/components/rowpanels/headerLayout.ts +5 -0
  302. package/src/components/rowpanels/renderFeaturePanel.ts +66 -0
  303. package/src/components/rowpanels/renderRowPanel.ts +41 -0
  304. package/src/components/rowpanels/renderStrip.ts +46 -0
  305. package/src/components/tracks/TrackBlocks.tsx +13 -18
  306. package/src/components/tracks/TrackTooltipContent.tsx +156 -0
  307. package/src/components/tracks/drawTracks.ts +169 -38
  308. package/src/components/tracks/useTrackHover.ts +42 -0
  309. package/src/components/tree/CladeLabels.tsx +77 -0
  310. package/src/components/tree/TreeCanvas.tsx +4 -3
  311. package/src/components/tree/TreeCanvasBlock.tsx +10 -32
  312. package/src/components/tree/TreeOverview.tsx +106 -0
  313. package/src/components/tree/TreeRuler.tsx +29 -5
  314. package/src/components/tree/cladeBrackets.ts +73 -0
  315. package/src/components/tree/dialogs/TreeNodeInfoDialog.tsx +2 -2
  316. package/src/components/tree/renderTreeCanvas.ts +312 -48
  317. package/src/components/tree/renderTreeOverview.ts +212 -0
  318. package/src/components/tree/scaleBar.ts +23 -0
  319. package/src/components/tree/useTreeHover.ts +2 -4
  320. package/src/constants.ts +61 -8
  321. package/src/createPaletteMap.ts +10 -9
  322. package/src/element.ts +321 -0
  323. package/src/featureFields.ts +19 -0
  324. package/src/fetchUtils.ts +7 -40
  325. package/src/flatToTree.ts +1 -2
  326. package/src/ggplotPalettes.ts +60 -0
  327. package/src/hierarchy.ts +97 -20
  328. package/src/index.ts +46 -6
  329. package/src/model/DataModel.ts +5 -4
  330. package/src/model/msaModel.ts +3 -2
  331. package/src/model/treeModel.ts +42 -0
  332. package/src/model.ts +2492 -793
  333. package/src/mount.tsx +32 -0
  334. package/src/neighborJoining.ts +8 -32
  335. package/src/rawImports.d.ts +9 -0
  336. package/src/renderToStaticMarkup.ts +41 -25
  337. package/src/renderToSvg.tsx +365 -92
  338. package/src/rowCoordinateCalculations.ts +16 -62
  339. package/src/scales.ts +47 -0
  340. package/src/seqPosToGlobalCol.ts +5 -4
  341. package/src/sequenceLogo.ts +6 -11
  342. package/src/stripDefault.ts +7 -14
  343. package/src/types.ts +352 -12
  344. package/src/umd.ts +4 -2
  345. package/src/useCanvasAutorun.ts +5 -10
  346. package/src/useMsaSvgFigure.ts +72 -0
  347. package/src/util.ts +40 -14
  348. package/src/version.ts +1 -1
  349. package/src/wuss.ts +62 -0
  350. package/dist/components/dialogs/InterProScanDialog.js +0 -137
  351. package/dist/components/dialogs/InterProScanDialog.js.map +0 -1
  352. package/dist/components/dialogs/InterProScanFileDialog.d.ts +0 -7
  353. package/dist/components/dialogs/InterProScanFileDialog.js +0 -64
  354. package/dist/components/dialogs/InterProScanFileDialog.js.map +0 -1
  355. package/dist/components/msa/ColumnStats.js +0 -42
  356. package/dist/components/msa/ColumnStats.js.map +0 -1
  357. package/dist/launchInterProScan.d.ts +0 -13
  358. package/dist/launchInterProScan.js +0 -110
  359. package/dist/launchInterProScan.js.map +0 -1
  360. package/dist/useColorContrast.d.ts +0 -10
  361. package/dist/useColorContrast.js +0 -13
  362. package/dist/useColorContrast.js.map +0 -1
  363. package/src/clustalX.test.ts +0 -74
  364. package/src/colSpace.test.ts +0 -67
  365. package/src/collapseLogic.test.ts +0 -115
  366. package/src/colorSchemes.docs.test.ts +0 -57
  367. package/src/columnCounts.test.ts +0 -73
  368. package/src/columnStats.test.ts +0 -94
  369. package/src/columnTracks.test.ts +0 -97
  370. package/src/components/dialogs/InterProScanDialog.tsx +0 -214
  371. package/src/components/dialogs/InterProScanFileDialog.tsx +0 -138
  372. package/src/components/minimap/minimapLayout.test.ts +0 -71
  373. package/src/components/msa/ColumnStats.tsx +0 -54
  374. package/src/components/msa/msaRaster.test.ts +0 -145
  375. package/src/components/msa/renderBoxFeatureCanvasBlock.test.ts +0 -51
  376. package/src/components/tree/renderTreeCanvas.test.ts +0 -296
  377. package/src/dataInitialized.test.ts +0 -42
  378. package/src/domainBands.test.ts +0 -122
  379. package/src/domainLetterColor.test.ts +0 -80
  380. package/src/exportFileName.test.ts +0 -40
  381. package/src/fetchUtils.test.ts +0 -83
  382. package/src/flatToTree.test.ts +0 -51
  383. package/src/hierarchy.test.ts +0 -228
  384. package/src/highlights.test.ts +0 -70
  385. package/src/impgRender.test.tsx +0 -68
  386. package/src/largeTree.test.ts +0 -67
  387. package/src/launchInterProScan.ts +0 -167
  388. package/src/loadErrorDisplay.test.tsx +0 -102
  389. package/src/modelFilehandleLoaders.test.ts +0 -137
  390. package/src/modelReset.test.ts +0 -120
  391. package/src/neighborJoining.test.ts +0 -164
  392. package/src/parseAsn1.test.ts +0 -36
  393. package/src/propertyConservation.test.ts +0 -36
  394. package/src/renderColumnTracksSvg.test.tsx +0 -108
  395. package/src/renderDomainsSvg.test.tsx +0 -118
  396. package/src/renderHighlightsSvg.test.tsx +0 -85
  397. package/src/renderMinimapSvg.test.tsx +0 -62
  398. package/src/renderRasterSvg.test.tsx +0 -144
  399. package/src/renderSequenceLogoSvg.test.tsx +0 -165
  400. package/src/renderToSvg.test.tsx +0 -65
  401. package/src/reset.test.ts +0 -61
  402. package/src/rowCoordinateCalculations.test.ts +0 -211
  403. package/src/rowSpace.test.ts +0 -46
  404. package/src/segmentOverlay.test.ts +0 -70
  405. package/src/seqPosToGlobalCol.test.ts +0 -59
  406. package/src/sequenceLogo.test.ts +0 -88
  407. package/src/setFontSize.test.ts +0 -33
  408. package/src/stripDefault.test.ts +0 -48
  409. package/src/stripDefaultSnapshot.test.ts +0 -87
  410. package/src/treeMetadata.test.ts +0 -39
  411. package/src/useCanvasAutorun.test.tsx +0 -98
  412. package/src/useColorContrast.ts +0 -18
  413. package/src/viewChrome.test.tsx +0 -134
  414. package/src/wheelZoom.test.ts +0 -46
package/src/model.ts CHANGED
@@ -6,9 +6,9 @@ import {
6
6
  applySnapshot,
7
7
  cast,
8
8
  getSnapshot,
9
+ isAlive,
9
10
  types,
10
11
  } from '@jbrowse/mobx-state-tree'
11
- import { colord } from 'colord'
12
12
  import { autorun, transaction } from 'mobx'
13
13
  import {
14
14
  generateNodeIds,
@@ -24,33 +24,50 @@ import { calculateBlocks } from './calculateBlocks.ts'
24
24
  import { clustalXColumnColors } from './clustalX.ts'
25
25
  import colorSchemes from './colorSchemes.ts'
26
26
  import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.ts'
27
+ import { columnStats } from './columnStats.ts'
28
+ import { packDomainLanes } from './components/msa/packDomainLanes.ts'
29
+ import { visibleColRange } from './components/msa/visibleColRange.ts'
27
30
  import TrackBlocks from './components/tracks/TrackBlocks.tsx'
31
+ import { cladeGutterWidth } from './components/tree/cladeBrackets.ts'
32
+ import { scaleBarLength } from './components/tree/scaleBar.ts'
28
33
  import {
34
+ cladeHighlightAlpha,
35
+ cladeHighlightColor,
29
36
  defaultAllowedGappyness,
30
37
  defaultColWidth,
31
38
  defaultColorSchemeName,
32
39
  defaultCurrentAlignment,
33
40
  defaultDrawMsaLetters,
41
+ defaultFeaturePanelWidth,
34
42
  defaultHeight,
35
43
  defaultHideGaps,
36
44
  defaultRowHeight,
37
45
  defaultScrollX,
38
46
  defaultScrollY,
39
47
  defaultScrollZoom,
48
+ defaultScrollZoomAxis,
40
49
  defaultShowDomainLegend,
41
50
  defaultShowDomains,
42
51
  defaultSubFeatureRows,
52
+ defaultTreeWidth,
43
53
  labelReferenceFontSize,
44
54
  maxCellSize,
55
+ maxInlineSnapshotBytes,
56
+ maxNeighborJoiningRows,
45
57
  minColWidth,
46
58
  minLetterColWidth,
47
59
  minLetterRowHeight,
48
60
  minRowHeight,
61
+ rowPanelHeaderHeight,
62
+ rowTintAlpha,
63
+ scrollZoomAxes,
49
64
  segmentFeatureTypes,
50
65
  segmentShades,
66
+ treeScaleBarHeight,
51
67
  } from './constants.ts'
52
68
  import { createPaletteMap } from './createPaletteMap.ts'
53
69
  import { exportFileName } from './exportFileName.ts'
70
+ import { featureField, featureName } from './featureFields.ts'
54
71
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
55
72
  import { flatToTree } from './flatToTree.ts'
56
73
  import {
@@ -59,12 +76,13 @@ import {
59
76
  collapse,
60
77
  collapsedSubtreeMaxLength,
61
78
  find,
62
- findMaxBranchLen,
63
79
  forEachDescendant,
64
80
  hierarchy,
81
+ leafIndex,
65
82
  leaves,
66
- links,
67
83
  maxLength,
84
+ mrca,
85
+ nodeCoveringRows,
68
86
  setBrLength,
69
87
  sort,
70
88
  sum as hierarchySum,
@@ -79,10 +97,11 @@ import { parseAsn1 } from './parseAsn1.ts'
79
97
  import { calculatePropertyConservation } from './propertyConservation.ts'
80
98
  import {
81
99
  globalColToVisibleCol,
100
+ seqPosOfGlobalCol,
82
101
  visibleColToGlobalCol,
83
- visibleColToSeqPosForRow,
84
102
  visibleColsBefore,
85
103
  } from './rowCoordinateCalculations.ts'
104
+ import { resolveScale } from './scales.ts'
86
105
  import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
87
106
  import { maxBitsFor } from './sequenceLogo.ts'
88
107
  import { stripDefault } from './stripDefault.ts'
@@ -90,22 +109,50 @@ import {
90
109
  computeRowInsertions,
91
110
  dropBlanks,
92
111
  len,
112
+ outlineColor,
93
113
  skipBlanks,
94
114
  transform,
115
+ withAlpha,
95
116
  } from './util.ts'
96
117
  import { saveAs } from './vendor/fileSaver.ts'
118
+ import { parseWuss } from './wuss.ts'
97
119
 
120
+ import type { ColumnStats } from './columnStats.ts'
121
+ import type { ScrollZoomAxis } from './constants.ts'
98
122
  import type { HierarchyNode } from './hierarchy.ts'
99
123
  import type { ExportSvgOptions } from './renderToSvg.tsx'
124
+ import type { ScaleSpec } from './scales.ts'
100
125
  import type {
101
126
  Annotation,
127
+ Arc,
102
128
  BasicTrack,
129
+ Cell,
130
+ Clade,
131
+ ResidueMappingProblem,
103
132
  ColumnTrackSpec,
104
133
  DomainBand,
134
+ Encoding,
135
+ EncodingChannel,
105
136
  Highlight,
137
+ Legend,
138
+ LegendEntry,
106
139
  NodeWithIds,
107
140
  NodeWithIdsAndLength,
141
+ Region,
142
+ ResidueMapping,
143
+ ResidueSegment,
144
+ ResolvedClade,
145
+ ResolvedEncoding,
108
146
  ResolvedHighlight,
147
+ ResolvedRowPanel,
148
+ RowFeaturesSpec,
149
+ RowPanelSpan,
150
+ RowPanelSpec,
151
+ RowResidue,
152
+ StructureResidue,
153
+ TrackKind,
154
+ UnshareableData,
155
+ Viewport,
109
156
  } from './types.ts'
110
157
  import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types'
111
158
  import type { Instance } from '@jbrowse/mobx-state-tree'
@@ -118,19 +165,204 @@ function parseTreeText(text: string) {
118
165
  return parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text)
119
166
  }
120
167
 
121
- // Tracks that start hidden. The sequence logo answers a narrower question than
122
- // conservation does and costs three times the vertical space, so it waits to be
123
- // asked for.
124
- const defaultOffTracks = new Set(['sequence-logo'])
168
+ // The height each kind draws at before the user drags a divider. A text track
169
+ // is absent: it is one alignment row tall and follows rowHeight, so the zoom
170
+ // controls already size it.
171
+ export const defaultTrackHeights: Partial<Record<TrackKind, number>> = {
172
+ bar: 40,
173
+ // taller than a bar track: in a 40px stack of four residues each glyph is
174
+ // too short to identify. Twice the height of the tracks above it, so it
175
+ // starts hidden
176
+ logo: 80,
177
+ arc: 50,
178
+ ruler: 20,
179
+ }
180
+
181
+ // the kinds a divider resizes. The ruler is a fixed scale, and a text track
182
+ // follows the vertical zoom.
183
+ const resizableKinds = new Set<TrackKind>(['bar', 'logo', 'arc'])
184
+
185
+ const defaultOffTracks = new Set(['sequence-logo', 'position-ruler'])
186
+
187
+ // a data track carries its own height rather than its kind's, so its divider
188
+ // resizes it alone
189
+ const ownHeightKey = (id: string) => `own:${id}`
190
+
191
+ // base-pair arcs: one color for nested helices, one for pseudoknots, which cross
192
+ // them
193
+ const HELIX_ARC = '#4e79a7'
194
+ const PSEUDOKNOT_ARC = '#e15759'
195
+
196
+ // the fill of a span under a scale that gives its value no color, such as a
197
+ // gene a `map` over gene names leaves out
198
+ const UNSCALED_FEATURE = '#d9d9d9'
199
+
200
+ // the fraction of a span a following one may cover before it takes a lane of
201
+ // its own in a `features` panel
202
+ const laneOverlap = 0.1
203
+
204
+ // a row panel's own width, or a default: the row height, which makes a strip
205
+ // cell square, and a gene neighborhood's width for a features panel
206
+ function rowPanelWidth(panel: RowPanelSpec, rowHeight: number) {
207
+ return (
208
+ panel.width ??
209
+ (panel.kind === 'features' ? defaultFeaturePanelWidth : rowHeight)
210
+ )
211
+ }
212
+
213
+ // the fill and outline of every feature in a list: its own GFF `color=` first,
214
+ // then the scale an encoding resolves over one of its fields, then the
215
+ // accession palette
216
+ function featureColorMap(
217
+ annotations: Annotation[],
218
+ encoding:
219
+ | { field: string; colorOf: (value: string) => string | undefined }
220
+ | undefined,
221
+ fillPalette: Record<string, string>,
222
+ ) {
223
+ const strokes = new Map<string, string>()
224
+ const strokeOf = (fill: string) => {
225
+ const hit = strokes.get(fill)
226
+ if (hit !== undefined) {
227
+ return hit
228
+ }
229
+ const stroke = outlineColor(fill)
230
+ strokes.set(fill, stroke)
231
+ return stroke
232
+ }
233
+ return new Map(
234
+ annotations.map(annotation => {
235
+ const value = encoding
236
+ ? featureField(annotation, encoding.field)
237
+ : undefined
238
+ const fill =
239
+ annotation.color ??
240
+ (value === undefined ? undefined : encoding!.colorOf(value)) ??
241
+ (encoding ? UNSCALED_FEATURE : fillPalette[annotation.accession]!)
242
+ return [annotation, { fill, stroke: strokeOf(fill) }]
243
+ }),
244
+ )
245
+ }
246
+
247
+ // the text a label channel draws inside each span, for the features carrying
248
+ // the field it names
249
+ function featureLabelMap(annotations: Annotation[], field: string) {
250
+ const labels = new Map<Annotation, string>()
251
+ for (const annotation of annotations) {
252
+ const value = featureField(annotation, field)
253
+ if (value !== undefined) {
254
+ labels.set(annotation, value)
255
+ }
256
+ }
257
+ return labels
258
+ }
259
+
260
+ /**
261
+ * The spans a `features` panel draws, keyed by row name and measured in the
262
+ * panel's own pixels. `column` takes the bands the overlay draws, at the
263
+ * alignment's column width. `position` packs each row's features in its own
264
+ * residue positions, shifted by an `align` transform, and maps the extent they
265
+ * cover across every row onto the panel width, so a row with no alignment
266
+ * still has an x.
267
+ */
268
+ function featurePanelSpans({
269
+ panel,
270
+ width,
271
+ rowHeight,
272
+ colWidth,
273
+ domainBands,
274
+ annotationsByRow,
275
+ shifts,
276
+ }: {
277
+ panel: RowFeaturesSpec
278
+ width: number
279
+ rowHeight: number
280
+ colWidth: number
281
+ domainBands: Map<string, DomainBand[]>
282
+ annotationsByRow: Record<string, Annotation[]>
283
+ shifts: Map<string, number> | undefined
284
+ }): Map<string, RowPanelSpan[]> {
285
+ if (panel.x === 'column') {
286
+ return new Map(
287
+ [...domainBands].map(([name, bands]) => [
288
+ name,
289
+ bands.map(band => ({
290
+ ...band,
291
+ xStart: band.startCol * colWidth,
292
+ xEnd: band.endCol * colWidth,
293
+ })),
294
+ ]),
295
+ )
296
+ }
297
+ const shifted = Object.entries(annotationsByRow).map(
298
+ ([name, annotations]) => {
299
+ const shift = shifts?.get(name) ?? 0
300
+ return [
301
+ name,
302
+ annotations.map(annotation => ({
303
+ annotation,
304
+ start: annotation.start - 1 + shift,
305
+ end: annotation.end + shift,
306
+ })),
307
+ ] as const
308
+ },
309
+ )
310
+ let min = Infinity
311
+ let max = -Infinity
312
+ for (const [, features] of shifted) {
313
+ for (const { start, end } of features) {
314
+ min = Math.min(min, start)
315
+ max = Math.max(max, end)
316
+ }
317
+ }
318
+ // the arrowhead on the rightmost feature reaches a row height past its end,
319
+ // so the extent maps onto the panel less that much
320
+ const drawable = Math.max(1, width - rowHeight)
321
+ const scale = max > min ? drawable / (max - min) : 0
322
+ // a lane opens where a span covers more than a tenth of the one before it,
323
+ // which keeps the genes of an operon on one lane: adjacent genes commonly
324
+ // share a few bases, and a stop codon overlapping the next start reads as a
325
+ // second lane over the whole row
326
+ return new Map(
327
+ shifted.map(([name, features]) => [
328
+ name,
329
+ packDomainLanes(
330
+ features.map(({ annotation, start, end }) => ({
331
+ annotation,
332
+ xStart: (start - min) * scale,
333
+ xEnd: (end - min) * scale,
334
+ startCol: start,
335
+ endCol: end - (end - start) * laneOverlap,
336
+ })),
337
+ ),
338
+ ]),
339
+ )
340
+ }
341
+
342
+ // a scale over the values a field takes across the features drawn
343
+ function resolveFeatureScale(
344
+ field: string,
345
+ scale: ScaleSpec | undefined,
346
+ annotations: Annotation[],
347
+ ) {
348
+ return {
349
+ field,
350
+ ...resolveScale(
351
+ scale,
352
+ annotations.map(a => featureField(a, field)).filter(notEmpty),
353
+ ),
354
+ }
355
+ }
125
356
 
126
357
  // a data track over this size stays in the live model but leaves the snapshot,
127
358
  // the same rule DataModel applies to an inline document
128
- const maxColumnTrackSnapshotBytes = 50_000
359
+ function columnTrackSizes(tracks?: readonly ColumnTrackSpec[]) {
360
+ return (tracks ?? []).map(t => JSON.stringify(t).length)
361
+ }
129
362
 
130
363
  function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
131
- const kept = tracks?.filter(
132
- t => JSON.stringify(t).length <= maxColumnTrackSnapshotBytes,
133
- )
364
+ const sizes = columnTrackSizes(tracks)
365
+ const kept = tracks?.filter((_t, i) => sizes[i]! <= maxInlineSnapshotBytes)
134
366
  return kept?.length ? { columnTracks: kept } : {}
135
367
  }
136
368
 
@@ -138,17 +370,13 @@ function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
138
370
  * The snapshot properties reset() carries across a return to the import form:
139
371
  * display preferences and layout, nothing derived from the loaded file.
140
372
  *
141
- * reset() applies a default snapshot filtered to this list, so the list is the
142
- * whole decision: a property left off it resets to its default, a visible and
143
- * benign failure. The previous shape — a hand-maintained list of things to
144
- * CLEAR — failed in the dangerous direction: a forgotten property silently
145
- * carried the previous file's state into the next one, and because node ids
146
- * are path-derived (node-0-0-1), a stale `collapsed` or `showOnly` id matched
147
- * a real node in the new tree and folded it. Downstream composed properties
148
- * (e.g. the jbrowse plugin's) are not on the list, so they reset too.
373
+ * reset() applies a default snapshot filtered to this list, so any property
374
+ * left off it resets, including downstream composed ones (e.g. the jbrowse
375
+ * plugin's). Node ids are path-derived (node-0-0-1), so a carried-over
376
+ * `collapsed` or `showOnly` id would match a real node in the next tree and
377
+ * fold it.
149
378
  *
150
- * Exported for modelReset.test.ts, which checks that everything off this list
151
- * matches a freshly created model after reset().
379
+ * Exported for modelReset.test.ts.
152
380
  */
153
381
  export const preservedOnReset = new Set([
154
382
  'id',
@@ -156,6 +384,7 @@ export const preservedOnReset = new Set([
156
384
  'height',
157
385
  'drawMsaLetters',
158
386
  'scrollZoom',
387
+ 'scrollZoomAxis',
159
388
  'bgColor',
160
389
  'colorSchemeName',
161
390
  'showColumnStats',
@@ -166,23 +395,207 @@ export const preservedOnReset = new Set([
166
395
  'showBranchLen',
167
396
  'drawTree',
168
397
  'drawNodeBubbles',
398
+ 'drawNodeLabels',
399
+ 'showTreeOverview',
400
+ 'overviewHeight',
169
401
  'autoTreeAreaWidth',
170
402
  'turnedOffTracks',
403
+ 'trackHeights',
171
404
  'hideGaps',
172
405
  'allowedGappyness',
173
406
  'subFeatureRows',
174
407
  'showDomainLegend',
175
408
  ])
176
409
 
177
- // `turnedOffTracks` records the user's explicit choices only: an id is absent
178
- // until they touch that track, and then its value is whether the track is OFF.
179
- // Reading the default through this is what lets a track ship hidden without
180
- // writing an entry into every snapshot and shared URL.
410
+ // `turnedOffTracks` holds only the user's explicit choices, so a hidden-by-default
411
+ // track writes nothing into the snapshot. A file-supplied track passes its own
412
+ // `defaultOff`, since the count varies by file: a Pfam seed has a couple of #=GR
413
+ // lines, an Rfam family one per row.
181
414
  function trackIsOff(
182
415
  turnedOffTracks: { get: (id: string) => boolean | undefined },
183
416
  id: string,
417
+ defaultOff?: boolean,
418
+ ) {
419
+ return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id))
420
+ }
421
+
422
+ // shared empty results, so observers don't see a fresh [] as a change
423
+ const noDomains: Annotation[] = []
424
+ const noClades: ResolvedClade[] = []
425
+
426
+ /**
427
+ * The rows a clade covers and the node an `mrca` names, or undefined when the
428
+ * clade does not resolve: a tip name the tree does not have or has twice, or a
429
+ * leaf count `tips` disagrees with. `index` and `rowNamesSet` are the memoized
430
+ * passes over the tree.
431
+ */
432
+ function cladeRows(
433
+ clade: Clade,
434
+ root: HierarchyNode<NodeWithIds>,
435
+ index: Map<string, HierarchyNode<NodeWithIds> | undefined>,
436
+ rowNamesSet: Map<string, number>,
437
+ ): { rows: [number, number]; nodeId?: string } | undefined {
438
+ if (clade.range) {
439
+ const [a, b] = clade.range.map(name =>
440
+ index.get(name) ? rowNamesSet.get(name) : undefined,
441
+ )
442
+ if (a === undefined || b === undefined) {
443
+ return undefined
444
+ }
445
+ const rows: [number, number] = a <= b ? [a, b] : [b, a]
446
+ return rows[1] - rows[0] + 1 === clade.tips ? { rows } : undefined
447
+ }
448
+ const node = clade.mrca ? mrca(root, clade.mrca, index) : undefined
449
+ if (!node) {
450
+ return undefined
451
+ }
452
+ const tips = leaves(node)
453
+ if (tips.length !== clade.tips) {
454
+ return undefined
455
+ }
456
+ let first = Infinity
457
+ let last = -Infinity
458
+ for (const tip of tips) {
459
+ const row = rowNamesSet.get(tip.data.name)
460
+ if (row !== undefined) {
461
+ first = Math.min(first, row)
462
+ last = Math.max(last, row)
463
+ }
464
+ }
465
+ return first <= last
466
+ ? { rows: [first, last], nodeId: node.data.id }
467
+ : undefined
468
+ }
469
+
470
+ /**
471
+ * Every clade that resolves, with its fill color settled, against the leaf
472
+ * order `rowNamesSet` gives. The tree panel resolves against the displayed
473
+ * rows and the overview against its own, so both take the tree they draw. A
474
+ * `range` record names no node, so `collapse` and `focus`, which need one,
475
+ * drop it.
476
+ */
477
+ function resolveClades(
478
+ clades: Clade[],
479
+ root: HierarchyNode<NodeWithIds>,
480
+ rowNamesSet: Map<string, number>,
481
+ ): ResolvedClade[] {
482
+ const index = leafIndex(root)
483
+ return clades.flatMap(clade => {
484
+ const resolved = cladeRows(clade, root, index, rowNamesSet)
485
+ const seeding = clade.mark === 'collapse' || clade.mark === 'focus'
486
+ return resolved && !(seeding && resolved.nodeId === undefined)
487
+ ? [
488
+ {
489
+ ...resolved,
490
+ mark: clade.mark,
491
+ color: withAlpha(
492
+ clade.color ?? cladeHighlightColor,
493
+ cladeHighlightAlpha,
494
+ ),
495
+ markColor: clade.color,
496
+ label: clade.label,
497
+ },
498
+ ]
499
+ : []
500
+ })
501
+ }
502
+
503
+ /**
504
+ * The tree with the display transforms applied: leaf counts summed, children
505
+ * sorted by branch length, `showOnly` taken as the new root, and each
506
+ * `collapsed` clade folded. The overview builds the same tree without the
507
+ * focus, so it shows the focused subtree inside the whole.
508
+ */
509
+ function buildTreeRoot(
510
+ tree: NodeWithIds,
511
+ collapsed: readonly string[],
512
+ showOnly?: string,
184
513
  ) {
185
- return turnedOffTracks.get(id) ?? defaultOffTracks.has(id)
514
+ let hier = hierarchy(tree, d => d.children)
515
+ hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1))
516
+ sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1))
517
+
518
+ if (showOnly) {
519
+ const res = find(hier, n => n.data.id === showOnly)
520
+ if (res) {
521
+ hier = res
522
+ }
523
+ }
524
+
525
+ for (const collapsedId of collapsed) {
526
+ const node = find(hier, n => n.data.id === collapsedId)
527
+ if (node) {
528
+ if (node.children) {
529
+ collapse(node)
530
+ } else if (node.parent?.children) {
531
+ node.parent.children = node.parent.children.filter(
532
+ c => c.data.id !== collapsedId,
533
+ )
534
+ }
535
+ }
536
+ }
537
+
538
+ return hier
539
+ }
540
+
541
+ // the inclusive tip indices a node covers, from the row-space extent
542
+ // clusterLayout writes to xMin/xMax as tip centers
543
+ function tipRange(node: HierarchyNode): [number, number] {
544
+ return [Math.round(node.xMin! - 0.5), Math.round(node.xMax! - 0.5)]
545
+ }
546
+
547
+ // the channels reading the feature table; every other channel reads rowData
548
+ const featureChannels = new Set<EncodingChannel>([
549
+ 'featureFill',
550
+ 'featureLabel',
551
+ ])
552
+
553
+ // seqPos -> column indexes per row, keyed on the parse so they are garbage
554
+ // collected with it. A computed would rebuild every row's index when read
555
+ // outside a reactive context.
556
+ const seqPosIndexCache = new WeakMap<object, Map<string, Int32Array>>()
557
+
558
+ // A segment is a 1:1 run. A segment whose sides differ in length is malformed,
559
+ // and the lookups treat it like an uncovered position.
560
+ function sameLength(segment: ResidueSegment) {
561
+ return (
562
+ segment.rowEnd - segment.rowStart ===
563
+ segment.structEnd - segment.structStart
564
+ )
565
+ }
566
+
567
+ // fit() divides the viewport by the row or column count and multiplies back, so
568
+ // an exact fit can land a fraction of a pixel over. A plain `>` would then show
569
+ // a scrollbar that shrinks the viewport and leaves a gap.
570
+ function overflows(content: number, viewport: number) {
571
+ return content - viewport > 0.5
572
+ }
573
+
574
+ function inRanges(ranges: [number, number][] | undefined, position: number) {
575
+ return !!ranges?.some(([start, end]) => position >= start && position <= end)
576
+ }
577
+
578
+ // the value every one of a node's children reports, or undefined where one of
579
+ // them has no value or they disagree
580
+ function sharedValue(values: (string | undefined)[]) {
581
+ const [first] = values
582
+ return first !== undefined && values.every(v => v === first)
583
+ ? first
584
+ : undefined
585
+ }
586
+
587
+ // preorder, so reversing the list puts every node after its descendants
588
+ function preorder(tree: NodeWithIds) {
589
+ const order: NodeWithIds[] = []
590
+ const stack = [tree]
591
+ while (stack.length > 0) {
592
+ const node = stack.pop()!
593
+ order.push(node)
594
+ for (const child of node.children) {
595
+ stack.push(child)
596
+ }
597
+ }
598
+ return order
186
599
  }
187
600
 
188
601
  /**
@@ -225,10 +638,8 @@ function stateModelFactory() {
225
638
  /**
226
639
  * #property
227
640
  * whether the domain legend is expanded. The legend floats over the
228
- * top-right of the alignment, so on a tall panel it covers real
229
- * residues -- persisting the state is what lets a reader collapse it
230
- * and keep it collapsed, and what lets a session or a figure open with
231
- * it already out of the way.
641
+ * top-right of the alignment and covers residues, so a session or
642
+ * figure can open with it collapsed.
232
643
  */
233
644
  showDomainLegend: stripDefault(types.boolean, defaultShowDomainLegend),
234
645
  /**
@@ -261,6 +672,15 @@ function stateModelFactory() {
261
672
  */
262
673
  scrollZoom: stripDefault(types.boolean, defaultScrollZoom),
263
674
 
675
+ /**
676
+ * #property
677
+ * which cell dimensions a wheel zoom scales, while `scrollZoom` is on
678
+ */
679
+ scrollZoomAxis: stripDefault(
680
+ types.enumeration('ScrollZoomAxis', [...scrollZoomAxes]),
681
+ defaultScrollZoomAxis,
682
+ ),
683
+
264
684
  /**
265
685
  * #property
266
686
  * height of the div containing the view, px
@@ -341,17 +761,38 @@ function stateModelFactory() {
341
761
  * hidden-by-default track adds nothing to the shared URL.
342
762
  */
343
763
  turnedOffTracks: stripDefault(types.map(types.boolean), {}),
764
+
765
+ /**
766
+ * #property
767
+ * the height of every track one divider resizes, keyed by `heightKey`:
768
+ * the `kind` for the computed tracks, `own:<id>` for a data track. A
769
+ * key is absent until the user drags that divider, and
770
+ * `defaultTrackHeights` answers for it until then, so an untouched
771
+ * viewer adds nothing to the shared URL.
772
+ */
773
+ trackHeights: stripDefault(types.map(types.number), {}),
344
774
  /**
345
775
  * #property
346
- * tracks supplied as data rather than computed from the alignment:
347
- * per-column values drawn as bars, or a per-column string drawn as a
348
- * text track. See docs/layers.md
776
+ * tracks supplied as data: per-column values drawn as bars, or a
777
+ * per-column string drawn as a text track. See docs/layers.md
349
778
  */
350
779
  columnTracks: stripDefault(
351
780
  types.array(types.frozen<ColumnTrackSpec>()),
352
781
  [],
353
782
  ),
354
783
 
784
+ /**
785
+ * #property
786
+ * row-to-structure residue correspondence, computed outside the viewer
787
+ * (e.g. from SIFTS). Matching by sequence equality places a tagged
788
+ * construct, a truncation or a subsequence row on the wrong residue.
789
+ * See docs/layers.md
790
+ */
791
+ residueMappings: stripDefault(
792
+ types.array(types.frozen<ResidueMapping>()),
793
+ [],
794
+ ),
795
+
355
796
  /**
356
797
  * #property
357
798
  * data from the loaded tree/msa/treeMetadata, generally loaded by
@@ -365,8 +806,12 @@ function stateModelFactory() {
365
806
 
366
807
  /**
367
808
  * #property
809
+ * the user's explicit hide choices per annotation accession, keyed by
810
+ * accession with the value meaning "off", like `turnedOffTracks`. An
811
+ * untouched accession is absent and drawn, so the shared URL grows
812
+ * only with the user's filters
368
813
  */
369
- featureFilters: stripDefault(types.map(types.boolean), {}),
814
+ turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
370
815
  /**
371
816
  * #property
372
817
  */
@@ -385,9 +830,34 @@ function stateModelFactory() {
385
830
  * labeled highlights in 1-based inclusive coordinates: a column span
386
831
  * `{start, end}`, a residue span `{row, start, end}` of a named row,
387
832
  * or a row set `{rows}`, each with an optional `label` and `color`.
388
- * Persists in the snapshot, so a computed answer travels in the URL.
833
+ * Persists in the snapshot and the URL.
389
834
  */
390
835
  highlights: stripDefault(types.array(types.frozen<Highlight>()), []),
836
+ /**
837
+ * #property
838
+ * clades of the tree with a mark drawn over them. `mrca` names tips
839
+ * whose common ancestor is the clade, or `range` its first and last
840
+ * tip in display order, and `tips` is the leaf count the producer
841
+ * measured. `mark` is `highlight`, `bracket`, `collapse` or `focus`.
842
+ * See docs/layers.md
843
+ */
844
+ clades: stripDefault(types.array(types.frozen<Clade>()), []),
845
+ /**
846
+ * #property
847
+ * what the viewer's own marks read from `rowData`:
848
+ * `{channel, field, scale?}` per channel, where `channel` is
849
+ * `tipLabel` or `rowTint`. See docs/layers.md
850
+ */
851
+ encodings: stripDefault(types.array(types.frozen<Encoding>()), []),
852
+ /**
853
+ * #property
854
+ * panels drawn between the tree and the alignment:
855
+ * `{kind: "strip", field, scale?, width?, header?}` colors one cell
856
+ * per row from a `rowData` field, and
857
+ * `{kind: "features", x, encoding?, transform?, width?, header?}`
858
+ * draws the spans the GFF carries. See docs/layers.md
859
+ */
860
+ rowPanels: stripDefault(types.array(types.frozen<RowPanelSpec>()), []),
391
861
  }),
392
862
  )
393
863
  .volatile(() => ({
@@ -397,15 +867,17 @@ function stateModelFactory() {
397
867
  headerHeight: 0,
398
868
  /**
399
869
  * #volatile
870
+ * leaves the toolbar out, for a host drawing its own controls. Kept out
871
+ * of the snapshot so a link opened in the full app shows the toolbar.
400
872
  */
401
- status: undefined as
402
- | { msg: string; url?: string; onCancel?: () => void }
403
- | undefined,
873
+ hideHeader: false,
874
+ /**
875
+ * #volatile
876
+ */
877
+ status: undefined as { msg: string; onCancel?: () => void } | undefined,
404
878
  /**
405
879
  * #volatile
406
- * high resolution scale factor, helps make canvas look better on hi-dpi
407
- * screens. derived from the device pixel ratio so canvases are crisp on
408
- * retina/4k displays and not needlessly oversized on standard ones
880
+ * canvas scale factor, from the device pixel ratio
409
881
  */
410
882
  highResScaleFactor:
411
883
  typeof window === 'undefined' ? 1 : window.devicePixelRatio,
@@ -472,6 +944,13 @@ function stateModelFactory() {
472
944
  */
473
945
  highlightedColumns: undefined as number[] | undefined,
474
946
 
947
+ /**
948
+ * #volatile
949
+ * transient highlights keyed by owner, so a structure viewer's hover and
950
+ * a genome view's hover each clear only their own. Not persisted.
951
+ */
952
+ transientHighlights: {} as Record<string, Highlight[]>,
953
+
475
954
  /**
476
955
  * #volatile
477
956
  */
@@ -480,33 +959,41 @@ function stateModelFactory() {
480
959
  /**
481
960
  * #volatile
482
961
  */
483
- conservationTrackHeight: 40,
962
+ marginLeft: 20,
484
963
 
485
964
  /**
486
965
  * #volatile
487
- * taller than the conservation track by default: the logo spends its
488
- * height on stacked glyphs, and a 40px stack of four residues leaves each
489
- * one too short to identify
490
966
  */
491
- sequenceLogoTrackHeight: 80,
967
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
968
+ error: undefined as unknown,
492
969
 
493
970
  /**
494
971
  * #volatile
972
+ * non-fatal load problems: an optional layer that failed to load, an
973
+ * overlay that failed to parse. `error` replaces the view and is for the
974
+ * alignment itself
495
975
  */
496
- marginLeft: 20,
976
+ warnings: [] as string[],
497
977
 
498
978
  /**
499
979
  * #volatile
980
+ * bumped by reset(). The error boundary above the view uses it as its key,
981
+ * since the boundary keeps its caught error until remounted
500
982
  */
501
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
502
- error: undefined as unknown,
983
+ resetCount: 0,
984
+
985
+ /**
986
+ * #volatile
987
+ * set by a host that restores the loaded documents itself, such as a
988
+ * jbrowse session or a page that refetches them. `unshareableData` then
989
+ * reports nothing
990
+ */
991
+ hostCarriesData: false,
503
992
 
504
993
  /**
505
994
  * #volatile
506
- * overlay annotations drawn on the alignment, whatever their source.
507
- * Every source -- InterProScan, GFF, a user upload -- converts to this
508
- * flat list before it reaches the model, so nothing downstream of here
509
- * knows which one it came from
995
+ * overlay annotations drawn on the alignment. InterProScan JSON, GFF and
996
+ * user uploads all convert to this flat list
510
997
  */
511
998
  annotations: [] as Annotation[],
512
999
  }))
@@ -552,8 +1039,8 @@ function stateModelFactory() {
552
1039
  },
553
1040
  /**
554
1041
  * #action
555
- * high-res scale factor, tracks the device pixel ratio so canvases stay
556
- * crisp when the window moves between monitors or the browser zooms
1042
+ * update the canvas scale factor when the device pixel ratio changes
1043
+ * (moving between monitors, browser zoom)
557
1044
  */
558
1045
  setHighResScaleFactor(arg: number) {
559
1046
  self.highResScaleFactor = arg
@@ -574,13 +1061,38 @@ function stateModelFactory() {
574
1061
  self.error = error
575
1062
  },
576
1063
 
1064
+ /**
1065
+ * #action
1066
+ * record a non-fatal load problem: a layer that failed to load, a file
1067
+ * that failed to parse
1068
+ */
1069
+ addWarning(warning: string) {
1070
+ self.warnings = [...self.warnings, warning]
1071
+ },
1072
+
1073
+ /**
1074
+ * #action
1075
+ */
1076
+ clearWarnings() {
1077
+ self.warnings = []
1078
+ },
1079
+
1080
+ /**
1081
+ * #action
1082
+ * declare that this host restores the loaded documents itself, which
1083
+ * hides the "Not in the link" warning. See `hostCarriesData`
1084
+ */
1085
+ setHostCarriesData(arg: boolean) {
1086
+ self.hostCarriesData = arg
1087
+ },
1088
+
577
1089
  /**
578
1090
  * #action
579
1091
  * set mouse position (row, column) in the MSA
580
1092
  *
581
- * CROSS-REPO CONTRACT: jbrowse-plugin-protein3d calls this (and reads the
582
- * `mouseCol` volatile) to sync MSA<->3D-structure hover. Keep the name and
583
- * signature stable; see that repo's ProteinToMsaHoverSync.tsx.
1093
+ * Public API: a host calls this (and reads `mouseCol`) to sync hover with
1094
+ * its own view, such as a genome view or 3D structure. Keep the name and
1095
+ * signature stable.
584
1096
  */
585
1097
  setMousePos(col?: number, row?: number) {
586
1098
  self.mouseCol = col
@@ -591,9 +1103,9 @@ function stateModelFactory() {
591
1103
  * #action
592
1104
  * set highlighted columns
593
1105
  *
594
- * CROSS-REPO CONTRACT: called by jbrowse-plugin-msaview
595
- * (afterCreateAutoruns.ts) to highlight alignment columns. It has no
596
- * in-repo caller, so do not flag it as dead code — it is public API.
1106
+ * Public API: jbrowse-plugin-msaview calls this from its
1107
+ * afterCreateAutoruns, and MSAViewer passes its `highlightColumns` prop
1108
+ * through it.
597
1109
  */
598
1110
  setHighlightedColumns(columns?: number[]) {
599
1111
  self.highlightedColumns = columns
@@ -604,6 +1116,35 @@ function stateModelFactory() {
604
1116
  setHighlights(highlights: Highlight[]) {
605
1117
  self.highlights.replace(highlights)
606
1118
  },
1119
+
1120
+ /**
1121
+ * #action
1122
+ * replace the clades the viewer marks (see docs/layers.md)
1123
+ */
1124
+ setClades(clades: Clade[]) {
1125
+ self.clades.replace(clades)
1126
+ },
1127
+ /**
1128
+ * #action
1129
+ * show `highlights` for `owner`, replacing that owner's previous ones and
1130
+ * leaving other owners' in place
1131
+ */
1132
+ applyHighlight(owner: string, highlights: Highlight[]) {
1133
+ self.transientHighlights = {
1134
+ ...self.transientHighlights,
1135
+ [owner]: highlights,
1136
+ }
1137
+ },
1138
+ /**
1139
+ * #action
1140
+ * remove `owner`'s highlights, leaving other owners' in place
1141
+ */
1142
+ clearHighlight(owner: string) {
1143
+ if (owner in self.transientHighlights) {
1144
+ const { [owner]: _dropped, ...rest } = self.transientHighlights
1145
+ self.transientHighlights = rest
1146
+ }
1147
+ },
607
1148
  /**
608
1149
  * #action
609
1150
  * toggle the annotation overlay on the alignment
@@ -651,10 +1192,22 @@ function stateModelFactory() {
651
1192
 
652
1193
  /**
653
1194
  * #action
654
- *
1195
+ * switch to another alignment of a multi-alignment file (Stockholm).
1196
+ * Clears the collapsed node ids, the subtree in focus, the reference row
1197
+ * and the scroll position, which all refer to the previous alignment
655
1198
  */
656
1199
  setCurrentAlignment(n: number) {
657
- self.currentAlignment = n
1200
+ if (n === self.currentAlignment) {
1201
+ return
1202
+ }
1203
+ transaction(() => {
1204
+ self.currentAlignment = n
1205
+ self.collapsed.clear()
1206
+ this.setShowOnly(undefined)
1207
+ this.drawRelativeTo(undefined)
1208
+ self.scrollX = 0
1209
+ self.scrollY = 0
1210
+ })
658
1211
  },
659
1212
 
660
1213
  /**
@@ -740,6 +1293,17 @@ function stateModelFactory() {
740
1293
  setTreeMetadata(result: string) {
741
1294
  self.data.setTreeMetadata(result)
742
1295
  },
1296
+
1297
+ /**
1298
+ * #action
1299
+ * store the GFF text in the snapshot like the alignment and tree. The
1300
+ * parsed annotations are volatile and a blob filehandle is cleared once
1301
+ * read, so the text is the only persisted copy. An autorun parses it
1302
+ * into annotations.
1303
+ */
1304
+ setGFF(result: string) {
1305
+ self.data.setGFF(result)
1306
+ },
743
1307
  }))
744
1308
 
745
1309
  .views(self => ({
@@ -765,6 +1329,20 @@ function stateModelFactory() {
765
1329
  get actuallyShowDomains() {
766
1330
  return self.showDomains && self.annotations.length > 0
767
1331
  },
1332
+ /**
1333
+ * #getter
1334
+ * whether the host restores the loaded documents outside the snapshot.
1335
+ * When true, `unshareableData` is empty.
1336
+ *
1337
+ * A simple host sets `hostCarriesData`. A host where this depends on how
1338
+ * the view was opened overrides the getter in its own composed model's
1339
+ * `.views` block; jbrowse-plugin-msaview's indexed-location views refetch
1340
+ * from a URL the session holds, while its data-store views do not.
1341
+ * `unshareableData` reads it off `self`, so an override takes effect.
1342
+ */
1343
+ get hostRestoresData() {
1344
+ return self.hostCarriesData
1345
+ },
768
1346
  get viewInitialized() {
769
1347
  return self.volatileWidth !== undefined
770
1348
  },
@@ -779,19 +1357,12 @@ function stateModelFactory() {
779
1357
  },
780
1358
  }))
781
1359
  .views(self => ({
782
- /**
783
- * #method
784
- * unused here, but can be used by derived classes to add extra items
785
- */
786
- extraViewMenuItems() {
787
- return []
788
- },
789
1360
  /**
790
1361
  * #getter
791
1362
  */
792
1363
  get colorScheme() {
793
- // colorSchemeName is a free string (menus, snapshots, URL params); fall
794
- // back to the default rather than returning undefined on a stale name
1364
+ // colorSchemeName is a free string (menus, snapshots, URL params), so a
1365
+ // stale name falls back to the default
795
1366
  return (
796
1367
  colorSchemes[self.colorSchemeName] ??
797
1368
  colorSchemes[defaultColorSchemeName]!
@@ -820,18 +1391,60 @@ function stateModelFactory() {
820
1391
  get noDomains() {
821
1392
  return self.annotations.length === 0
822
1393
  },
1394
+
1395
+ /**
1396
+ * #getter
1397
+ * loaded documents left out of the snapshot, largest first. A file opened
1398
+ * from disk or pasted in becomes inline text, and DataModel drops an
1399
+ * inline document past `maxInlineSnapshotBytes`.
1400
+ *
1401
+ * The header lists these, and the standalone app stops rewriting the
1402
+ * address bar while the list is non-empty, so a copied link does not
1403
+ * open an empty viewer unannounced. A document fetched from a URL never
1404
+ * appears here, since the snapshot keeps its filehandle.
1405
+ *
1406
+ * Empty when `hostRestoresData` is true.
1407
+ */
1408
+ get unshareableData(): UnshareableData[] {
1409
+ if (self.hostRestoresData) {
1410
+ return []
1411
+ }
1412
+ const { data } = self
1413
+ // data tracks past the limit also leave the snapshot
1414
+ const trackBytes = columnTrackSizes(self.columnTracks)
1415
+ .filter(bytes => bytes > maxInlineSnapshotBytes)
1416
+ .reduce((a, b) => a + b, 0)
1417
+ return (
1418
+ [
1419
+ ['alignment', data.msa, self.msaFilehandle],
1420
+ ['tree', data.tree, self.treeFilehandle],
1421
+ ['annotations', data.gff, self.gffFilehandle],
1422
+ ['row metadata', data.treeMetadata, self.treeMetadataFilehandle],
1423
+ ] as const
1424
+ )
1425
+ .flatMap<UnshareableData>(([what, text, filehandle]) =>
1426
+ !filehandle && text && text.length > maxInlineSnapshotBytes
1427
+ ? [{ what, bytes: text.length }]
1428
+ : [],
1429
+ )
1430
+ .concat(
1431
+ trackBytes ? [{ what: 'data tracks', bytes: trackBytes }] : [],
1432
+ )
1433
+ .sort((a, b) => b.bytes - a.bytes)
1434
+ },
823
1435
  menuItems() {
824
1436
  return []
825
1437
  },
826
1438
  /**
827
1439
  * #getter
828
- * extra per-row attributes, keyed by row name. Parsed defensively: the
829
- * source is a user-supplied document (treeMetadataFilehandle, or a
830
- * session snapshot), and this computed is read by labelWidthMap on every
831
- * layout, so a malformed file would otherwise throw out of rendering and
832
- * take the whole view down over a decorative field.
833
- */
834
- get treeMetadata(): Record<string, Record<string, string> | undefined> {
1440
+ * the row table: extra fields per row, keyed by row name, which the
1441
+ * `encodings` channels read. It is stored as the JSON string
1442
+ * `data.treeMetadata`, the name that travels in existing links, so the
1443
+ * inline size limit and `treeMetadataFilehandle` cover it. labelWidthMap
1444
+ * reads it on every layout, so a malformed user-supplied file returns {}
1445
+ * instead of throwing out of rendering.
1446
+ */
1447
+ get rowData(): Record<string, Record<string, string> | undefined> {
835
1448
  const text = self.data.treeMetadata
836
1449
  if (!text) {
837
1450
  return {}
@@ -842,10 +1455,31 @@ function stateModelFactory() {
842
1455
  ? (parsed as Record<string, Record<string, string> | undefined>)
843
1456
  : {}
844
1457
  } catch (e) {
845
- console.error('failed to parse treeMetadata', e)
1458
+ console.error('failed to parse rowData', e)
846
1459
  return {}
847
1460
  }
848
1461
  },
1462
+ /**
1463
+ * #method
1464
+ * one row's fields, the single reader of the row table
1465
+ */
1466
+ rowDataOf(name: string) {
1467
+ return this.rowData[name]
1468
+ },
1469
+ /**
1470
+ * #getter
1471
+ * the field names the row table carries, sorted, for a producer or a UI
1472
+ * choosing one to encode
1473
+ */
1474
+ get rowFields(): string[] {
1475
+ const fields = new Set<string>()
1476
+ for (const row of Object.values(this.rowData)) {
1477
+ for (const field of Object.keys(row ?? {})) {
1478
+ fields.add(field)
1479
+ }
1480
+ }
1481
+ return [...fields].sort((a, b) => a.localeCompare(b))
1482
+ },
849
1483
  /**
850
1484
  * #getter
851
1485
  */
@@ -895,6 +1529,37 @@ function stateModelFactory() {
895
1529
  this.leaves.map((leaf, index) => [leaf.data.name, index] as const),
896
1530
  )
897
1531
  },
1532
+ /**
1533
+ * #getter
1534
+ * `clades` resolved to the rows each one covers. The tip names resolve
1535
+ * against `tree` rather than `root`, so a clade whose ancestor the user
1536
+ * collapsed keeps its rows. One leaf pass over the tree serves every
1537
+ * clade. A `range` record names no node, so `collapse` and `focus`, which
1538
+ * need one, drop it.
1539
+ */
1540
+ get resolvedClades(): ResolvedClade[] {
1541
+ if (self.clades.length === 0) {
1542
+ return noClades
1543
+ }
1544
+ return resolveClades(
1545
+ self.clades,
1546
+ hierarchy(this.tree, d => d.children),
1547
+ this.rowNamesSet,
1548
+ )
1549
+ },
1550
+ /**
1551
+ * #getter
1552
+ * the pixel column reserved at the right of the tree area for the bracket
1553
+ * mark, which the tip labels and the tree itself stay clear of. Zero
1554
+ * where no clade draws a bar or a label.
1555
+ */
1556
+ get cladeGutterWidth() {
1557
+ return cladeGutterWidth({
1558
+ clades: this.resolvedClades,
1559
+ rowHeight: self.rowHeight,
1560
+ fontSize: this.fontSize,
1561
+ })
1562
+ },
898
1563
  /**
899
1564
  * #getter
900
1565
  */
@@ -928,46 +1593,143 @@ function stateModelFactory() {
928
1593
  * #getter
929
1594
  */
930
1595
  get root() {
931
- let hier = hierarchy(this.tree, d => d.children)
932
- hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1))
933
- sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1))
934
-
935
- if (self.showOnly) {
936
- const res = find(hier, n => n.data.id === self.showOnly)
937
- if (res) {
938
- hier = res
939
- }
1596
+ return buildTreeRoot(this.tree, self.collapsed, self.showOnly)
1597
+ },
1598
+
1599
+ /**
1600
+ * #getter
1601
+ * height of the band the tree overview draws in, zero when it is off
1602
+ */
1603
+ get treeOverviewHeight() {
1604
+ return self.showTreeOverview ? self.overviewHeight : 0
1605
+ },
1606
+
1607
+ /**
1608
+ * #getter
1609
+ * the whole tree laid out for the overview, or undefined when the
1610
+ * overview is off. The focus is left out, so the focused subtree draws
1611
+ * inside the whole tree, and the collapsed clades are folded, since
1612
+ * those are rows the view no longer has. `x` is in tip-index space and
1613
+ * `len` is a fraction of the root-to-tip length, so one layout serves
1614
+ * any band size.
1615
+ */
1616
+ get treeOverviewLayout() {
1617
+ if (!self.showTreeOverview) {
1618
+ return undefined
1619
+ }
1620
+ const root = buildTreeRoot(this.tree, self.collapsed)
1621
+ const numTips = leaves(root).length
1622
+ clusterLayout(root, numTips, 1)
1623
+ const rootLen = Math.max(root.data.length || 0, 0)
1624
+ const extent = maxLength(root) - rootLen
1625
+ setBrLength(root, -rootLen, extent ? 1 / extent : 0)
1626
+ return {
1627
+ root,
1628
+ numTips,
1629
+ maxDepthToLeaf: calcDepthToLeaf(root),
1630
+ showBranchLen: self.showBranchLen && extent > 0,
940
1631
  }
1632
+ },
941
1633
 
942
- for (const collapsedId of self.collapsed) {
943
- const node = find(hier, n => n.data.id === collapsedId)
944
- if (node) {
945
- if (node.children) {
946
- collapse(node)
947
- } else if (node.parent?.children) {
948
- node.parent.children = node.parent.children.filter(
949
- c => c.data.id !== collapsedId,
950
- )
951
- }
952
- }
1634
+ /**
1635
+ * #getter
1636
+ * the `clades` highlights in the overview's own row space, which the
1637
+ * focus does not narrow
1638
+ */
1639
+ get treeOverviewClades(): ResolvedClade[] {
1640
+ const layout = this.treeOverviewLayout
1641
+ if (!layout || self.clades.length === 0) {
1642
+ return noClades
1643
+ }
1644
+ const rowNames = new Map(
1645
+ leaves(layout.root).map((leaf, index) => [leaf.data.name, index]),
1646
+ )
1647
+ return resolveClades(self.clades, layout.root, rowNames)
1648
+ },
1649
+
1650
+ /**
1651
+ * #getter
1652
+ * the inclusive tip rows the focused subtree covers in the overview,
1653
+ * which is the box drawn on it. undefined with no focus
1654
+ */
1655
+ get treeOverviewFocusRows(): [number, number] | undefined {
1656
+ const layout = this.treeOverviewLayout
1657
+ if (!layout || !self.showOnly) {
1658
+ return undefined
953
1659
  }
1660
+ const node = find(layout.root, n => n.data.id === self.showOnly)
1661
+ return node ? tipRange(node) : undefined
1662
+ },
954
1663
 
955
- return hier
1664
+ /**
1665
+ * #method
1666
+ * the subtree a point `y` pixels down the tree overview picks: the
1667
+ * deepest one whose tip range covers every row under that pixel, with
1668
+ * the rows it covers. A pixel stands for several tips on a large tree,
1669
+ * which is what keeps the pick off the individual tips. undefined when
1670
+ * the overview is off or the point picks the whole tree.
1671
+ */
1672
+ treeOverviewHit(y: number) {
1673
+ const layout = this.treeOverviewLayout
1674
+ if (!layout) {
1675
+ return undefined
1676
+ }
1677
+ const { root, numTips } = layout
1678
+ const perPixel = numTips / self.overviewHeight
1679
+ const first = clamp(Math.floor(y * perPixel), 0, numTips - 1)
1680
+ const last = clamp(
1681
+ Math.ceil((y + 1) * perPixel) - 1,
1682
+ first,
1683
+ numTips - 1,
1684
+ )
1685
+ let node = nodeCoveringRows(root, first + 0.5, last + 0.5)
1686
+ // focusing one tip leaves a single row on screen, so the pick lifts to
1687
+ // the subtree that tip sits in
1688
+ while (!node.children && node.parent) {
1689
+ node = node.parent
1690
+ }
1691
+ return node === root
1692
+ ? undefined
1693
+ : { id: node.data.id, rows: tipRange(node) }
956
1694
  },
957
1695
 
958
1696
  /**
959
1697
  * #getter
960
- * widget width minus the tree area gives the space for the MSA
1698
+ * the pixel column the row panels occupy between the tree and the
1699
+ * alignment, the sum of each record's width
1700
+ */
1701
+ get rowPanelsWidth() {
1702
+ return sum(self.rowPanels.map(p => rowPanelWidth(p, self.rowHeight)))
1703
+ },
1704
+ /**
1705
+ * #getter
1706
+ * height of the band the row panel headers draw in, which is zero with
1707
+ * no row panels and leaves the top area as it was
1708
+ */
1709
+ get rowPanelsHeaderHeight() {
1710
+ return self.rowPanels.length > 0 ? rowPanelHeaderHeight : 0
1711
+ },
1712
+ /**
1713
+ * #getter
1714
+ * widget width minus the tree area and the row panels gives the space
1715
+ * for the MSA
961
1716
  */
962
1717
  get msaAreaWidth() {
963
- return self.width - self.treeAreaWidth - self.resizeHandleWidth
1718
+ return (
1719
+ self.width -
1720
+ self.treeAreaWidth -
1721
+ this.rowPanelsWidth -
1722
+ self.resizeHandleWidth
1723
+ )
964
1724
  },
965
1725
 
966
1726
  /**
967
1727
  * #getter
1728
+ * the right edge the tip labels end at, which is the tree area less the
1729
+ * margin and the bracket gutter
968
1730
  */
969
1731
  get treeAreaWidthMinusMargin() {
970
- return self.treeAreaWidth - self.marginLeft
1732
+ return self.treeAreaWidth - self.marginLeft - this.cladeGutterWidth
971
1733
  },
972
1734
  /**
973
1735
  * #getter
@@ -1037,34 +1799,54 @@ function stateModelFactory() {
1037
1799
  },
1038
1800
  /**
1039
1801
  * #getter
1040
- * number of rows the alignment occupies on screen. This is the leaf count,
1041
- * not `rows.length`: a tree leaf with no matching MSA row still takes up a
1042
- * row of vertical space (drawn blank), so row hit-testing and fit-to-height
1043
- * must count it.
1802
+ * number of rows on screen: the leaf count, which includes tree leaves
1803
+ * with no matching MSA row (drawn blank), unlike `rows.length`.
1044
1804
  */
1045
1805
  get numRows() {
1046
1806
  return this.leaves.length
1047
1807
  },
1048
1808
 
1049
1809
  /**
1050
- * #getter
1051
- * per-row index of the global column holding each ungapped sequence
1052
- * position, so seqPos -> column is a lookup rather than a scan of the row.
1053
- * The domain overlay resolves thousands of these per redraw.
1054
- */
1055
- get seqPosGlobalColIndex() {
1056
- return new Map(
1057
- this.rows.map(
1058
- ([name, seq]) => [name, buildSeqPosIndex(seq)] as const,
1059
- ),
1060
- )
1061
- },
1062
-
1063
- /**
1064
- * #getter
1810
+ * #method
1811
+ * index of the global column holding each ungapped sequence position of a
1812
+ * row. The domain overlay resolves thousands of these per redraw. Built
1813
+ * lazily per row and cached on the parse.
1814
+ */
1815
+ seqPosIndex(rowName: string): Int32Array | undefined {
1816
+ const MSA = this.MSA
1817
+ if (!MSA) {
1818
+ return undefined
1819
+ }
1820
+ let cache = seqPosIndexCache.get(MSA)
1821
+ if (!cache) {
1822
+ cache = new Map()
1823
+ seqPosIndexCache.set(MSA, cache)
1824
+ }
1825
+ let index = cache.get(rowName)
1826
+ if (!index) {
1827
+ const seq = MSA.getRow(rowName)
1828
+ if (!seq) {
1829
+ return undefined
1830
+ }
1831
+ index = buildSeqPosIndex(seq)
1832
+ cache.set(rowName, index)
1833
+ }
1834
+ return index
1835
+ },
1836
+
1837
+ /**
1838
+ * #getter
1839
+ * every sequence in the alignment, keyed by row name, including rows a
1840
+ * collapsed clade hides. `rows` holds only the rows on screen; lookups
1841
+ * by row name use this
1065
1842
  */
1066
1843
  get rowMap() {
1067
- return new Map(this.rows)
1844
+ const MSA = this.MSA
1845
+ return new Map(
1846
+ MSA?.getNames()
1847
+ .map(name => [name, MSA.getRow(name)] as const)
1848
+ .filter(([, seq]) => !!seq),
1849
+ )
1068
1850
  },
1069
1851
  /**
1070
1852
  * #getter
@@ -1201,10 +1983,11 @@ function stateModelFactory() {
1201
1983
  get hierarchy(): HierarchyNode<NodeWithIdsAndLength> {
1202
1984
  const r = this.root
1203
1985
  clusterLayout(r, this.totalHeight, self.treeWidth)
1204
- r.data.length = 0
1205
- const max = maxLength(r)
1986
+ const max = this.rootToTipLength
1206
1987
  const k = max ? self.treeWidth / max : 0
1207
- setBrLength(r, 0, k)
1988
+ // the displayed root starts at x=0, so subtract its length here; `root`
1989
+ // returns the cached parse, which must not be mutated
1990
+ setBrLength(r, -Math.max(r.data.length || 0, 0), k)
1208
1991
  // for each collapsed clade, record the pixel x-position of its farthest
1209
1992
  // tip so the renderer can draw a triangle spanning the branch-length
1210
1993
  // extent of the hidden subtree
@@ -1233,10 +2016,31 @@ function stateModelFactory() {
1233
2016
 
1234
2017
  /**
1235
2018
  * #getter
1236
- * max branch length across the tree, used to scale phylogram x-positions
2019
+ * branch-length extent of the displayed tree, root to farthest tip, in
2020
+ * the tree's own units
2021
+ */
2022
+ get rootToTipLength() {
2023
+ const r = this.root
2024
+ return maxLength(r) - Math.max(r.data.length || 0, 0)
2025
+ },
2026
+
2027
+ /**
2028
+ * #getter
2029
+ * x-position of the farthest tip in a phylogram, px: treeWidth, or 0 for
2030
+ * a tree with no branch lengths (drawn as a cladogram)
1237
2031
  */
1238
2032
  get maxBranchLength() {
1239
- return findMaxBranchLen(this.hierarchy)
2033
+ return this.rootToTipLength ? self.treeWidth : 0
2034
+ },
2035
+
2036
+ /**
2037
+ * #getter
2038
+ * pixels per unit of branch length in the phylogram layout, 0 in
2039
+ * cladogram mode. The tree's scale bar uses it.
2040
+ */
2041
+ get pxPerBranchLength() {
2042
+ const max = maxLength(this.root)
2043
+ return this.showBranchLenEffective && max ? self.treeWidth / max : 0
1240
2044
  },
1241
2045
 
1242
2046
  /**
@@ -1251,7 +2055,7 @@ function stateModelFactory() {
1251
2055
  * #getter
1252
2056
  */
1253
2057
  get allBranchesLength0() {
1254
- return links(this.hierarchy).every(s => !s.source.data.length)
2058
+ return this.rootToTipLength === 0
1255
2059
  },
1256
2060
 
1257
2061
  /**
@@ -1275,170 +2079,899 @@ function stateModelFactory() {
1275
2079
  /**
1276
2080
  * #getter
1277
2081
  */
1278
- get dataInitialized() {
1279
- // truthiness, not `!== ''`: these are types.maybe, and DataModel's
1280
- // postProcessSnapshot drops a document over 50kb, so a restored session
1281
- // that inlined a large alignment comes back `undefined` here -- which
1282
- // `!== ''` reads as initialized and renders an empty view instead of
1283
- // the import form
1284
- return !!(self.data.msa || self.data.tree) && !self.error
2082
+ get showMsaLetters() {
2083
+ return (
2084
+ self.drawMsaLetters &&
2085
+ self.rowHeight >= minLetterRowHeight &&
2086
+ self.colWidth >= minLetterColWidth &&
2087
+ self.colWidth > self.rowHeight / 2
2088
+ )
1285
2089
  },
1286
2090
  /**
1287
2091
  * #getter
1288
2092
  */
1289
- get blocksX() {
1290
- return calculateBlocks({
1291
- viewportSize: self.msaAreaWidth,
1292
- viewportPos: -self.scrollX,
1293
- blockSize: self.blockSize,
1294
- mapSize: self.totalWidth,
1295
- })
2093
+ get showTreeText() {
2094
+ return self.drawLabels && self.rowHeight >= minLetterRowHeight
1296
2095
  },
2096
+ }))
2097
+ .views(self => ({
1297
2098
  /**
1298
2099
  * #getter
1299
2100
  */
1300
- get blocksY() {
1301
- return calculateBlocks({
1302
- viewportSize: this.visibleMsaHeight,
1303
- viewportPos: -self.scrollY,
1304
- blockSize: self.blockSize,
1305
- mapSize: self.totalHeight,
1306
- })
2101
+ get labelWidthMap() {
2102
+ const { showTreeText, leaves } = self
2103
+ // gated on the renderer's label condition, so hidden labels reserve no
2104
+ // gutter. Measured once at a reference size and scaled by
2105
+ // labelWidthScale: re-measuring per vertical-zoom frame cost ~200ms on a
2106
+ // 50k-leaf tree
2107
+ return showTreeText
2108
+ ? new Map(
2109
+ leaves.map(node => {
2110
+ const { name } = node.data
2111
+ // `||`, matching renderTreeLabels: an empty genome falls back
2112
+ // to the row name
2113
+ const displayName = self.rowDataOf(name)?.genome || name
2114
+ return [
2115
+ name,
2116
+ measureTextCanvas(displayName, labelReferenceFontSize),
2117
+ ] as const
2118
+ }),
2119
+ )
2120
+ : new Map<string, number>()
1307
2121
  },
2122
+
1308
2123
  /**
1309
2124
  * #getter
1310
- * height of the alignment viewport, px. The same subtraction as
1311
- * msaAreaHeight, which is defined later in the views chain
2125
+ * factor turning a labelWidthMap entry into its width at the current
2126
+ * font size
1312
2127
  */
1313
- get visibleMsaHeight() {
1314
- return (
1315
- self.height -
1316
- self.headerHeight -
1317
- (self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0)
1318
- )
2128
+ get labelWidthScale() {
2129
+ return self.fontSize / labelReferenceFontSize
1319
2130
  },
1320
- }))
1321
- .views(self => ({
2131
+
2132
+ get labelsWidth() {
2133
+ // a loop, not Math.max(...): spreading passes one argument per leaf,
2134
+ // and the ~125k argument limit throws RangeError on the 230k-tip COVID
2135
+ // tree
2136
+ let max = 0
2137
+ for (const width of this.labelWidthMap.values()) {
2138
+ if (width > max) {
2139
+ max = width
2140
+ }
2141
+ }
2142
+ return max * this.labelWidthScale
2143
+ },
2144
+
1322
2145
  /**
1323
2146
  * #getter
1324
2147
  */
1325
- get blocks2d() {
1326
- return self.blocksY.flatMap(by =>
1327
- self.blocksX.map(bx => [bx, by] as const),
1328
- )
2148
+ get secondaryStructureConsensus() {
2149
+ return self.MSA?.secondaryStructureConsensus
1329
2150
  },
1330
2151
 
1331
2152
  /**
1332
2153
  * #getter
1333
2154
  */
1334
- get isLoading() {
1335
- return self.loadingMSA || self.loadingTree
2155
+ get seqConsensus() {
2156
+ return self.MSA?.seqConsensus
1336
2157
  },
2158
+
1337
2159
  /**
1338
2160
  * #getter
2161
+ * the base pairs of the consensus secondary structure, as arcs, in
2162
+ * visible column space (hidden columns are removed before parsing)
1339
2163
  */
1340
- get maxScrollX() {
1341
- return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
2164
+ get secondaryStructureArcs(): Arc[] | undefined {
2165
+ const { blanks, hideGapsEffective } = self
2166
+ const ss = this.secondaryStructureConsensus
2167
+ if (!ss) {
2168
+ return undefined
2169
+ }
2170
+ return parseWuss(hideGapsEffective ? skipBlanks(blanks, ss) : ss).map(
2171
+ ({ start, end, pseudoknot }) => ({
2172
+ start,
2173
+ end,
2174
+ color: pseudoknot ? PSEUDOKNOT_ARC : HELIX_ARC,
2175
+ }),
2176
+ )
1342
2177
  },
2178
+
1343
2179
  /**
1344
2180
  * #getter
1345
- * most-negative allowed scrollY, keeping the last row in view rather than
1346
- * letting the whole alignment scroll off the top.
1347
2181
  */
1348
- get maxScrollY() {
1349
- return Math.min(-self.totalHeight + self.visibleMsaHeight, 0)
2182
+ get adapterTrackModels(): BasicTrack[] {
2183
+ const { MSA, hideGapsEffective, blanks } = self
2184
+ const tracks = (MSA?.tracks ?? []).filter(t => !!t.data)
2185
+ if (tracks.length === 0) {
2186
+ // return before reading rowHeight, so zooming does not rebuild the
2187
+ // list and redraw every track canvas
2188
+ return []
2189
+ }
2190
+ const { rowHeight } = self
2191
+ return tracks.map(t => ({
2192
+ model: {
2193
+ ...t,
2194
+ kind: 'text' as const,
2195
+ data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
2196
+ height: rowHeight,
2197
+ },
2198
+ ReactComponent: TrackBlocks,
2199
+ }))
1350
2200
  },
2201
+
1351
2202
  /**
1352
2203
  * #getter
2204
+ * a data track's values or string, projected from its row's residues
2205
+ * onto alignment columns when it names a row
1353
2206
  */
1354
- get showMsaLetters() {
2207
+ get columnTrackContent() {
2208
+ const { MSA, blanks, hideGapsEffective } = self
2209
+ const width = MSA?.getWidth() ?? 0
2210
+ const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
2211
+ if (!track.row) {
2212
+ return items
2213
+ }
2214
+ const out = Array.from({ length: width }, () => fill)
2215
+ const index = self.seqPosIndex(track.row)
2216
+ items.forEach((item, seqPos) => {
2217
+ const col = index?.[seqPos]
2218
+ if (col !== undefined) {
2219
+ out[col] = item
2220
+ }
2221
+ })
2222
+ return out
2223
+ }
2224
+ const skip = <T>(items: T[]) =>
2225
+ hideGapsEffective ? dropBlanks(blanks, items) : items
2226
+ // an arc endpoint maps row residue -> column -> visible column.
2227
+ // visibleColsBefore, not globalColToVisibleCol, so an endpoint in a
2228
+ // hidden column moves to the neighboring visible one and the arc stays
2229
+ const resolve = (track: ColumnTrackSpec, pos: number) => {
2230
+ const col = track.row
2231
+ ? self.seqPosIndex(track.row)?.[pos - 1]
2232
+ : pos - 1
2233
+ if (col === undefined || col < 0 || col >= width) {
2234
+ return undefined
2235
+ }
2236
+ return hideGapsEffective ? visibleColsBefore(blanks, col) : col
2237
+ }
2238
+ return new Map<
2239
+ string,
2240
+ { values?: number[]; data?: string; arcs?: Arc[] }
2241
+ >(
2242
+ self.columnTracks.map(track => {
2243
+ if (track.kind === 'arc') {
2244
+ const arcs = (track.arcs ?? [])
2245
+ .map(arc => {
2246
+ const start = resolve(track, Math.min(arc.start, arc.end))
2247
+ const end = resolve(track, Math.max(arc.start, arc.end))
2248
+ return start !== undefined && end !== undefined && start < end
2249
+ ? { start, end, color: arc.color }
2250
+ : undefined
2251
+ })
2252
+ .filter(notEmpty)
2253
+ return [track.id, { arcs }] as const
2254
+ }
2255
+ if (track.kind === 'bar') {
2256
+ const max = track.max ?? 1
2257
+ const values = skip(project(track, track.values ?? [], 0)).map(
2258
+ v => Math.min(1, Math.max(0, v / max)),
2259
+ )
2260
+ return [track.id, { values }] as const
2261
+ }
2262
+ const data = skip(project(track, (track.data ?? '').split(''), ' '))
2263
+ return [track.id, { data: data.join('') }] as const
2264
+ }),
2265
+ )
2266
+ },
2267
+ /**
2268
+ * #method
2269
+ * the height a track draws at: what the user dragged its divider to,
2270
+ * then the height its snapshot asked for, then its kind's default. Only
2271
+ * a text track falls through to rowHeight, and `??` short-circuits
2272
+ * before reading it, so vertical zoom does not rebuild the other tracks
2273
+ */
2274
+ trackHeight(kind: TrackKind, heightKey = kind as string, given?: number) {
1355
2275
  return (
1356
- self.drawMsaLetters &&
1357
- self.rowHeight >= minLetterRowHeight &&
1358
- self.colWidth >= minLetterColWidth &&
1359
- self.colWidth > self.rowHeight / 2
2276
+ self.trackHeights.get(heightKey) ??
2277
+ given ??
2278
+ defaultTrackHeights[kind] ??
2279
+ self.rowHeight
1360
2280
  )
1361
2281
  },
1362
2282
  /**
1363
2283
  * #getter
1364
2284
  */
1365
- get showTreeText() {
1366
- return self.drawLabels && self.rowHeight >= minLetterRowHeight
2285
+ get columnTrackModels(): BasicTrack[] {
2286
+ return self.columnTracks.map(track => {
2287
+ const heightKey = resizableKinds.has(track.kind)
2288
+ ? ownHeightKey(track.id)
2289
+ : undefined
2290
+ return {
2291
+ model: {
2292
+ id: track.id,
2293
+ name: track.name,
2294
+ kind: track.kind,
2295
+ heightKey,
2296
+ height: this.trackHeight(track.kind, heightKey, track.height),
2297
+ barColor: track.color,
2298
+ arcColor: track.color,
2299
+ customColorScheme: track.colors,
2300
+ data: this.columnTrackContent.get(track.id)?.data,
2301
+ arcs: this.columnTrackContent.get(track.id)?.arcs,
2302
+ },
2303
+ ReactComponent: TrackBlocks,
2304
+ }
2305
+ })
1367
2306
  },
1368
- }))
1369
- .actions(self => ({
1370
2307
  /**
1371
- * #action
1372
- */
1373
- setDrawMsaLetters(arg: boolean) {
1374
- self.drawMsaLetters = arg
2308
+ * #getter
2309
+ * the consensus secondary structure as a track, when there is one. A
2310
+ * separate getter keeps the object stable across zoom, so its canvas
2311
+ * does not redraw
2312
+ */
2313
+ get basePairTrackModels(): BasicTrack[] {
2314
+ const arcs = this.secondaryStructureArcs
2315
+ return arcs?.length
2316
+ ? [
2317
+ {
2318
+ model: {
2319
+ id: 'base-pairs',
2320
+ name: 'Base pairs',
2321
+ kind: 'arc' as const,
2322
+ heightKey: 'arc',
2323
+ height: this.trackHeight('arc'),
2324
+ arcs,
2325
+ },
2326
+ ReactComponent: TrackBlocks,
2327
+ },
2328
+ ]
2329
+ : []
1375
2330
  },
1376
2331
 
1377
2332
  /**
1378
- * #action
2333
+ * #getter
2334
+ * the tracks computed from the alignment; they depend on their heights
2335
+ * and the alphabet, not on zoom
1379
2336
  */
1380
- setScrollZoom(arg: boolean) {
1381
- self.scrollZoom = arg
2337
+ get computedTrackModels(): BasicTrack[] {
2338
+ return [
2339
+ {
2340
+ id: 'conservation',
2341
+ name: 'Conservation',
2342
+ kind: 'bar' as const,
2343
+ barColor: 'gray',
2344
+ },
2345
+ ...(self.sequenceType === 'amino'
2346
+ ? [
2347
+ {
2348
+ id: 'property-conservation',
2349
+ name: 'Property conservation',
2350
+ kind: 'bar' as const,
2351
+ barColor: '#6a51a3',
2352
+ },
2353
+ ]
2354
+ : []),
2355
+ {
2356
+ id: 'sequence-logo',
2357
+ name: 'Sequence logo',
2358
+ kind: 'logo' as const,
2359
+ },
2360
+ // last, so it sits against the alignment it numbers
2361
+ {
2362
+ id: 'position-ruler',
2363
+ name: 'Position',
2364
+ kind: 'ruler' as const,
2365
+ },
2366
+ ].map(model => ({
2367
+ // every computed track of a kind shares that kind's height, so the
2368
+ // kind is its key
2369
+ model: {
2370
+ ...model,
2371
+ heightKey: resizableKinds.has(model.kind) ? model.kind : undefined,
2372
+ height: this.trackHeight(model.kind),
2373
+ },
2374
+ ReactComponent: TrackBlocks,
2375
+ }))
1382
2376
  },
1383
2377
 
1384
- /**
1385
- * #action
1386
- * set hovered tree node and its descendants
1387
- */
1388
- setHoveredTreeNode(nodeId?: string) {
1389
- // the tree's mousemove handler calls this on every event, and both the
1390
- // lookup and the write are expensive: `find` walks the whole hierarchy,
1391
- // and a fresh object here invalidates hoveredRowIndices and redraws the
1392
- // tree and MSA overlays. Re-hovering the same node is the common case
1393
- if (nodeId === self.hoveredTreeNode?.nodeId) {
1394
- return
1395
- }
1396
- if (!nodeId) {
1397
- self.hoveredTreeNode = undefined
1398
- return
1399
- }
1400
- const node = find(self.hierarchy, n => n.data.id === nodeId)
1401
- self.hoveredTreeNode = node
1402
- ? {
1403
- nodeId,
1404
- descendantNames: leaves(node).map(leaf => leaf.data.name),
1405
- }
1406
- : undefined
2378
+ get tracks(): BasicTrack[] {
2379
+ return [
2380
+ ...this.adapterTrackModels,
2381
+ ...this.basePairTrackModels,
2382
+ ...this.columnTrackModels,
2383
+ // every computed track reads the alignment's columns, and a tree, a
2384
+ // GFF and a features panel draw a figure with none
2385
+ ...(self.numColumns > 0 ? this.computedTrackModels : []),
2386
+ ]
1407
2387
  },
1408
2388
 
1409
2389
  /**
1410
- * #action
1411
- * Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
2390
+ * #getter
1412
2391
  */
1413
- calculateNeighborJoiningTreeFromMSA() {
1414
- if (self.rows.length < 2) {
1415
- throw new Error('Need at least 2 sequences to build a tree')
1416
- }
1417
- const newickTree = calculateNeighborJoiningTree(self.rows)
1418
- self.setTree(newickTree)
2392
+ get turnedOnTracks() {
2393
+ return this.tracks.filter(
2394
+ f =>
2395
+ !trackIsOff(self.turnedOffTracks, f.model.id, f.model.defaultOff),
2396
+ )
1419
2397
  },
1420
2398
 
1421
2399
  /**
1422
- * #action
1423
- * restore the default column width and row height
2400
+ * #getter
1424
2401
  */
1425
- resetZoom() {
1426
- self.setColWidth(defaultColWidth)
1427
- self.setRowHeight(defaultRowHeight)
2402
+ get showHorizontalScrollbar() {
2403
+ return overflows(self.totalWidth, self.msaAreaWidth)
1428
2404
  },
2405
+
1429
2406
  /**
1430
- * #action
2407
+ * #method
2408
+ * Return a row-specific letter at a visible column, or undefined if gap.
2409
+ *
2410
+ * @param rowName - The name of the row
2411
+ * @param visibleCol - The visible column index (what the user sees on screen)
2412
+ * @returns The letter at that position, or undefined if it's a gap
1431
2413
  */
1432
- zoomOutHorizontal() {
1433
- self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
1434
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
2414
+ visibleColToRowLetter(rowName: string, visibleCol: number) {
2415
+ return self.rowMap.get(rowName)?.[
2416
+ this.visibleColToGlobalCol(visibleCol)
2417
+ ]
1435
2418
  },
2419
+
1436
2420
  /**
1437
- * #action
2421
+ * #method
2422
+ * Convert a visible column to a row-specific sequence position (0-based).
2423
+ * Returns undefined if the position is a gap in the sequence.
2424
+ *
2425
+ * Public API, like the sibling converters (visibleColToGlobalCol,
2426
+ * seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) hosts
2427
+ * use to translate between columns and residue positions. Keep them
2428
+ * stable.
2429
+ *
2430
+ * @param rowName - The name of the row
2431
+ * @param visibleCol - The visible column index
2432
+ * @returns The sequence position (0-based), or undefined if it's a gap
1438
2433
  */
1439
- zoomInHorizontal() {
1440
- self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
1441
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
2434
+ visibleColToSeqPos(rowName: string, visibleCol: number) {
2435
+ // binary search: this runs on every mouse move, and scanning a
2436
+ // 30k-column row per event takes the whole frame
2437
+ return seqPosOfGlobalCol(
2438
+ self.seqPosIndex(rowName),
2439
+ this.visibleColToGlobalCol(visibleCol),
2440
+ )
2441
+ },
2442
+
2443
+ /**
2444
+ * #method
2445
+ * Convert a visible column to a row-specific sequence position (1-based).
2446
+ * Returns undefined if the position is a gap in the sequence.
2447
+ *
2448
+ * @param rowName - The name of the row
2449
+ * @param visibleCol - The visible column index
2450
+ * @returns The sequence position (1-based), or undefined if it's a gap
2451
+ */
2452
+ visibleColToSeqPosOneBased(rowName: string, visibleCol: number) {
2453
+ const val = this.visibleColToSeqPos(rowName, visibleCol)
2454
+ return val !== undefined ? val + 1 : undefined
2455
+ },
2456
+
2457
+ /**
2458
+ * #method
2459
+ * Convert a global column index to a visible column index.
2460
+ * Returns undefined if the column is hidden (in blanks).
2461
+ * This is the inverse of visibleColToGlobalCol.
2462
+ *
2463
+ * @param globalCol - The global column index in the full MSA
2464
+ * @returns The visible column index, or undefined if the column is hidden
2465
+ */
2466
+ globalColToVisibleCol(globalCol: number) {
2467
+ const { blanks, hideGapsEffective } = self
2468
+ if (!hideGapsEffective) {
2469
+ return globalCol
2470
+ }
2471
+ return globalColToVisibleCol(blanks, globalCol)
2472
+ },
2473
+
2474
+ /**
2475
+ * #method
2476
+ * Convert a visible column index (what a mouse handler reports) to a
2477
+ * column of the full alignment. A host indexing its own per-column data
2478
+ * needs this when columns are hidden.
2479
+ *
2480
+ * @param visibleCol - The visible column index
2481
+ * @returns The global column index in the full MSA
2482
+ */
2483
+ visibleColToGlobalCol(visibleCol: number) {
2484
+ const { blanks, hideGapsEffective } = self
2485
+ return hideGapsEffective
2486
+ ? visibleColToGlobalCol(blanks, visibleCol)
2487
+ : visibleCol
2488
+ },
2489
+
2490
+ /**
2491
+ * #method
2492
+ * Convert a sequence position (ungapped) to a global column index.
2493
+ * Returns undefined for a row name the alignment does not have.
2494
+ *
2495
+ * @param rowName - The name of the row
2496
+ * @param seqPos - The sequence position (0-based, ungapped)
2497
+ * @returns The global column index in the full MSA, or undefined
2498
+ */
2499
+ seqPosToGlobalCol(rowName: string, seqPos: number) {
2500
+ const seq = self.rowMap.get(rowName)
2501
+ if (seq === undefined) {
2502
+ return undefined
2503
+ }
2504
+ const col = self.seqPosIndex(rowName)?.[seqPos]
2505
+ // past the end of the ungapped sequence: 0 for the degenerate all-gap
2506
+ // row, otherwise one past the last column
2507
+ return col ?? (seqPos === 0 ? 0 : seq.length)
2508
+ },
2509
+
2510
+ /**
2511
+ * #method
2512
+ * Convert a sequence position (ungapped) directly to a visible column index.
2513
+ * This combines seqPosToGlobalCol and globalColToVisibleCol.
2514
+ *
2515
+ * @param rowName - The name of the row
2516
+ * @param seqPos - The sequence position (0-based, ungapped)
2517
+ * @returns The visible column index, or undefined if the column is hidden
2518
+ */
2519
+ seqPosToVisibleCol(rowName: string, seqPos: number) {
2520
+ const globalCol = this.seqPosToGlobalCol(rowName, seqPos)
2521
+ return globalCol === undefined
2522
+ ? undefined
2523
+ : this.globalColToVisibleCol(globalCol)
2524
+ },
2525
+
2526
+ /**
2527
+ * #method
2528
+ * the visible columns a span covers, in highlight coordinates: `start`
2529
+ * and `end` are 1-based residues of `row`, or columns of the file
2530
+ * without it. A fractional position, as a zoom gesture reports one,
2531
+ * widens to the whole residue or column it falls in. A span entirely on
2532
+ * hidden columns, or naming a row the alignment lacks, gives undefined.
2533
+ */
2534
+ visibleSpan({ row, start: rawStart, end: rawEnd }: Region) {
2535
+ const { blanks } = self
2536
+ const start = Math.max(1, Math.floor(rawStart))
2537
+ const end = Math.ceil(rawEnd)
2538
+ let startGlobal = start - 1
2539
+ let endGlobal = end - 1
2540
+ if (row !== undefined) {
2541
+ const rowStart = this.seqPosToGlobalCol(row, start - 1)
2542
+ const rowEnd = this.seqPosToGlobalCol(row, end - 1)
2543
+ if (rowStart === undefined || rowEnd === undefined) {
2544
+ return undefined
2545
+ }
2546
+ startGlobal = rowStart
2547
+ endGlobal = rowEnd
2548
+ }
2549
+ const startCol =
2550
+ this.globalColToVisibleCol(startGlobal) ??
2551
+ visibleColsBefore(blanks, startGlobal)
2552
+ const endCol =
2553
+ this.globalColToVisibleCol(endGlobal) ??
2554
+ visibleColsBefore(blanks, endGlobal) - 1
2555
+ return startCol <= endCol ? { startCol, endCol } : undefined
2556
+ },
2557
+
2558
+ /**
2559
+ * #getter
2560
+ * why each ignored residue mapping is ignored, so a host can tell a
2561
+ * missing structure from a mapping made against a different alignment.
2562
+ */
2563
+ get residueMappingProblems(): ResidueMappingProblem[] {
2564
+ const problems: ResidueMappingProblem[] = []
2565
+ for (const mapping of self.residueMappings) {
2566
+ const where = { row: mapping.row, structureId: mapping.structure.id }
2567
+ const residues = self.seqPosIndex(mapping.row)?.length
2568
+ if (residues === undefined) {
2569
+ problems.push({
2570
+ ...where,
2571
+ scope: 'mapping',
2572
+ reason: 'no such row in the alignment',
2573
+ })
2574
+ continue
2575
+ }
2576
+ if (
2577
+ mapping.rowLength !== undefined &&
2578
+ mapping.rowLength !== residues
2579
+ ) {
2580
+ problems.push({
2581
+ ...where,
2582
+ scope: 'mapping',
2583
+ reason: `computed against a ${mapping.rowLength}-residue row; this one has ${residues}`,
2584
+ })
2585
+ continue
2586
+ }
2587
+ const overrun = mapping.segments.find(
2588
+ segment => segment.rowEnd > residues || segment.rowStart < 1,
2589
+ )
2590
+ if (overrun) {
2591
+ problems.push({
2592
+ ...where,
2593
+ scope: 'mapping',
2594
+ reason: `segment ${overrun.rowStart}-${overrun.rowEnd} does not fit a ${residues}-residue row`,
2595
+ })
2596
+ continue
2597
+ }
2598
+ for (const segment of mapping.segments) {
2599
+ if (!sameLength(segment)) {
2600
+ problems.push({
2601
+ ...where,
2602
+ scope: 'segment',
2603
+ reason: `segment ${segment.rowStart}-${segment.rowEnd} maps to ${segment.structStart}-${segment.structEnd}, which is a different length`,
2604
+ })
2605
+ }
2606
+ }
2607
+ }
2608
+ return problems
2609
+ },
2610
+
2611
+ /**
2612
+ * #getter
2613
+ * the mappings that fit the loaded alignment. A row-level problem drops
2614
+ * the whole mapping; a malformed segment drops only that segment.
2615
+ */
2616
+ get usableResidueMappings(): ResidueMapping[] {
2617
+ const unusable = new Set(
2618
+ this.residueMappingProblems
2619
+ .filter(problem => problem.scope === 'mapping')
2620
+ .map(problem => `${problem.row}\u0000${problem.structureId}`),
2621
+ )
2622
+ return self.residueMappings
2623
+ .filter(m => !unusable.has(`${m.row}\u0000${m.structure.id}`))
2624
+ .map(m => ({
2625
+ ...m,
2626
+ segments: m.segments.filter(sameLength),
2627
+ }))
2628
+ },
2629
+
2630
+ /**
2631
+ * #getter
2632
+ * the structures with usable mappings. A row can map onto several, such
2633
+ * as an experimental entry and a predicted model.
2634
+ */
2635
+ get mappedStructures() {
2636
+ return this.usableResidueMappings.map(m => ({
2637
+ row: m.row,
2638
+ structure: m.structure,
2639
+ }))
2640
+ },
2641
+
2642
+ /**
2643
+ * #method
2644
+ * The structure residue for a row residue. Returns undefined when no
2645
+ * segment covers `seqPos`, or when the row maps onto several structures
2646
+ * and `structureId` does not pick one (see `mappedStructures`).
2647
+ *
2648
+ * Positions are 1-based, like `residueMappings` and `highlights`; the
2649
+ * column helpers above are 0-based.
2650
+ *
2651
+ * @param rowName - The alignment row
2652
+ * @param seqPos - Residue of that row, 1-based
2653
+ * @param structureId - Which structure, when the row maps onto several
2654
+ */
2655
+ structureResidue(
2656
+ rowName: string,
2657
+ seqPos: number,
2658
+ structureId?: string,
2659
+ ): StructureResidue | undefined {
2660
+ const hits: StructureResidue[] = []
2661
+ for (const mapping of this.usableResidueMappings) {
2662
+ if (mapping.row !== rowName) {
2663
+ continue
2664
+ }
2665
+ if (
2666
+ structureId !== undefined &&
2667
+ mapping.structure.id !== structureId
2668
+ ) {
2669
+ continue
2670
+ }
2671
+ const segment = mapping.segments.find(
2672
+ seg => seqPos >= seg.rowStart && seqPos <= seg.rowEnd,
2673
+ )
2674
+ if (segment) {
2675
+ const position = segment.structStart + (seqPos - segment.rowStart)
2676
+ hits.push({
2677
+ structure: mapping.structure,
2678
+ position,
2679
+ observed: !inRanges(mapping.unobserved, position),
2680
+ })
2681
+ }
2682
+ }
2683
+ return hits.length === 1 ? hits[0] : undefined
2684
+ },
2685
+
2686
+ /**
2687
+ * #method
2688
+ * The row residue for a structure residue; the inverse of
2689
+ * `structureResidue`, returning undefined in the same cases. `asymId`
2690
+ * picks a chain when several mappings share an entry id, as in a
2691
+ * homodimer.
2692
+ *
2693
+ * @param structureId - The structure's id, as the mapping names it
2694
+ * @param position - Residue of that structure, 1-based label_seq_id
2695
+ * @param asymId - Which chain, when the id alone is ambiguous
2696
+ */
2697
+ rowResidue(
2698
+ structureId: string,
2699
+ position: number,
2700
+ asymId?: string,
2701
+ ): RowResidue | undefined {
2702
+ const hits: RowResidue[] = []
2703
+ for (const mapping of this.usableResidueMappings) {
2704
+ if (mapping.structure.id !== structureId) {
2705
+ continue
2706
+ }
2707
+ if (asymId !== undefined && mapping.structure.asymId !== asymId) {
2708
+ continue
2709
+ }
2710
+ const segment = mapping.segments.find(
2711
+ seg => position >= seg.structStart && position <= seg.structEnd,
2712
+ )
2713
+ if (segment) {
2714
+ hits.push({
2715
+ rowName: mapping.row,
2716
+ seqPos: segment.rowStart + (position - segment.structStart),
2717
+ })
2718
+ }
2719
+ }
2720
+ return hits.length === 1 ? hits[0] : undefined
2721
+ },
2722
+ }))
2723
+
2724
+ .views(self => ({
2725
+ /**
2726
+ * #getter
2727
+ * the branch-length scale bar over the tree, undefined in cladogram mode
2728
+ * or when the tree area is too narrow for one
2729
+ */
2730
+ get treeScaleBar() {
2731
+ return scaleBarLength(
2732
+ self.pxPerBranchLength,
2733
+ self.treeAreaWidth - self.marginLeft * 2,
2734
+ )
2735
+ },
2736
+ /**
2737
+ * #getter
2738
+ * the band across the top: the minimap over the alignment, the row panel
2739
+ * headers, and the tree overview stacked on the scale bar, as tall as the
2740
+ * tallest of the three
2741
+ */
2742
+ get topBandHeight() {
2743
+ return Math.max(
2744
+ self.showHorizontalScrollbar ? self.minimapHeight : 0,
2745
+ self.rowPanelsHeaderHeight,
2746
+ self.treeOverviewHeight +
2747
+ (this.treeScaleBar ? treeScaleBarHeight : 0),
2748
+ )
2749
+ },
2750
+ /**
2751
+ * #getter
2752
+ * the vertical space for alignment rows: the widget height less the
2753
+ * header, the top band and the tracks. Shared by blocksY, maxScrollY, the
2754
+ * vertical scrollbar and fitVertically.
2755
+ */
2756
+ get msaAreaHeight() {
2757
+ return (
2758
+ self.height -
2759
+ this.topBandHeight -
2760
+ self.headerHeight -
2761
+ this.totalTrackAreaHeight
2762
+ )
2763
+ },
2764
+ /**
2765
+ * #getter
2766
+ * total height of track area (px)
2767
+ */
2768
+ get totalTrackAreaHeight() {
2769
+ return sum(self.turnedOnTracks.map(r => r.model.height))
2770
+ },
2771
+ /**
2772
+ * one representative annotation per accession, for the legend, the
2773
+ * filter dialog and the palettes
2774
+ */
2775
+ get annotationTypes() {
2776
+ // first occurrence wins; only name, description and segment start are
2777
+ // read from it
2778
+ const types = new Map<string, Annotation>()
2779
+ for (const annot of self.annotations) {
2780
+ if (!types.has(annot.accession)) {
2781
+ types.set(annot.accession, annot)
2782
+ }
2783
+ }
2784
+ return types
2785
+ },
2786
+ get filteredAnnotations() {
2787
+ return self.annotations.filter(
2788
+ r => !self.turnedOffFeatures.get(r.accession),
2789
+ )
2790
+ },
2791
+ get annotationsByRow() {
2792
+ return groupBy(this.filteredAnnotations, r => r.id)
2793
+ },
2794
+ }))
2795
+ .views(self => ({
2796
+ /**
2797
+ * #getter
2798
+ */
2799
+ get showVerticalScrollbar() {
2800
+ return overflows(self.totalHeight, self.msaAreaHeight)
2801
+ },
2802
+ }))
2803
+ .views(self => ({
2804
+ /**
2805
+ * #getter
2806
+ */
2807
+ get dataInitialized() {
2808
+ // truthiness, not `!== ''`: DataModel drops an inline document over
2809
+ // 50kb from the snapshot, so a restored session can hold `undefined`
2810
+ return !!(self.data.msa || self.data.tree) && !self.error
2811
+ },
2812
+ /**
2813
+ * #getter
2814
+ */
2815
+ get blocksX() {
2816
+ return calculateBlocks({
2817
+ viewportSize: self.msaAreaWidth,
2818
+ viewportPos: -self.scrollX,
2819
+ blockSize: self.blockSize,
2820
+ mapSize: self.totalWidth,
2821
+ })
2822
+ },
2823
+ /**
2824
+ * #getter
2825
+ */
2826
+ get blocksY() {
2827
+ return calculateBlocks({
2828
+ viewportSize: self.msaAreaHeight,
2829
+ viewportPos: -self.scrollY,
2830
+ blockSize: self.blockSize,
2831
+ mapSize: self.totalHeight,
2832
+ })
2833
+ },
2834
+ }))
2835
+ .views(self => ({
2836
+ /**
2837
+ * #getter
2838
+ */
2839
+ get blocks2d() {
2840
+ return self.blocksY.flatMap(by =>
2841
+ self.blocksX.map(bx => [bx, by] as const),
2842
+ )
2843
+ },
2844
+
2845
+ /**
2846
+ * #getter
2847
+ */
2848
+ get isLoading() {
2849
+ return self.loadingMSA || self.loadingTree
2850
+ },
2851
+ /**
2852
+ * #getter
2853
+ */
2854
+ get maxScrollX() {
2855
+ return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
2856
+ },
2857
+ /**
2858
+ * #getter
2859
+ * most-negative allowed scrollY, which keeps the last row in view
2860
+ */
2861
+ get maxScrollY() {
2862
+ return Math.min(-self.totalHeight + self.msaAreaHeight, 0)
2863
+ },
2864
+ /**
2865
+ * #getter
2866
+ * axis a wheel zoom scales, for ctrl+wheel as much as for scroll-zoom.
2867
+ * With scroll-zoom off the toolbar shows no axis, so ctrl+wheel takes
2868
+ * both.
2869
+ */
2870
+ get wheelZoomAxis(): ScrollZoomAxis {
2871
+ return self.scrollZoom ? self.scrollZoomAxis : 'both'
2872
+ },
2873
+ }))
2874
+ .actions(self => ({
2875
+ /**
2876
+ * #action
2877
+ */
2878
+ setDrawMsaLetters(arg: boolean) {
2879
+ self.drawMsaLetters = arg
2880
+ },
2881
+
2882
+ /**
2883
+ * #action
2884
+ */
2885
+ setScrollZoom(arg: boolean) {
2886
+ self.scrollZoom = arg
2887
+ },
2888
+
2889
+ /**
2890
+ * #action
2891
+ */
2892
+ setScrollZoomAxis(arg: ScrollZoomAxis) {
2893
+ self.scrollZoomAxis = arg
2894
+ },
2895
+
2896
+ /**
2897
+ * #action
2898
+ * set hovered tree node and its descendants
2899
+ */
2900
+ setHoveredTreeNode(nodeId?: string) {
2901
+ // called on every tree mousemove; `find` walks the whole hierarchy and a
2902
+ // new object redraws the tree and MSA overlays
2903
+ if (nodeId === self.hoveredTreeNode?.nodeId) {
2904
+ return
2905
+ }
2906
+ if (!nodeId) {
2907
+ self.hoveredTreeNode = undefined
2908
+ return
2909
+ }
2910
+ const node = find(self.hierarchy, n => n.data.id === nodeId)
2911
+ self.hoveredTreeNode = node
2912
+ ? {
2913
+ nodeId,
2914
+ descendantNames: leaves(node).map(leaf => leaf.data.name),
2915
+ }
2916
+ : undefined
2917
+ },
2918
+
2919
+ /**
2920
+ * #action
2921
+ * Calculate a neighbor joining tree from the current MSA using BLOSUM62
2922
+ * distances. Throws above `maxNeighborJoiningRows`: the join loop is
2923
+ * cubic and runs on the main thread, and 800 rows freeze the tab for ten
2924
+ * seconds with no cancel.
2925
+ */
2926
+ calculateNeighborJoiningTreeFromMSA() {
2927
+ // every sequence, including rows in collapsed clades
2928
+ const rows = [...self.rowMap]
2929
+ if (rows.length < 2) {
2930
+ throw new Error('Need at least 2 sequences to build a tree')
2931
+ }
2932
+ if (rows.length > maxNeighborJoiningRows) {
2933
+ throw new Error(
2934
+ `Neighbor joining here is capped at ${maxNeighborJoiningRows} sequences and this alignment has ${rows.length}. Build the tree with FastTree or IQ-TREE and open it alongside the alignment: https://gmod.org/JBrowseMSA/tutorials/protein_family`,
2935
+ )
2936
+ }
2937
+ this.replaceTree(calculateNeighborJoiningTree(rows))
2938
+ },
2939
+
2940
+ /**
2941
+ * #action
2942
+ * swap in a different tree over the same alignment. Clears `collapsed`
2943
+ * and `showOnly`, since path-derived node ids (node-0-0-1) from the old
2944
+ * tree would match unrelated nodes in the new one.
2945
+ */
2946
+ replaceTree(newick: string) {
2947
+ transaction(() => {
2948
+ self.collapsed.clear()
2949
+ self.setShowOnly(undefined)
2950
+ self.setTree(newick)
2951
+ })
2952
+ },
2953
+
2954
+ /**
2955
+ * #action
2956
+ * restore the default column width and row height
2957
+ */
2958
+ resetZoom() {
2959
+ self.setColWidth(defaultColWidth)
2960
+ self.setRowHeight(defaultRowHeight)
2961
+ },
2962
+ /**
2963
+ * #action
2964
+ */
2965
+ zoomOutHorizontal() {
2966
+ self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
2967
+ self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
2968
+ },
2969
+ /**
2970
+ * #action
2971
+ */
2972
+ zoomInHorizontal() {
2973
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
2974
+ self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1442
2975
  },
1443
2976
  /**
1444
2977
  * #action
@@ -1477,26 +3010,36 @@ function stateModelFactory() {
1477
3010
  * #action
1478
3011
  * Smoothly zoom by a continuous scaleFactor. The column under the cursor
1479
3012
  * (offsetX/offsetY, px relative to the MSA area) stays anchored
1480
- * horizontally. Vertically the anchor is biased toward the top: when the
1481
- * alignment nearly fits the viewport, snap to y=0 rather than pinning a
1482
- * random row under the cursor, with the bias fading out as the alignment
1483
- * grows taller than the viewport (where cursor-anchoring is useful).
1484
- * Drives wheel/trackpad-pinch zoom.
1485
- */
1486
- zoomToPos(scaleFactor: number, offsetX: number, offsetY: number) {
3013
+ * horizontally. Vertically the anchor is biased toward y=0 when the
3014
+ * alignment nearly fits the viewport, fading to cursor-anchoring as the
3015
+ * alignment grows taller than the viewport.
3016
+ * Drives wheel/trackpad-pinch zoom. `axis` holds one cell dimension
3017
+ * fixed; the held axis still re-anchors its scroll offset, since the
3018
+ * other one can change how much of the alignment fits.
3019
+ */
3020
+ zoomToPos(
3021
+ scaleFactor: number,
3022
+ offsetX: number,
3023
+ offsetY: number,
3024
+ axis: ScrollZoomAxis = 'both',
3025
+ ) {
1487
3026
  transaction(() => {
1488
3027
  const colInView = (-self.scrollX + offsetX) / self.colWidth
1489
3028
  const rowInView = (-self.scrollY + offsetY) / self.rowHeight
1490
- self.colWidth = clamp(
1491
- self.colWidth * scaleFactor,
1492
- minColWidth,
1493
- maxCellSize,
1494
- )
1495
- self.rowHeight = clamp(
1496
- self.rowHeight * scaleFactor,
1497
- minRowHeight,
1498
- maxCellSize,
1499
- )
3029
+ if (axis !== 'vertical') {
3030
+ self.colWidth = clamp(
3031
+ self.colWidth * scaleFactor,
3032
+ minColWidth,
3033
+ maxCellSize,
3034
+ )
3035
+ }
3036
+ if (axis !== 'horizontal') {
3037
+ self.rowHeight = clamp(
3038
+ self.rowHeight * scaleFactor,
3039
+ minRowHeight,
3040
+ maxCellSize,
3041
+ )
3042
+ }
1500
3043
  self.scrollX = clamp(
1501
3044
  offsetX - colInView * self.colWidth,
1502
3045
  self.maxScrollX,
@@ -1504,9 +3047,7 @@ function stateModelFactory() {
1504
3047
  )
1505
3048
 
1506
3049
  const anchoredScrollY = offsetY - rowInView * self.rowHeight
1507
- // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
1508
- // overflows, so -maxScrollY is exactly that overflow past the
1509
- // scrollable MSA viewport (0 when it fits)
3050
+ // -maxScrollY is the overflow past the viewport, 0 when it fits
1510
3051
  const overflow = Math.max(0, -self.maxScrollY)
1511
3052
  const visibleHeight = self.totalHeight - overflow
1512
3053
  const topBias =
@@ -1535,13 +3076,14 @@ function stateModelFactory() {
1535
3076
 
1536
3077
  /**
1537
3078
  * #action
1538
- * Set the overlay annotations and reveal the overlay in a single step (an
1539
- * empty list clears both). Every source funnels through here after its
1540
- * own adapter has flattened it: InterProScan, GFF, user uploads, NCBI CDD.
3079
+ * Set the overlay annotations (an empty list clears them). InterProScan,
3080
+ * GFF, user uploads and NCBI CDD all arrive here as Annotation[].
3081
+ *
3082
+ * Leaves `showDomains` alone, because a restored snapshot reloads its
3083
+ * GFF and must keep a hidden overlay hidden.
1541
3084
  */
1542
3085
  setAnnotations(annotations: Annotation[]) {
1543
3086
  self.annotations = annotations
1544
- self.setShowDomains(annotations.length > 0)
1545
3087
  },
1546
3088
 
1547
3089
  /**
@@ -1566,382 +3108,62 @@ function stateModelFactory() {
1566
3108
  },
1567
3109
 
1568
3110
  /**
1569
- * #action
1570
- */
1571
- setScrollX(n: number) {
1572
- self.scrollX = clamp(n, self.maxScrollX, 0)
1573
- },
1574
-
1575
- /**
1576
- * #action
1577
- */
1578
- setColumnTracks(tracks: ColumnTrackSpec[]) {
1579
- self.columnTracks.replace(tracks)
1580
- },
1581
- /**
1582
- * #action
1583
- */
1584
- toggleTrack(id: string) {
1585
- // the stored value is "is off", so the current shown state is exactly
1586
- // what the flipped entry should hold
1587
- self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id))
1588
- },
1589
- /**
1590
- * #action
1591
- */
1592
- setStatus(status?: { msg: string; url?: string; onCancel?: () => void }) {
1593
- self.status = status
1594
- },
1595
- }))
1596
- .views(self => ({
1597
- /**
1598
- * #getter
1599
- */
1600
- get labelWidthMap() {
1601
- const { showTreeText, leaves, treeMetadata } = self
1602
- // gated on the same condition the renderer draws labels under, so the
1603
- // gutter labelsWidth reserves and the labels actually drawn cannot
1604
- // disagree -- and so turning labels off hands their space to the tree.
1605
- // Measured at a fixed reference size and scaled by labelWidthScale:
1606
- // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
1607
- // 50k-leaf tree
1608
- return showTreeText
1609
- ? new Map(
1610
- leaves.map(node => {
1611
- const { name } = node.data
1612
- // `||`, matching renderTreeLabels: an empty genome falls back
1613
- // to the row name, and measuring '' would size the gutter (and
1614
- // the label's click target) to nothing
1615
- const displayName = treeMetadata[name]?.genome || name
1616
- return [
1617
- name,
1618
- measureTextCanvas(displayName, labelReferenceFontSize),
1619
- ] as const
1620
- }),
1621
- )
1622
- : new Map<string, number>()
1623
- },
1624
-
1625
- /**
1626
- * #getter
1627
- * factor turning a labelWidthMap entry into its width at the current
1628
- * font size
1629
- */
1630
- get labelWidthScale() {
1631
- return self.fontSize / labelReferenceFontSize
1632
- },
1633
-
1634
- get labelsWidth() {
1635
- // a loop, not Math.max(...widths.values()): spreading a map of every
1636
- // leaf passes one argument per row, and the argument limit is somewhere
1637
- // around 125k -- so the bundled 230k-tip COVID tree threw
1638
- // "RangeError: Maximum call stack size exceeded" out of a getter the
1639
- // treeWidth autorun reads on load
1640
- let max = 0
1641
- for (const width of this.labelWidthMap.values()) {
1642
- if (width > max) {
1643
- max = width
1644
- }
1645
- }
1646
- return max * this.labelWidthScale
1647
- },
1648
-
1649
- /**
1650
- * #getter
1651
- */
1652
- get secondaryStructureConsensus() {
1653
- return self.MSA?.secondaryStructureConsensus
1654
- },
1655
-
1656
- /**
1657
- * #getter
1658
- */
1659
- get seqConsensus() {
1660
- return self.MSA?.seqConsensus
1661
- },
1662
-
1663
- /**
1664
- * #getter
1665
- */
1666
- get adapterTrackModels(): BasicTrack[] {
1667
- const { rowHeight, MSA, hideGapsEffective, blanks } = self
1668
- const tracks = MSA?.tracks ?? []
1669
- return tracks
1670
- .filter(t => !!t.data)
1671
- .map(t => ({
1672
- model: {
1673
- ...t,
1674
- kind: 'text' as const,
1675
- data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
1676
- height: rowHeight,
1677
- },
1678
- ReactComponent: TrackBlocks,
1679
- }))
1680
- },
1681
-
1682
- /**
1683
- * #getter
1684
- * a data track's values or string, projected from its row's residues
1685
- * onto alignment columns when it names a row
1686
- */
1687
- get columnTrackContent() {
1688
- const { MSA, blanks, hideGapsEffective } = self
1689
- const width = MSA?.getWidth() ?? 0
1690
- const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
1691
- if (!track.row) {
1692
- return items
1693
- }
1694
- const out = Array.from({ length: width }, () => fill)
1695
- const index = self.seqPosGlobalColIndex.get(track.row)
1696
- items.forEach((item, seqPos) => {
1697
- const col = index?.[seqPos]
1698
- if (col !== undefined) {
1699
- out[col] = item
1700
- }
1701
- })
1702
- return out
1703
- }
1704
- const skip = <T>(items: T[]) =>
1705
- hideGapsEffective ? dropBlanks(blanks, items) : items
1706
- return new Map<string, { values?: number[]; data?: string }>(
1707
- self.columnTracks.map(track => {
1708
- if (track.kind === 'bar') {
1709
- const max = track.max ?? 1
1710
- const values = skip(project(track, track.values ?? [], 0)).map(
1711
- v => Math.min(1, Math.max(0, v / max)),
1712
- )
1713
- return [track.id, { values }] as const
1714
- }
1715
- const data = skip(project(track, (track.data ?? '').split(''), ' '))
1716
- return [track.id, { data: data.join('') }] as const
1717
- }),
1718
- )
1719
- },
1720
- /**
1721
- * #getter
1722
- */
1723
- get columnTrackModels(): BasicTrack[] {
1724
- return self.columnTracks.map(track => ({
1725
- model: {
1726
- id: track.id,
1727
- name: track.name,
1728
- kind: track.kind,
1729
- height:
1730
- track.height ??
1731
- (track.kind === 'bar'
1732
- ? self.conservationTrackHeight
1733
- : self.rowHeight),
1734
- barColor: track.color,
1735
- customColorScheme: track.colors,
1736
- data: this.columnTrackContent.get(track.id)?.data,
1737
- },
1738
- ReactComponent: TrackBlocks,
1739
- }))
1740
- },
1741
- /**
1742
- * #getter
1743
- */
1744
- get tracks(): BasicTrack[] {
1745
- const conservationTrack = {
1746
- id: 'conservation',
1747
- name: 'Conservation',
1748
- kind: 'bar' as const,
1749
- height: self.conservationTrackHeight,
1750
- barColor: 'gray',
1751
- }
1752
- const propertyConservationTrack = {
1753
- id: 'property-conservation',
1754
- name: 'Property conservation',
1755
- kind: 'bar' as const,
1756
- height: self.conservationTrackHeight,
1757
- barColor: '#6a51a3',
1758
- }
1759
- const sequenceLogoTrack = {
1760
- id: 'sequence-logo',
1761
- name: 'Sequence logo',
1762
- kind: 'logo' as const,
1763
- height: self.sequenceLogoTrackHeight,
1764
- }
1765
- return [
1766
- ...this.adapterTrackModels,
1767
- ...this.columnTrackModels,
1768
- ...[
1769
- conservationTrack,
1770
- ...(self.sequenceType === 'amino'
1771
- ? [propertyConservationTrack]
1772
- : []),
1773
- sequenceLogoTrack,
1774
- ].map(model => ({ model, ReactComponent: TrackBlocks })),
1775
- ]
1776
- },
1777
-
1778
- /**
1779
- * #getter
1780
- */
1781
- get turnedOnTracks() {
1782
- return this.tracks.filter(
1783
- f => !trackIsOff(self.turnedOffTracks, f.model.id),
1784
- )
1785
- },
1786
-
1787
- /**
1788
- * #getter
1789
- */
1790
- get showHorizontalScrollbar() {
1791
- return self.msaAreaWidth < self.totalWidth
1792
- },
1793
-
1794
- /**
1795
- * #method
1796
- * Return a row-specific letter at a visible column, or undefined if gap.
1797
- *
1798
- * @param rowName - The name of the row
1799
- * @param visibleCol - The visible column index (what the user sees on screen)
1800
- * @returns The letter at that position, or undefined if it's a gap
1801
- */
1802
- visibleColToRowLetter(rowName: string, visibleCol: number) {
1803
- const { rowMap, blanks } = self
1804
- return rowMap.get(rowName)?.[visibleColToGlobalCol(blanks, visibleCol)]
1805
- },
1806
-
1807
- /**
1808
- * #method
1809
- * Convert a visible column to a row-specific sequence position (0-based).
1810
- * Returns undefined if the position is a gap in the sequence.
1811
- *
1812
- * CROSS-REPO CONTRACT: this and the sibling coordinate converters
1813
- * (seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) are used
1814
- * by jbrowse-plugin-protein3d to translate between alignment columns and
1815
- * structure/sequence residue positions across gaps. Keep them stable.
1816
- *
1817
- * @param rowName - The name of the row
1818
- * @param visibleCol - The visible column index
1819
- * @returns The sequence position (0-based), or undefined if it's a gap
1820
- */
1821
- visibleColToSeqPos(rowName: string, visibleCol: number) {
1822
- return visibleColToSeqPosForRow({
1823
- rowName,
1824
- visibleCol,
1825
- rowMap: self.rowMap,
1826
- blanks: self.blanks,
1827
- })
1828
- },
1829
-
1830
- /**
1831
- * #method
1832
- * Convert a visible column to a row-specific sequence position (1-based).
1833
- * Returns undefined if the position is a gap in the sequence.
1834
- *
1835
- * @param rowName - The name of the row
1836
- * @param visibleCol - The visible column index
1837
- * @returns The sequence position (1-based), or undefined if it's a gap
1838
- */
1839
- visibleColToSeqPosOneBased(rowName: string, visibleCol: number) {
1840
- const val = this.visibleColToSeqPos(rowName, visibleCol)
1841
- return val !== undefined ? val + 1 : undefined
1842
- },
1843
-
1844
- /**
1845
- * #method
1846
- * Convert a global column index to a visible column index.
1847
- * Returns undefined if the column is hidden (in blanks).
1848
- * This is the inverse of visibleColToGlobalCol.
1849
- *
1850
- * @param globalCol - The global column index in the full MSA
1851
- * @returns The visible column index, or undefined if the column is hidden
1852
- */
1853
- globalColToVisibleCol(globalCol: number) {
1854
- const { blanks, hideGapsEffective } = self
1855
- if (!hideGapsEffective) {
1856
- return globalCol
1857
- }
1858
- return globalColToVisibleCol(blanks, globalCol)
3111
+ * #action
3112
+ */
3113
+ setScrollX(n: number) {
3114
+ self.scrollX = clamp(n, self.maxScrollX, 0)
1859
3115
  },
1860
3116
 
1861
3117
  /**
1862
- * #method
1863
- * Convert a sequence position (ungapped) to a global column index.
1864
- *
1865
- * @param rowName - The name of the row
1866
- * @param seqPos - The sequence position (0-based, ungapped)
1867
- * @returns The global column index in the full MSA
3118
+ * #action
1868
3119
  */
1869
- seqPosToGlobalCol(rowName: string, seqPos: number) {
1870
- const seq = self.rowMap.get(rowName)
1871
- if (seq === undefined) {
1872
- return 0
1873
- }
1874
- const col = self.seqPosGlobalColIndex.get(rowName)?.[seqPos]
1875
- // past the end of the ungapped sequence: 0 for the degenerate all-gap
1876
- // row, otherwise one past the last column
1877
- return col ?? (seqPos === 0 ? 0 : seq.length)
3120
+ setColumnTracks(tracks: ColumnTrackSpec[]) {
3121
+ self.columnTracks.replace(tracks)
1878
3122
  },
1879
-
1880
3123
  /**
1881
- * #method
1882
- * Convert a sequence position (ungapped) directly to a visible column index.
1883
- * This combines seqPosToGlobalCol and globalColToVisibleCol.
1884
- *
1885
- * @param rowName - The name of the row
1886
- * @param seqPos - The sequence position (0-based, ungapped)
1887
- * @returns The visible column index, or undefined if the column is hidden
3124
+ * #action
3125
+ * replace the row table, which the model keeps as the JSON string
3126
+ * `data.treeMetadata` (see docs/layers.md)
1888
3127
  */
1889
- seqPosToVisibleCol(rowName: string, seqPos: number) {
1890
- const globalCol = this.seqPosToGlobalCol(rowName, seqPos)
1891
- return this.globalColToVisibleCol(globalCol)
3128
+ setRowData(rowData: Record<string, Record<string, string>>) {
3129
+ self.data.setTreeMetadata(JSON.stringify(rowData))
1892
3130
  },
1893
- }))
1894
-
1895
- .views(self => ({
1896
3131
  /**
1897
- * #getter
1898
- * widget width minus the tree area gives the space for the MSA
3132
+ * #action
3133
+ * replace what the viewer's marks read from the row table
1899
3134
  */
1900
- get msaAreaHeight() {
1901
- return (
1902
- self.height -
1903
- (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
1904
- self.headerHeight
1905
- )
3135
+ setEncodings(encodings: Encoding[]) {
3136
+ self.encodings.replace(encodings)
1906
3137
  },
1907
3138
  /**
1908
- * #getter
1909
- * total height of track area (px)
3139
+ * #action
3140
+ * replace the panels drawn between the tree and the alignment
1910
3141
  */
1911
- get totalTrackAreaHeight() {
1912
- return sum(self.turnedOnTracks.map(r => r.model.height))
3142
+ setRowPanels(panels: RowPanelSpec[]) {
3143
+ self.rowPanels.replace(panels)
1913
3144
  },
1914
3145
  /**
1915
- * one representative annotation per accession, which is what the legend,
1916
- * the filter dialog and the palettes key off
3146
+ * #action
3147
+ * replace the alignment<->structure correspondence (see docs/layers.md)
1917
3148
  */
1918
- get annotationTypes() {
1919
- // first occurrence wins. The representative supplies only the name,
1920
- // description and -- for ordinal segments -- the start that orders
1921
- // them, and those agree across an accession's instances
1922
- const types = new Map<string, Annotation>()
1923
- for (const annot of self.annotations) {
1924
- if (!types.has(annot.accession)) {
1925
- types.set(annot.accession, annot)
1926
- }
1927
- }
1928
- return types
3149
+ setResidueMappings(mappings: ResidueMapping[]) {
3150
+ self.residueMappings.replace(mappings)
1929
3151
  },
1930
- get filteredAnnotations() {
1931
- return self.annotations.filter(r =>
1932
- self.featureFilters.get(r.accession),
3152
+ /**
3153
+ * #action
3154
+ */
3155
+ toggleTrack(id: string) {
3156
+ const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff
3157
+ self.turnedOffTracks.set(
3158
+ id,
3159
+ !trackIsOff(self.turnedOffTracks, id, defaultOff),
1933
3160
  )
1934
3161
  },
1935
- get annotationsByRow() {
1936
- return groupBy(this.filteredAnnotations, r => r.id)
1937
- },
1938
- }))
1939
- .views(self => ({
1940
3162
  /**
1941
- * #getter
3163
+ * #action
1942
3164
  */
1943
- get showVerticalScrollbar() {
1944
- return self.msaAreaHeight < self.totalHeight
3165
+ setStatus(status?: { msg: string; onCancel?: () => void }) {
3166
+ self.status = status
1945
3167
  },
1946
3168
  }))
1947
3169
  .views(self => ({
@@ -1953,18 +3175,78 @@ function stateModelFactory() {
1953
3175
  },
1954
3176
  /**
1955
3177
  * #getter
1956
- * width of the alignment canvas itself: the msa area less the vertical
1957
- * scrollbar sitting in it. Not usable from showHorizontalScrollbar, which
1958
- * feeds msaAreaHeight -> showVerticalScrollbar and would close a cycle
3178
+ * width of the alignment canvas: the msa area less the vertical
3179
+ * scrollbar. showHorizontalScrollbar must not read it, since that feeds
3180
+ * msaAreaHeight -> showVerticalScrollbar and would form a cycle
1959
3181
  */
1960
3182
  get msaCanvasWidth() {
1961
3183
  return self.msaAreaWidth - this.verticalScrollbarWidth
1962
3184
  },
3185
+ /**
3186
+ * #method
3187
+ * the cell at a visible column and row index, in the coordinates a host
3188
+ * writes highlights in
3189
+ */
3190
+ cellAt(visibleCol: number, rowIndex?: number): Cell {
3191
+ const column = self.visibleColToGlobalCol(visibleCol)
3192
+ const row =
3193
+ rowIndex === undefined ? undefined : self.leaves[rowIndex]?.data.name
3194
+ return row === undefined
3195
+ ? { column: column + 1 }
3196
+ : {
3197
+ column: column + 1,
3198
+ row,
3199
+ residue: self.visibleColToSeqPosOneBased(row, visibleCol),
3200
+ letter: self.rowMap.get(row)?.[column],
3201
+ }
3202
+ },
3203
+ /**
3204
+ * #getter
3205
+ * the cell under the pointer. Public API: MSAViewer's onCellHover
3206
+ * reports it.
3207
+ */
3208
+ get hoveredCell() {
3209
+ const { mouseCol, mouseRow } = self
3210
+ return mouseCol === undefined
3211
+ ? undefined
3212
+ : this.cellAt(mouseCol, mouseRow)
3213
+ },
3214
+ /**
3215
+ * #getter
3216
+ * the cell a click pinned. Public API: MSAViewer's onCellClick reports it.
3217
+ */
3218
+ get clickedCell() {
3219
+ const { mouseClickCol, mouseClickRow } = self
3220
+ return mouseClickCol === undefined
3221
+ ? undefined
3222
+ : this.cellAt(mouseClickCol, mouseClickRow)
3223
+ },
3224
+ /**
3225
+ * #getter
3226
+ * the columns on screen. Public API: MSAViewer's onViewportChange reports
3227
+ * it.
3228
+ */
3229
+ get viewport(): Viewport | undefined {
3230
+ const { scrollX, colWidth, numColumns, viewInitialized } = self
3231
+ if (numColumns === 0 || !viewInitialized) {
3232
+ return undefined
3233
+ }
3234
+ const { xStart, xEnd } = visibleColRange({
3235
+ offsetX: -scrollX,
3236
+ blockWidth: this.msaCanvasWidth,
3237
+ colWidth,
3238
+ })
3239
+ const last = Math.max(0, Math.min(xEnd, numColumns) - 1)
3240
+ return {
3241
+ startColumn: self.visibleColToGlobalCol(Math.min(xStart, last)) + 1,
3242
+ endColumn: self.visibleColToGlobalCol(last) + 1,
3243
+ }
3244
+ },
1963
3245
  /**
1964
3246
  * #getter
1965
3247
  * ordinal segment types (exons etc.), ordered by sequence position so
1966
- * exon-1..exon-14 read left-to-right; colored by alternating shade and
1967
- * labeled by number rather than each getting a distinct hue + legend row
3248
+ * exon-1..exon-14 run left-to-right; colored by alternating shade and
3249
+ * labeled by number, with no legend row
1968
3250
  */
1969
3251
  get segmentDomainTypes() {
1970
3252
  return [...self.annotationTypes.values()]
@@ -1996,10 +3278,48 @@ function stateModelFactory() {
1996
3278
  get strokePalette() {
1997
3279
  return transform(this.fillPalette, ([key, val]) => [
1998
3280
  key,
1999
- colord(val).darken(0.1).toHex(),
3281
+ outlineColor(val),
2000
3282
  ])
2001
3283
  },
2002
3284
 
3285
+ /**
3286
+ * #getter
3287
+ * the encoding coloring the overlay's spans, undefined when none does,
3288
+ * which leaves each span the color its accession takes in `fillPalette`
3289
+ */
3290
+ get featureFillEncoding(): ResolvedEncoding | undefined {
3291
+ return this.resolvedEncodings.find(e => e.channel === 'featureFill')
3292
+ },
3293
+
3294
+ /**
3295
+ * #getter
3296
+ * the fill and outline of every feature's span: its own GFF `color=`
3297
+ * first, then the `featureFill` scale, then the accession palette.
3298
+ * Computed once per change of the features, the encodings or the palette
3299
+ */
3300
+ get featureColors(): Map<Annotation, { fill: string; stroke: string }> {
3301
+ return featureColorMap(
3302
+ self.filteredAnnotations,
3303
+ this.featureFillEncoding,
3304
+ this.fillPalette,
3305
+ )
3306
+ },
3307
+
3308
+ /**
3309
+ * #getter
3310
+ * the text the `featureLabel` channel draws inside each span, undefined
3311
+ * when no encoding names the channel. A data channel, so it draws
3312
+ * whether or not the residue letters do
3313
+ */
3314
+ get featureLabels(): Map<Annotation, string> | undefined {
3315
+ const encoding = this.resolvedEncodings.find(
3316
+ e => e.channel === 'featureLabel',
3317
+ )
3318
+ return encoding
3319
+ ? featureLabelMap(self.filteredAnnotations, encoding.field)
3320
+ : undefined
3321
+ },
3322
+
2003
3323
  /**
2004
3324
  * #getter
2005
3325
  * accession -> number drawn on each segment band: the trailing number of
@@ -2017,25 +3337,104 @@ function stateModelFactory() {
2017
3337
  /**
2018
3338
  * #getter
2019
3339
  * the domain types currently drawn on the alignment (filtered-on), shared
2020
- * by the on-screen legend and the SVG export legend. Ordinal segments
2021
- * (exons) are excluded — they read as a numbered gene model, not a color
2022
- * key — so this is the categorical types ordered by sequence position
3340
+ * by the on-screen legend and the SVG export legend: the categorical types
3341
+ * ordered by sequence position. Ordinal segments (exons) are numbered on
3342
+ * the band instead
2023
3343
  */
2024
3344
  get visibleDomainTypes() {
2025
3345
  return this.categoricalDomainTypes
2026
- .filter(d => self.featureFilters.get(d.accession))
3346
+ .filter(d => !self.turnedOffFeatures.get(d.accession))
2027
3347
  .toSorted((a, b) => a.start - b.start)
2028
3348
  },
2029
3349
 
3350
+ /**
3351
+ * #getter
3352
+ * the categorical color keys drawn for this view, shared by the on-screen
3353
+ * legend overlay and the SVG export's reserved column. The domain overlay
3354
+ * produces the first, listing the `featureFill` scale where an encoding
3355
+ * names one. Every field a row-table encoding or a row panel reads
3356
+ * produces one more, so two channels over one field, or two strips over
3357
+ * it, list that field once. A strip's `legend` names the title it lists
3358
+ * under, so strips over eight fields with one set of colors list one
3359
+ * legend between them
3360
+ */
3361
+ get legends(): Legend[] {
3362
+ const { featureFillEncoding, fillPalette, visibleDomainTypes } = this
3363
+ const byKey = new Map<string, Legend>()
3364
+ // the accession palette keys on no field, so it takes a key of its own
3365
+ const add = (
3366
+ field: string | undefined,
3367
+ entries: LegendEntry[],
3368
+ id?: string,
3369
+ ) => {
3370
+ if (entries.length === 0) {
3371
+ return
3372
+ }
3373
+ const key = field === undefined ? 'domains' : `field:${field}`
3374
+ const legend = byKey.get(key)
3375
+ if (legend) {
3376
+ const seen = new Set(legend.entries.map(e => e.id))
3377
+ legend.entries.push(...entries.filter(e => !seen.has(e.id)))
3378
+ } else {
3379
+ byKey.set(key, {
3380
+ id: id ?? (field === undefined ? 'domains' : `rowData-${field}`),
3381
+ title: field ?? 'Domains',
3382
+ entries: [...entries],
3383
+ })
3384
+ }
3385
+ }
3386
+ // the overlay draws the domain key; with no columns it draws nothing,
3387
+ // and a features panel taking the same colors lists them instead
3388
+ if (self.actuallyShowDomains && self.numColumns > 0) {
3389
+ add(
3390
+ featureFillEncoding?.field,
3391
+ featureFillEncoding
3392
+ ? featureFillEncoding.legend
3393
+ : visibleDomainTypes.map(d => ({
3394
+ id: d.accession,
3395
+ label: d.name,
3396
+ color: fillPalette[d.accession]!,
3397
+ })),
3398
+ 'domains',
3399
+ )
3400
+ }
3401
+ for (const { channel, field, legend } of this.resolvedEncodings) {
3402
+ if (!featureChannels.has(channel)) {
3403
+ add(field, legend)
3404
+ }
3405
+ }
3406
+ for (const { legendTitle, legend } of this.resolvedRowPanels) {
3407
+ add(legendTitle, legend)
3408
+ }
3409
+ return [...byKey.values()]
3410
+ },
3411
+
3412
+ /**
3413
+ * #getter
3414
+ * whether the overlay marks each domain with a bar under its row instead
3415
+ * of filling the row behind the letters. Letter-color mode hands the
3416
+ * background to the color scheme, so a filled box would paint over it and
3417
+ * leave the setting with nothing to show. Sub-row layout already stacks
3418
+ * the boxes clear of the letters, and with the letters too small to draw
3419
+ * the filled box is the only thing left to read.
3420
+ */
3421
+ get domainUnderline() {
3422
+ return (
3423
+ self.actuallyShowDomains &&
3424
+ !self.bgColor &&
3425
+ !self.subFeatureRows &&
3426
+ self.showMsaLetters
3427
+ )
3428
+ },
3429
+
2030
3430
  /**
2031
3431
  * #getter
2032
3432
  * every filtered-on annotation resolved to the visible column span it is
2033
3433
  * drawn across, keyed by row name. Each row is ordered longest-first so a
2034
- * short domain nested inside a long one draws on top of it rather than
2035
- * under it. Resolving these once here rather than inside each canvas
2036
- * block removes a per-feature, per-block sequence position conversion
2037
- * from every redraw, and gives the letter renderer the band colors it
2038
- * needs to keep residues readable on top of the boxes.
3434
+ * nested short domain draws on top, and each band carries the lane the
3435
+ * sub-row layout puts it in. Resolved once here instead of per canvas
3436
+ * block per redraw; the letter renderer also reads the band colors to
3437
+ * pick legible letter colors.
2039
3438
  */
2040
3439
  get domainBands() {
2041
3440
  const { blanks } = self
@@ -2046,32 +3445,27 @@ function stateModelFactory() {
2046
3445
  const rowBands = annotations
2047
3446
  .toSorted((a, b) => len(b) - len(a))
2048
3447
  .map(annotation => {
2049
- // annotation positions are 1-based and inclusive. Both ends count
2050
- // the visible columns in front of a global column, so endCol is
2051
- // the exclusive column after the last residue's own column --
2052
- // the band stops there rather than stretching across a following
2053
- // gap run -- and a residue whose column is itself hidden
2054
- // collapses onto the neighbouring boundary instead of dropping
2055
- // the band. A band whose every column is hidden spans nothing
2056
- // and is left out.
2057
- const startCol = visibleColsBefore(
2058
- blanks,
2059
- self.seqPosToGlobalCol(name, annotation.start - 1),
2060
- )
2061
- const endCol = visibleColsBefore(
2062
- blanks,
2063
- self.seqPosToGlobalCol(name, annotation.end - 1) + 1,
2064
- )
3448
+ // annotation positions are 1-based and inclusive. endCol is
3449
+ // exclusive, one past the last residue's column, so the band does
3450
+ // not extend over a following gap run. An endpoint in a hidden
3451
+ // column moves to the neighboring boundary. Bands with no visible
3452
+ // columns, or naming a missing row, are dropped.
3453
+ const start = self.seqPosToGlobalCol(name, annotation.start - 1)
3454
+ const end = self.seqPosToGlobalCol(name, annotation.end - 1)
3455
+ if (start === undefined || end === undefined) {
3456
+ return undefined
3457
+ }
3458
+ const startCol = visibleColsBefore(blanks, start)
3459
+ const endCol = visibleColsBefore(blanks, end + 1)
2065
3460
  return endCol > startCol
2066
3461
  ? { annotation, startCol, endCol }
2067
3462
  : undefined
2068
3463
  })
2069
3464
  .filter(notEmpty)
2070
- // numbered after the drop, so a band that resolved to nothing does
2071
- // not leave an empty sub-row behind it
3465
+ // numbered after filtering, so a dropped band leaves no gap
2072
3466
  .map((band, stackIndex) => ({ ...band, stackIndex }))
2073
3467
  if (rowBands.length > 0) {
2074
- bands.set(name, rowBands)
3468
+ bands.set(name, packDomainLanes(rowBands))
2075
3469
  }
2076
3470
  }
2077
3471
  return bands
@@ -2099,14 +3493,16 @@ function stateModelFactory() {
2099
3493
  * domain annotations under the mouse, hit-tested against the exact visible
2100
3494
  * column span each box is drawn at (so it matches the overlay across gaps)
2101
3495
  */
2102
- get mouseOverDomains() {
3496
+ get mouseOverDomains(): Annotation[] {
2103
3497
  const { mouseCol } = self
2104
3498
  const name = self.mouseOverRowName
2105
- return name !== undefined && mouseCol !== undefined
2106
- ? (this.domainBands.get(name) ?? [])
2107
- .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
2108
- .map(b => b.annotation)
2109
- : []
3499
+ if (name === undefined || mouseCol === undefined) {
3500
+ return noDomains
3501
+ }
3502
+ const hits = (this.domainBands.get(name) ?? [])
3503
+ .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
3504
+ .map(b => b.annotation)
3505
+ return hits.length > 0 ? hits : noDomains
2110
3506
  },
2111
3507
 
2112
3508
  /**
@@ -2124,8 +3520,7 @@ function stateModelFactory() {
2124
3520
  * #getter
2125
3521
  * row indices highlighted by the current tree hover (a hovered internal
2126
3522
  * node highlights every tip below it). Shared by the tree and MSA overlay
2127
- * canvases so they cannot disagree, and resolved through the memoized
2128
- * name->index map rather than rebuilding a lookup on each mouse move.
3523
+ * canvases, via the memoized name->index map.
2129
3524
  */
2130
3525
  get hoveredRowIndices() {
2131
3526
  const { hoveredTreeNode, rowNamesSet } = self
@@ -2139,8 +3534,8 @@ function stateModelFactory() {
2139
3534
  /**
2140
3535
  * #getter
2141
3536
  * contiguous runs of `highlightedColumns`, so a run of highlighted columns
2142
- * draws as one bordered band. Computed here because the overlay canvas
2143
- * redraws on every mouse move while the highlight itself rarely changes.
3537
+ * draws as one bordered band. Memoized because the overlay canvas redraws
3538
+ * on every mouse move.
2144
3539
  */
2145
3540
  get highlightedColumnRuns() {
2146
3541
  const { highlightedColumns } = self
@@ -2166,89 +3561,289 @@ function stateModelFactory() {
2166
3561
  * is dropped. Row names that match no row are ignored.
2167
3562
  */
2168
3563
  get resolvedHighlights(): ResolvedHighlight[] {
2169
- const { blanks, rowNamesSet, rowMap } = self
2170
- const toVisible = (globalCol: number) => {
2171
- const visible = self.globalColToVisibleCol(globalCol)
2172
- return visible ?? visibleColsBefore(blanks, globalCol)
2173
- }
2174
- return self.highlights.flatMap(
2175
- ({ row, rows, start, end, label, color }) => {
2176
- const base = { label, color }
2177
- if (rows) {
2178
- const rowIndices = rows
2179
- .map(name => rowNamesSet.get(name))
2180
- .filter(notEmpty)
2181
- return rowIndices.length ? [{ ...base, rowIndices }] : []
2182
- }
2183
- if (start === undefined || end === undefined) {
2184
- return []
3564
+ const { rowNamesSet, transientHighlights } = self
3565
+ // persisted highlights first, so transient ones draw on top
3566
+ const all = [
3567
+ ...self.highlights,
3568
+ ...Object.values(transientHighlights).flat(),
3569
+ ]
3570
+ return all.flatMap(({ row, rows, start, end, label, color }) => {
3571
+ const base = { label, color }
3572
+ if (rows) {
3573
+ const rowIndices = rows
3574
+ .map(name => rowNamesSet.get(name))
3575
+ .filter(notEmpty)
3576
+ return rowIndices.length ? [{ ...base, rowIndices }] : []
3577
+ }
3578
+ if (start === undefined || end === undefined) {
3579
+ return []
3580
+ }
3581
+ const span = self.visibleSpan({ row, start, end })
3582
+ return span ? [{ ...base, ...span, rowIndices: [] }] : []
3583
+ })
3584
+ },
3585
+
3586
+ /**
3587
+ * #method
3588
+ * per-column summary statistics: consensus residue and its identity
3589
+ * fraction, both conservation scores, gap fraction, and the sorted non-gap
3590
+ * residue distribution. undefined past the end of the alignment or for an
3591
+ * all-gap column.
3592
+ */
3593
+ columnStatsAt(col: number): ColumnStats | undefined {
3594
+ const { colStats, conservation, propertyConservation } = self
3595
+ return columnStats({
3596
+ col,
3597
+ colStats,
3598
+ conservation,
3599
+ propertyConservation,
3600
+ })
3601
+ },
3602
+
3603
+ /**
3604
+ * #getter
3605
+ * `columnStatsAt` for the hovered column, undefined when nothing is
3606
+ * hovered
3607
+ */
3608
+ get mouseOverColumnStats(): ColumnStats | undefined {
3609
+ const { mouseCol, colStats, conservation, propertyConservation } = self
3610
+ return mouseCol === undefined
3611
+ ? undefined
3612
+ : columnStats({
3613
+ col: mouseCol,
3614
+ colStats,
3615
+ conservation,
3616
+ propertyConservation,
3617
+ })
3618
+ },
3619
+
3620
+ /**
3621
+ * #method
3622
+ */
3623
+ getRowData(name: string) {
3624
+ return {
3625
+ data: self.MSA?.getRowData(name),
3626
+ rowData: self.rowDataOf(name),
3627
+ }
3628
+ },
3629
+
3630
+ /**
3631
+ * #getter
3632
+ * each encoding with its scale resolved against the values its field
3633
+ * takes: a feature channel reads them across the features drawn, every
3634
+ * other channel across the row table. Resolved once per change of that
3635
+ * table or the encodings, never per row per frame.
3636
+ */
3637
+ get resolvedEncodings(): ResolvedEncoding[] {
3638
+ const rows = Object.values(self.rowData)
3639
+ const features = self.filteredAnnotations
3640
+ return self.encodings.map(encoding => ({
3641
+ ...encoding,
3642
+ ...resolveScale(
3643
+ encoding.scale,
3644
+ featureChannels.has(encoding.channel)
3645
+ ? features
3646
+ .map(a => featureField(a, encoding.field))
3647
+ .filter(notEmpty)
3648
+ : rows.map(row => row?.[encoding.field]).filter(notEmpty),
3649
+ ),
3650
+ }))
3651
+ },
3652
+
3653
+ /**
3654
+ * #getter
3655
+ * each row panel with its scale resolved against the values its field
3656
+ * takes across the row table, giving the color per row name, the pixel
3657
+ * column it draws in, and the entries its legend lists. Resolved once
3658
+ * per change of that table or the panels, never per block per frame.
3659
+ */
3660
+ get resolvedRowPanels(): ResolvedRowPanel[] {
3661
+ const rows = Object.entries(self.rowData)
3662
+ const { fillPalette, featureFillEncoding, featureLabels } = this
3663
+ const annotations = self.filteredAnnotations
3664
+ let offsetX = 0
3665
+ return self.rowPanels.map((panel, index) => {
3666
+ const width = rowPanelWidth(panel, self.rowHeight)
3667
+ const base = { id: `rowpanel-${index}`, width, offsetX }
3668
+ offsetX += width
3669
+ if (panel.kind === 'features') {
3670
+ const color = panel.encoding?.color
3671
+ const encoding = color
3672
+ ? resolveFeatureScale(color.field, color.scale, annotations)
3673
+ : featureFillEncoding
3674
+ const label = panel.encoding?.label
3675
+ const align = panel.transform?.find(t => t.type === 'align')
3676
+ return {
3677
+ ...base,
3678
+ kind: panel.kind,
3679
+ x: panel.x,
3680
+ header: panel.header ?? '',
3681
+ field: encoding?.field,
3682
+ legendTitle: encoding?.field,
3683
+ spans: featurePanelSpans({
3684
+ panel,
3685
+ width,
3686
+ rowHeight: self.rowHeight,
3687
+ colWidth: self.colWidth,
3688
+ domainBands: this.domainBands,
3689
+ annotationsByRow: self.annotationsByRow,
3690
+ shifts: align
3691
+ ? this.featureAlignShifts.get(align.on)
3692
+ : undefined,
3693
+ }),
3694
+ colors: featureColorMap(annotations, encoding, fillPalette),
3695
+ labels: label
3696
+ ? featureLabelMap(annotations, label)
3697
+ : featureLabels,
3698
+ legend:
3699
+ encoding?.legend ??
3700
+ this.visibleDomainTypes.map(d => ({
3701
+ id: d.accession,
3702
+ label: d.name,
3703
+ color: fillPalette[d.accession]!,
3704
+ })),
2185
3705
  }
2186
- let startGlobal = start - 1
2187
- let endGlobal = end - 1
2188
- if (row !== undefined) {
2189
- if (!rowMap.has(row)) {
2190
- return []
2191
- }
2192
- startGlobal = self.seqPosToGlobalCol(row, start - 1)
2193
- endGlobal = self.seqPosToGlobalCol(row, end - 1)
3706
+ }
3707
+ const { colorOf, legend } = resolveScale(
3708
+ panel.scale,
3709
+ rows.map(([, row]) => row?.[panel.field]).filter(notEmpty),
3710
+ )
3711
+ const colors = new Map<string, string>()
3712
+ for (const [name, row] of rows) {
3713
+ const value = row?.[panel.field]
3714
+ const color = value === undefined ? undefined : colorOf(value)
3715
+ if (color) {
3716
+ colors.set(name, color)
2194
3717
  }
2195
- const startCol = toVisible(startGlobal)
2196
- const endVisible = self.globalColToVisibleCol(endGlobal)
2197
- const endCol =
2198
- endVisible ?? visibleColsBefore(blanks, endGlobal) - 1
2199
- return startCol <= endCol
2200
- ? [{ ...base, startCol, endCol, rowIndices: [] }]
2201
- : []
2202
- },
3718
+ }
3719
+ return {
3720
+ ...base,
3721
+ kind: panel.kind,
3722
+ field: panel.field,
3723
+ header: panel.header ?? panel.field,
3724
+ legendTitle: panel.legend ?? panel.field,
3725
+ colors,
3726
+ legend,
3727
+ }
3728
+ })
3729
+ },
3730
+
3731
+ /**
3732
+ * #getter
3733
+ * the shift an `align` transform gives each row, keyed by the feature
3734
+ * name it aligns on: the offset putting the first feature of that name
3735
+ * at zero. A row carrying no such feature is absent, and keeps its own
3736
+ * origin.
3737
+ */
3738
+ get featureAlignShifts(): Map<string, Map<string, number>> {
3739
+ const names = new Set(
3740
+ self.rowPanels.flatMap(panel =>
3741
+ panel.kind === 'features' && panel.x === 'position'
3742
+ ? (panel.transform ?? [])
3743
+ .filter(t => t.type === 'align')
3744
+ .map(t => t.on)
3745
+ : [],
3746
+ ),
3747
+ )
3748
+ return new Map(
3749
+ [...names].map(on => [
3750
+ on,
3751
+ new Map(
3752
+ Object.entries(self.annotationsByRow)
3753
+ .map(([name, annotations]) => {
3754
+ const hit = annotations.find(a => featureName(a) === on)
3755
+ return hit ? ([name, 1 - hit.start] as const) : undefined
3756
+ })
3757
+ .filter(notEmpty),
3758
+ ),
3759
+ ]),
2203
3760
  )
2204
3761
  },
2205
3762
 
2206
3763
  /**
2207
3764
  * #getter
2208
- * per-column summary statistics for the hovered column: consensus residue
2209
- * and its identity fraction, conservation score, gap fraction, and the
2210
- * sorted non-gap residue distribution. undefined when nothing is hovered.
3765
+ * the color the `tipLabel` channel gives each row, by row name. Undefined
3766
+ * when no encoding names the channel, which leaves the labels the theme's
3767
+ * text color.
2211
3768
  */
2212
- get mouseOverColumnStats() {
2213
- const { mouseCol } = self
2214
- if (mouseCol === undefined) {
3769
+ get tipLabelColors(): Map<string, string> | undefined {
3770
+ const encoding = this.resolvedEncodings.find(
3771
+ e => e.channel === 'tipLabel',
3772
+ )
3773
+ if (!encoding) {
2215
3774
  return undefined
2216
3775
  }
2217
- const { colStats } = self
2218
- if (mouseCol >= colStats.numColumns) {
2219
- return undefined
3776
+ const colors = new Map<string, string>()
3777
+ for (const [name, row] of Object.entries(self.rowData)) {
3778
+ const value = row?.[encoding.field]
3779
+ const color =
3780
+ value === undefined ? undefined : encoding.colorOf(value)
3781
+ if (color) {
3782
+ colors.set(name, color)
3783
+ }
2220
3784
  }
2221
- const total = colStats.total(mouseCol)
2222
- if (!total) {
3785
+ return colors
3786
+ },
3787
+
3788
+ /**
3789
+ * #getter
3790
+ * the wash the `rowTint` channel draws over each row, indexed by row, or
3791
+ * undefined when no encoding names the channel. The overlay draws these,
3792
+ * so a tint stays out of the raster tile cache and its keys.
3793
+ */
3794
+ get rowTints(): (string | undefined)[] | undefined {
3795
+ const encoding = this.resolvedEncodings.find(
3796
+ e => e.channel === 'rowTint',
3797
+ )
3798
+ if (!encoding) {
2223
3799
  return undefined
2224
3800
  }
2225
- const gaps = colStats.gapCount(mouseCol)
2226
- const distribution = colStats
2227
- .residueEntries(mouseCol)
2228
- .sort((a, b) => b[1] - a[1])
2229
- const consensus = distribution[0]
2230
- return {
2231
- col: mouseCol,
2232
- total,
2233
- gaps,
2234
- gapFraction: gaps / total,
2235
- conservation: self.conservation[mouseCol] ?? 0,
2236
- propertyConservation: self.propertyConservation[mouseCol],
2237
- consensusLetter: consensus?.[0] ?? '',
2238
- consensusCount: consensus?.[1] ?? 0,
2239
- consensusFraction: consensus ? consensus[1] / total : 0,
2240
- distribution,
2241
- }
3801
+ return self.rowNames.map(name => {
3802
+ const value = self.rowDataOf(name)?.[encoding.field]
3803
+ const color =
3804
+ value === undefined ? undefined : encoding.colorOf(value)
3805
+ if (!color) {
3806
+ return undefined
3807
+ }
3808
+ return withAlpha(color, rowTintAlpha)
3809
+ })
2242
3810
  },
2243
3811
 
2244
3812
  /**
2245
- * #method
3813
+ * #getter
3814
+ * the color the `branch` channel gives each tree edge, by the node id at
3815
+ * the edge's far end, or undefined when no encoding names the channel. A
3816
+ * node takes the field value its tips agree on, so a clade of one value
3817
+ * colors down from where it splits off, and a node whose tips disagree or
3818
+ * whose value has no color is absent and draws in the default color.
3819
+ *
3820
+ * The pass runs over the whole tree, never `root`, so a collapsed or
3821
+ * focused clade keeps the color the full tree gives it.
2246
3822
  */
2247
- getRowData(name: string) {
2248
- return {
2249
- data: self.MSA?.getRowData(name),
2250
- treeMetadata: self.treeMetadata[name],
3823
+ get branchColors(): Map<string, string> | undefined {
3824
+ const encoding = this.resolvedEncodings.find(
3825
+ e => e.channel === 'branch',
3826
+ )
3827
+ if (!encoding) {
3828
+ return undefined
3829
+ }
3830
+ const order = preorder(self.tree)
3831
+ const values = new Map<NodeWithIds, string | undefined>()
3832
+ const colors = new Map<string, string>()
3833
+ for (let i = order.length - 1; i >= 0; i--) {
3834
+ const node = order[i]!
3835
+ const value =
3836
+ node.children.length > 0
3837
+ ? sharedValue(node.children.map(child => values.get(child)))
3838
+ : self.rowDataOf(node.name)?.[encoding.field]
3839
+ values.set(node, value)
3840
+ const color =
3841
+ value === undefined ? undefined : encoding.colorOf(value)
3842
+ if (color) {
3843
+ colors.set(node.id, color)
3844
+ }
2251
3845
  }
3846
+ return colors
2252
3847
  },
2253
3848
  }))
2254
3849
  .actions(self => ({
@@ -2261,23 +3856,39 @@ function stateModelFactory() {
2261
3856
  /**
2262
3857
  * #action
2263
3858
  */
2264
- setConservationTrackHeight(arg: number) {
2265
- self.conservationTrackHeight = arg
3859
+ setHideHeader(arg: boolean) {
3860
+ self.hideHeader = arg
3861
+ },
3862
+ /**
3863
+ * #action
3864
+ * focus the subtree a click `y` pixels down the tree overview lands on.
3865
+ * A click inside the box already drawn there clears the focus, the way
3866
+ * clicking the focused branch again does.
3867
+ */
3868
+ treeOverviewClick(y: number) {
3869
+ const hit = self.treeOverviewHit(y)
3870
+ const focus = self.treeOverviewFocusRows
3871
+ const inside =
3872
+ !!focus && !!hit && hit.rows[0] >= focus[0] && hit.rows[1] <= focus[1]
3873
+ self.setShowOnly(inside || !hit ? undefined : hit.id)
2266
3874
  },
2267
3875
  /**
2268
3876
  * #action
3877
+ * resize every track sharing a `heightKey`; see `trackHeights`
2269
3878
  */
2270
- setSequenceLogoTrackHeight(arg: number) {
2271
- self.sequenceLogoTrackHeight = arg
3879
+ setTrackHeight(heightKey: string, height: number) {
3880
+ self.trackHeights.set(heightKey, height)
2272
3881
  },
2273
3882
  /**
2274
3883
  * #action
2275
- * Return to the import form: every property off `preservedOnReset`
2276
- * (data, filehandles, collapsed/showOnly, zoom, scroll, ...) goes back
2277
- * to its default, then the file-derived volatiles applySnapshot cannot
2278
- * reach are cleared by hand.
3884
+ * Return to the import form: reset every property not in
3885
+ * `preservedOnReset` to its default, then clear the file-derived
3886
+ * volatiles applySnapshot does not touch.
2279
3887
  */
2280
3888
  reset() {
3889
+ self.resetCount++
3890
+ self.clearWarnings()
3891
+ self.setStatus(undefined)
2281
3892
  applySnapshot(
2282
3893
  self,
2283
3894
  Object.fromEntries(
@@ -2289,6 +3900,7 @@ function stateModelFactory() {
2289
3900
  self.setError(undefined)
2290
3901
  self.setAnnotations([])
2291
3902
  self.setHighlightedColumns(undefined)
3903
+ self.transientHighlights = {}
2292
3904
  self.setMousePos(undefined, undefined)
2293
3905
  self.setMouseClickPos(undefined, undefined)
2294
3906
  self.setHoveredTreeNode(undefined)
@@ -2302,21 +3914,31 @@ function stateModelFactory() {
2302
3914
  const blob = new Blob([html], { type: 'image/svg+xml' })
2303
3915
  saveAs(blob, exportFileName(self.msaFilehandle, 'svg'))
2304
3916
  },
2305
- initFilter(arg: string) {
2306
- if (!self.featureFilters.has(arg)) {
2307
- self.featureFilters.set(arg, true)
3917
+ /**
3918
+ * #action
3919
+ * show or hide an annotation type. Only hidden types are recorded; see
3920
+ * `turnedOffFeatures`
3921
+ */
3922
+ setFilter(accession: string, shown: boolean) {
3923
+ if (shown) {
3924
+ self.turnedOffFeatures.delete(accession)
3925
+ } else {
3926
+ self.turnedOffFeatures.set(accession, true)
2308
3927
  }
2309
3928
  },
2310
- setFilter(arg: string, flag: boolean) {
2311
- self.featureFilters.set(arg, flag)
2312
- },
2313
3929
 
2314
3930
  /**
2315
3931
  * #action
2316
3932
  */
2317
3933
  fit() {
2318
- this.fitVertically()
2319
- this.fitHorizontally()
3934
+ // two passes: each direction's viewport depends on whether the other
3935
+ // overflows (the minimap takes height, the vertical scrollbar width)
3936
+ transaction(() => {
3937
+ for (let pass = 0; pass < 2; pass++) {
3938
+ this.fitHorizontally()
3939
+ this.fitVertically()
3940
+ }
3941
+ })
2320
3942
  },
2321
3943
  /**
2322
3944
  * #action
@@ -2331,14 +3953,36 @@ function stateModelFactory() {
2331
3953
  }
2332
3954
  self.scrollY = 0
2333
3955
  },
3956
+ /**
3957
+ * #action
3958
+ * zoom and scroll so a span fills the alignment's width, in highlight
3959
+ * coordinates (see visibleSpan). Does nothing before the viewer knows
3960
+ * its width, or for a span that resolves to no visible column.
3961
+ */
3962
+ zoomToRegion(region: Region) {
3963
+ const span = self.visibleSpan(region)
3964
+ if (!span || !self.viewInitialized) {
3965
+ return
3966
+ }
3967
+ transaction(() => {
3968
+ self.colWidth = clamp(
3969
+ self.msaCanvasWidth / (span.endCol - span.startCol + 1),
3970
+ minColWidth,
3971
+ maxCellSize,
3972
+ )
3973
+ self.scrollX = clamp(
3974
+ -span.startCol * self.colWidth,
3975
+ self.maxScrollX,
3976
+ 0,
3977
+ )
3978
+ })
3979
+ },
2334
3980
  /**
2335
3981
  * #action
2336
3982
  */
2337
3983
  fitHorizontally() {
2338
3984
  if (self.numColumns > 0) {
2339
- // fitting to msaAreaWidth instead left the last ~20px of columns off
2340
- // the right edge -- and short of the width that shows a minimap, so
2341
- // nothing on screen said they were there
3985
+ // msaCanvasWidth excludes the vertical scrollbar's 20px
2342
3986
  self.colWidth = clamp(
2343
3987
  self.msaCanvasWidth / self.numColumns,
2344
3988
  minColWidth,
@@ -2349,26 +3993,43 @@ function stateModelFactory() {
2349
3993
  },
2350
3994
 
2351
3995
  afterCreate() {
2352
- // seed the highlighted-columns overlay from the declarative property so
2353
- // a shared snapshot/URL opens with those columns highlighted (the
2354
- // volatile highlightedColumns can later be driven by genome-hover sync)
2355
3996
  if (self.highlightColumns?.length) {
2356
3997
  self.setHighlightedColumns(self.highlightColumns)
2357
3998
  }
2358
3999
 
4000
+ // The `collapse` and `focus` clade marks seed the collapsed list and
4001
+ // the subtree in focus, which the tree, `hideGapsEffective` and the
4002
+ // alignment all read. The tree arrives with the model for inline data
4003
+ // and later for a filehandle, so the seeding waits for it and then runs
4004
+ // once: expanding a seeded clade sticks, and the record collapses it
4005
+ // again only on reload.
4006
+ let cladesSeeded = false
2359
4007
  addDisposer(
2360
4008
  self,
2361
4009
  autorun(() => {
2362
- for (const key of self.annotationTypes.keys()) {
2363
- this.initFilter(key)
4010
+ if (
4011
+ cladesSeeded ||
4012
+ !self.dataInitialized ||
4013
+ self.clades.length === 0
4014
+ ) {
4015
+ return
4016
+ }
4017
+ cladesSeeded = true
4018
+ for (const { mark, nodeId } of self.resolvedClades) {
4019
+ if (nodeId === undefined) {
4020
+ continue
4021
+ }
4022
+ if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
4023
+ self.toggleCollapsed(nodeId)
4024
+ } else if (mark === 'focus') {
4025
+ self.setShowOnly(nodeId)
4026
+ }
2364
4027
  }
2365
4028
  }),
2366
4029
  )
2367
4030
 
2368
- // track the live device pixel ratio so canvas backing stores re-scale
2369
- // when the window moves between monitors or the browser zooms. The
2370
- // matchMedia query is pinned to the current ratio, so each change
2371
- // re-registers against the new one to keep tracking further moves.
4031
+ // the matchMedia query is pinned to the current device pixel ratio, so
4032
+ // each change re-registers against the new one
2372
4033
  if (
2373
4034
  typeof window !== 'undefined' &&
2374
4035
  typeof window.matchMedia === 'function'
@@ -2392,44 +4053,57 @@ function stateModelFactory() {
2392
4053
  * Fetch a filehandle whenever it changes, and hand the text to
2393
4054
  * `onLoad`.
2394
4055
  *
2395
- * Every loader carries a generation guard: the filehandle can change
2396
- * mid-fetch (a second file picked while the first is still in flight),
2397
- * and the slower earlier request must not clobber the data, status, or
2398
- * loading flag belonging to the newer one.
4056
+ * A generation guard keeps a slower superseded request from overwriting
4057
+ * the newer one's data, status or loading flag. A superseded or cleared
4058
+ * request is aborted and clears its status line.
2399
4059
  *
2400
- * `clearFilehandle` serves two purposes for the loaders that pass it.
2401
- * A local file has no URL to refetch from, so the handle is dropped
2402
- * once its bytes are in the model; and a fetch the user cancels drops
2403
- * it too, returning the view to the import form rather than leaving a
2404
- * stuck spinner.
4060
+ * `clearFilehandle` runs after a local file loads, since a blob has no
4061
+ * URL to refetch, and when the user cancels, which returns the view to
4062
+ * the import form.
4063
+ *
4064
+ * `what` names the layer in a failure message. An `optional` layer
4065
+ * (annotations, row metadata) that fails adds a warning instead of an
4066
+ * error.
2405
4067
  */
2406
4068
  const loadOnFilehandleChange = ({
4069
+ what,
2407
4070
  getFilehandle,
2408
4071
  onLoad,
2409
4072
  setLoading,
2410
4073
  clearFilehandle,
4074
+ optional = false,
2411
4075
  }: {
4076
+ what: string
2412
4077
  getFilehandle: () => FileLocationType | undefined
2413
4078
  onLoad: (text: string) => void
2414
4079
  setLoading?: (arg: boolean) => void
2415
4080
  clearFilehandle?: () => void
4081
+ optional?: boolean
2416
4082
  }) => {
2417
4083
  let generation = 0
4084
+ let cancelInFlight: (() => void) | undefined
4085
+ addDisposer(self, () => {
4086
+ cancelInFlight?.()
4087
+ })
2418
4088
  addDisposer(
2419
4089
  self,
2420
4090
  autorun(async () => {
2421
4091
  const filehandle = getFilehandle()
2422
- // a cleared filehandle bumps the generation too, so a reset()
2423
- // mid-download invalidates the in-flight fetch instead of letting
2424
- // its onLoad land on the emptied model. That also orphans the
2425
- // invalidated run's `finally`, so the loading flag is cleared
2426
- // here on its behalf
2427
4092
  const current = ++generation
4093
+ cancelInFlight?.()
4094
+ cancelInFlight = undefined
2428
4095
  if (!filehandle) {
2429
4096
  setLoading?.(false)
2430
4097
  return
2431
4098
  }
2432
- const isCurrent = () => current === generation
4099
+ const isCurrent = () => current === generation && isAlive(self)
4100
+ const controller = new AbortController()
4101
+ cancelInFlight = () => {
4102
+ controller.abort()
4103
+ if (isAlive(self)) {
4104
+ self.setStatus(undefined)
4105
+ }
4106
+ }
2433
4107
  try {
2434
4108
  setLoading?.(true)
2435
4109
  self.setError(undefined)
@@ -2440,6 +4114,7 @@ function stateModelFactory() {
2440
4114
  self.setStatus(status)
2441
4115
  }
2442
4116
  },
4117
+ { controller },
2443
4118
  )
2444
4119
  if (isCurrent()) {
2445
4120
  transaction(() => {
@@ -2455,11 +4130,16 @@ function stateModelFactory() {
2455
4130
  clearFilehandle?.()
2456
4131
  } else {
2457
4132
  console.error(e)
2458
- self.setError(e)
4133
+ if (optional) {
4134
+ self.addWarning(`The ${what} did not load: ${e}`)
4135
+ } else {
4136
+ self.setError(e)
4137
+ }
2459
4138
  }
2460
4139
  }
2461
4140
  } finally {
2462
4141
  if (isCurrent()) {
4142
+ cancelInFlight = undefined
2463
4143
  setLoading?.(false)
2464
4144
  }
2465
4145
  }
@@ -2468,6 +4148,7 @@ function stateModelFactory() {
2468
4148
  }
2469
4149
 
2470
4150
  loadOnFilehandleChange({
4151
+ what: 'tree',
2471
4152
  getFilehandle: () => self.treeFilehandle,
2472
4153
  onLoad: text => {
2473
4154
  self.setTree(text)
@@ -2480,16 +4161,19 @@ function stateModelFactory() {
2480
4161
  },
2481
4162
  })
2482
4163
 
2483
- // treeMetadata is decorative and has no import-form step of its own, so
2484
- // it keeps no loading flag and nothing to return to on cancel
4164
+ // treeMetadata has no import-form step, so no loading flag or cancel
2485
4165
  loadOnFilehandleChange({
4166
+ what: 'row metadata',
4167
+ optional: true,
2486
4168
  getFilehandle: () => self.treeMetadataFilehandle,
2487
4169
  onLoad: text => {
2488
4170
  self.setTreeMetadata(text)
2489
4171
  },
2490
4172
  })
2491
4173
 
2492
- // autorun parses inline gff text from data.gff
4174
+ // parses data.gff into annotations. Clearing the text clears only the
4175
+ // annotations this autorun applied, not ones a host set directly
4176
+ let appliedGFF = false
2493
4177
  addDisposer(
2494
4178
  self,
2495
4179
  autorun(() => {
@@ -2497,19 +4181,25 @@ function stateModelFactory() {
2497
4181
  if (gffText) {
2498
4182
  try {
2499
4183
  self.applyGFFText(gffText)
4184
+ appliedGFF = true
2500
4185
  } catch (e) {
2501
4186
  console.error(e)
2502
- self.setError(e)
4187
+ self.addWarning(`The annotations did not parse: ${e}`)
2503
4188
  }
4189
+ } else if (appliedGFF) {
4190
+ appliedGFF = false
4191
+ self.setAnnotations([])
2504
4192
  }
2505
4193
  }),
2506
4194
  )
2507
4195
 
2508
- // gffFilehandle carries overlay annotations
4196
+ // gffFilehandle loads into data.gff, which the autorun above parses
2509
4197
  loadOnFilehandleChange({
4198
+ what: 'annotations',
4199
+ optional: true,
2510
4200
  getFilehandle: () => self.gffFilehandle,
2511
4201
  onLoad: text => {
2512
- self.applyGFFText(text)
4202
+ self.setGFF(text)
2513
4203
  },
2514
4204
  clearFilehandle: () => {
2515
4205
  self.setGFFFilehandle(undefined)
@@ -2517,6 +4207,7 @@ function stateModelFactory() {
2517
4207
  })
2518
4208
 
2519
4209
  loadOnFilehandleChange({
4210
+ what: 'alignment',
2520
4211
  getFilehandle: () => self.msaFilehandle,
2521
4212
  onLoad: text => {
2522
4213
  self.setMSA(text)
@@ -2529,19 +4220,12 @@ function stateModelFactory() {
2529
4220
  },
2530
4221
  })
2531
4222
 
2532
- // Keep the parse chain warm: reading self.columns transitively holds
2533
- // self.MSA (parseMSA) computed alive, so it is parsed once per data
2534
- // change rather than re-parsed on every non-reactive access. Do not
2535
- // remove.
4223
+ // Keep computeds alive that are read outside reactions, so they are not
4224
+ // recomputed on every access: self.columns (and through it the
4225
+ // parseMSA result), and the column statistics that dynamic color
4226
+ // schemes and the mousemove-driven hover tooltip read.
2536
4227
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
2537
4228
  // xref problem https://github.com/GMOD/react-msaview/issues/75
2538
- //
2539
- // The column statistics are held for the same reason whenever something
2540
- // reads them off the reactive path: dynamic color schemes, and the hover
2541
- // tooltip, which is read from a mousemove handler. Without this the
2542
- // tooltip's cost depends on whether some visible track happens to be
2543
- // observing them, so closing the conservation track would silently turn
2544
- // every mouse move into a full-alignment recount.
2545
4229
  addDisposer(
2546
4230
  self,
2547
4231
  autorun(() => {
@@ -2563,10 +4247,9 @@ function stateModelFactory() {
2563
4247
  }),
2564
4248
  )
2565
4249
 
2566
- // autorun: when autoTreeAreaWidth is set and no tree is drawn, shrink the
2567
- // tree area to fit the row labels rather than padding it to the fixed
2568
- // default. Gated on noTree/!drawTree so it never fights the treeWidth sync
2569
- // below (treeAreaWidth here depends only on labelsWidth, not treeWidth).
4250
+ // with autoTreeAreaWidth and no tree drawn, size the tree area to the
4251
+ // labels. Gated on noTree/!drawTree so it does not loop with the
4252
+ // treeWidth autorun below
2570
4253
  addDisposer(
2571
4254
  self,
2572
4255
  autorun(() => {
@@ -2575,19 +4258,36 @@ function stateModelFactory() {
2575
4258
  (self.noTree || !self.drawTree) &&
2576
4259
  self.labelsWidth
2577
4260
  ) {
2578
- self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12)
4261
+ self.setTreeAreaWidth(
4262
+ self.labelsWidth + self.marginLeft + 12 + self.cladeGutterWidth,
4263
+ )
2579
4264
  }
2580
4265
  }),
2581
4266
  )
2582
4267
 
2583
- // autorun synchronizes treeWidth with treeAreaWidth
4268
+ // treeWidth follows the tree area less the labels. A non-default
4269
+ // snapshot treeWidth (jbrowse-plugin-msaview opens at 100 in a 200px
4270
+ // area) is kept until the tree area changes. Not a getter, because
4271
+ // labelsWidth is measured off leaves laid out against treeWidth.
4272
+ let pinnedAreaWidth =
4273
+ self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth
2584
4274
  addDisposer(
2585
4275
  self,
2586
4276
  autorun(() => {
4277
+ const areaWidth = self.treeAreaWidth
4278
+ const labelsWidth = self.labelsWidth
4279
+ if (pinnedAreaWidth === areaWidth) {
4280
+ return
4281
+ }
4282
+ pinnedAreaWidth = undefined
2587
4283
  self.setTreeWidth(
2588
4284
  Math.max(
2589
4285
  50,
2590
- self.treeAreaWidth - self.labelsWidth - 10 - self.marginLeft,
4286
+ areaWidth -
4287
+ labelsWidth -
4288
+ 10 -
4289
+ self.marginLeft -
4290
+ self.cladeGutterWidth,
2591
4291
  ),
2592
4292
  )
2593
4293
  }),
@@ -2595,9 +4295,8 @@ function stateModelFactory() {
2595
4295
  },
2596
4296
  }))
2597
4297
  .postProcessSnapshot(({ data, columnTracks, ...rest }) => ({
2598
- // per-property defaults are stripped by the stripDefault helper; the only thing
2599
- // it can't express is this cross-field rule: drop inline tree/msa/metadata
2600
- // when a sibling filehandle can refetch them, keeping sessions/URLs small
4298
+ // stripDefault handles per-property defaults; this drops inline documents
4299
+ // whose filehandle can refetch them
2601
4300
  ...rest,
2602
4301
  ...smallColumnTracks(columnTracks),
2603
4302
  data: {