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/dist/model.js CHANGED
@@ -1,21 +1,26 @@
1
1
  import { clamp, groupBy, notEmpty, sum } from '@jbrowse/core/util';
2
2
  import { openLocation } from '@jbrowse/core/util/io';
3
3
  import { ElementId, FileLocation } from '@jbrowse/core/util/types/mst';
4
- import { addDisposer, applySnapshot, cast, getSnapshot, types, } from '@jbrowse/mobx-state-tree';
5
- import { colord } from 'colord';
4
+ import { addDisposer, applySnapshot, cast, getSnapshot, isAlive, types, } from '@jbrowse/mobx-state-tree';
6
5
  import { autorun, transaction } from 'mobx';
7
6
  import { generateNodeIds, gffToAnnotations, interProScanToAnnotations, parseEmfTree, parseGFF, parseMSA, parseNewick, } from 'msa-parsers';
8
7
  import { calculateBlocks } from './calculateBlocks.js';
9
8
  import { clustalXColumnColors } from './clustalX.js';
10
9
  import colorSchemes from './colorSchemes.js';
11
10
  import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.js';
11
+ import { columnStats } from './columnStats.js';
12
+ import { packDomainLanes } from './components/msa/packDomainLanes.js';
13
+ import { visibleColRange } from './components/msa/visibleColRange.js';
12
14
  import TrackBlocks from './components/tracks/TrackBlocks.js';
13
- import { defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultShowDomainLegend, defaultShowDomains, defaultSubFeatureRows, labelReferenceFontSize, maxCellSize, minColWidth, minLetterColWidth, minLetterRowHeight, minRowHeight, segmentFeatureTypes, segmentShades, } from './constants.js';
15
+ import { cladeGutterWidth } from './components/tree/cladeBrackets.js';
16
+ import { scaleBarLength } from './components/tree/scaleBar.js';
17
+ import { cladeHighlightAlpha, cladeHighlightColor, defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultFeaturePanelWidth, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultScrollZoomAxis, defaultShowDomainLegend, defaultShowDomains, defaultSubFeatureRows, defaultTreeWidth, labelReferenceFontSize, maxCellSize, maxInlineSnapshotBytes, maxNeighborJoiningRows, minColWidth, minLetterColWidth, minLetterRowHeight, minRowHeight, rowPanelHeaderHeight, rowTintAlpha, scrollZoomAxes, segmentFeatureTypes, segmentShades, treeScaleBarHeight, } from './constants.js';
14
18
  import { createPaletteMap } from './createPaletteMap.js';
15
19
  import { exportFileName } from './exportFileName.js';
20
+ import { featureField, featureName } from './featureFields.js';
16
21
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.js';
17
22
  import { flatToTree } from './flatToTree.js';
18
- import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find, findMaxBranchLen, forEachDescendant, hierarchy, leaves, links, maxLength, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
23
+ import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find, forEachDescendant, hierarchy, leafIndex, leaves, maxLength, mrca, nodeCoveringRows, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
19
24
  import { measureTextCanvas } from './measureTextCanvas.js';
20
25
  import { DataModelF } from './model/DataModel.js';
21
26
  import { DialogQueueSessionMixin } from './model/DialogQueue.js';
@@ -24,44 +29,176 @@ import { TreeModelF } from './model/treeModel.js';
24
29
  import { calculateNeighborJoiningTree } from './neighborJoining.js';
25
30
  import { parseAsn1 } from './parseAsn1.js';
26
31
  import { calculatePropertyConservation } from './propertyConservation.js';
27
- import { globalColToVisibleCol, visibleColToGlobalCol, visibleColToSeqPosForRow, visibleColsBefore, } from './rowCoordinateCalculations.js';
32
+ import { globalColToVisibleCol, seqPosOfGlobalCol, visibleColToGlobalCol, visibleColsBefore, } from './rowCoordinateCalculations.js';
33
+ import { resolveScale } from './scales.js';
28
34
  import { buildSeqPosIndex } from './seqPosToGlobalCol.js';
29
35
  import { maxBitsFor } from './sequenceLogo.js';
30
36
  import { stripDefault } from './stripDefault.js';
31
- import { computeRowInsertions, dropBlanks, len, skipBlanks, transform, } from './util.js';
37
+ import { computeRowInsertions, dropBlanks, len, outlineColor, skipBlanks, transform, withAlpha, } from './util.js';
32
38
  import { saveAs } from './vendor/fileSaver.js';
39
+ import { parseWuss } from './wuss.js';
33
40
  function parseTreeText(text) {
34
41
  if (text.startsWith('BioTreeContainer')) {
35
42
  return flatToTree(parseAsn1(text));
36
43
  }
37
44
  return parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text);
38
45
  }
39
- // Tracks that start hidden. The sequence logo answers a narrower question than
40
- // conservation does and costs three times the vertical space, so it waits to be
41
- // asked for.
42
- const defaultOffTracks = new Set(['sequence-logo']);
46
+ // The height each kind draws at before the user drags a divider. A text track
47
+ // is absent: it is one alignment row tall and follows rowHeight, so the zoom
48
+ // controls already size it.
49
+ export const defaultTrackHeights = {
50
+ bar: 40,
51
+ // taller than a bar track: in a 40px stack of four residues each glyph is
52
+ // too short to identify. Twice the height of the tracks above it, so it
53
+ // starts hidden
54
+ logo: 80,
55
+ arc: 50,
56
+ ruler: 20,
57
+ };
58
+ // the kinds a divider resizes. The ruler is a fixed scale, and a text track
59
+ // follows the vertical zoom.
60
+ const resizableKinds = new Set(['bar', 'logo', 'arc']);
61
+ const defaultOffTracks = new Set(['sequence-logo', 'position-ruler']);
62
+ // a data track carries its own height rather than its kind's, so its divider
63
+ // resizes it alone
64
+ const ownHeightKey = (id) => `own:${id}`;
65
+ // base-pair arcs: one color for nested helices, one for pseudoknots, which cross
66
+ // them
67
+ const HELIX_ARC = '#4e79a7';
68
+ const PSEUDOKNOT_ARC = '#e15759';
69
+ // the fill of a span under a scale that gives its value no color, such as a
70
+ // gene a `map` over gene names leaves out
71
+ const UNSCALED_FEATURE = '#d9d9d9';
72
+ // the fraction of a span a following one may cover before it takes a lane of
73
+ // its own in a `features` panel
74
+ const laneOverlap = 0.1;
75
+ // a row panel's own width, or a default: the row height, which makes a strip
76
+ // cell square, and a gene neighborhood's width for a features panel
77
+ function rowPanelWidth(panel, rowHeight) {
78
+ return (panel.width ??
79
+ (panel.kind === 'features' ? defaultFeaturePanelWidth : rowHeight));
80
+ }
81
+ // the fill and outline of every feature in a list: its own GFF `color=` first,
82
+ // then the scale an encoding resolves over one of its fields, then the
83
+ // accession palette
84
+ function featureColorMap(annotations, encoding, fillPalette) {
85
+ const strokes = new Map();
86
+ const strokeOf = (fill) => {
87
+ const hit = strokes.get(fill);
88
+ if (hit !== undefined) {
89
+ return hit;
90
+ }
91
+ const stroke = outlineColor(fill);
92
+ strokes.set(fill, stroke);
93
+ return stroke;
94
+ };
95
+ return new Map(annotations.map(annotation => {
96
+ const value = encoding
97
+ ? featureField(annotation, encoding.field)
98
+ : undefined;
99
+ const fill = annotation.color ??
100
+ (value === undefined ? undefined : encoding.colorOf(value)) ??
101
+ (encoding ? UNSCALED_FEATURE : fillPalette[annotation.accession]);
102
+ return [annotation, { fill, stroke: strokeOf(fill) }];
103
+ }));
104
+ }
105
+ // the text a label channel draws inside each span, for the features carrying
106
+ // the field it names
107
+ function featureLabelMap(annotations, field) {
108
+ const labels = new Map();
109
+ for (const annotation of annotations) {
110
+ const value = featureField(annotation, field);
111
+ if (value !== undefined) {
112
+ labels.set(annotation, value);
113
+ }
114
+ }
115
+ return labels;
116
+ }
117
+ /**
118
+ * The spans a `features` panel draws, keyed by row name and measured in the
119
+ * panel's own pixels. `column` takes the bands the overlay draws, at the
120
+ * alignment's column width. `position` packs each row's features in its own
121
+ * residue positions, shifted by an `align` transform, and maps the extent they
122
+ * cover across every row onto the panel width, so a row with no alignment
123
+ * still has an x.
124
+ */
125
+ function featurePanelSpans({ panel, width, rowHeight, colWidth, domainBands, annotationsByRow, shifts, }) {
126
+ if (panel.x === 'column') {
127
+ return new Map([...domainBands].map(([name, bands]) => [
128
+ name,
129
+ bands.map(band => ({
130
+ ...band,
131
+ xStart: band.startCol * colWidth,
132
+ xEnd: band.endCol * colWidth,
133
+ })),
134
+ ]));
135
+ }
136
+ const shifted = Object.entries(annotationsByRow).map(([name, annotations]) => {
137
+ const shift = shifts?.get(name) ?? 0;
138
+ return [
139
+ name,
140
+ annotations.map(annotation => ({
141
+ annotation,
142
+ start: annotation.start - 1 + shift,
143
+ end: annotation.end + shift,
144
+ })),
145
+ ];
146
+ });
147
+ let min = Infinity;
148
+ let max = -Infinity;
149
+ for (const [, features] of shifted) {
150
+ for (const { start, end } of features) {
151
+ min = Math.min(min, start);
152
+ max = Math.max(max, end);
153
+ }
154
+ }
155
+ // the arrowhead on the rightmost feature reaches a row height past its end,
156
+ // so the extent maps onto the panel less that much
157
+ const drawable = Math.max(1, width - rowHeight);
158
+ const scale = max > min ? drawable / (max - min) : 0;
159
+ // a lane opens where a span covers more than a tenth of the one before it,
160
+ // which keeps the genes of an operon on one lane: adjacent genes commonly
161
+ // share a few bases, and a stop codon overlapping the next start reads as a
162
+ // second lane over the whole row
163
+ return new Map(shifted.map(([name, features]) => [
164
+ name,
165
+ packDomainLanes(features.map(({ annotation, start, end }) => ({
166
+ annotation,
167
+ xStart: (start - min) * scale,
168
+ xEnd: (end - min) * scale,
169
+ startCol: start,
170
+ endCol: end - (end - start) * laneOverlap,
171
+ }))),
172
+ ]));
173
+ }
174
+ // a scale over the values a field takes across the features drawn
175
+ function resolveFeatureScale(field, scale, annotations) {
176
+ return {
177
+ field,
178
+ ...resolveScale(scale, annotations.map(a => featureField(a, field)).filter(notEmpty)),
179
+ };
180
+ }
43
181
  // a data track over this size stays in the live model but leaves the snapshot,
44
182
  // the same rule DataModel applies to an inline document
45
- const maxColumnTrackSnapshotBytes = 50_000;
183
+ function columnTrackSizes(tracks) {
184
+ return (tracks ?? []).map(t => JSON.stringify(t).length);
185
+ }
46
186
  function smallColumnTracks(tracks) {
47
- const kept = tracks?.filter(t => JSON.stringify(t).length <= maxColumnTrackSnapshotBytes);
187
+ const sizes = columnTrackSizes(tracks);
188
+ const kept = tracks?.filter((_t, i) => sizes[i] <= maxInlineSnapshotBytes);
48
189
  return kept?.length ? { columnTracks: kept } : {};
49
190
  }
50
191
  /**
51
192
  * The snapshot properties reset() carries across a return to the import form:
52
193
  * display preferences and layout, nothing derived from the loaded file.
53
194
  *
54
- * reset() applies a default snapshot filtered to this list, so the list is the
55
- * whole decision: a property left off it resets to its default, a visible and
56
- * benign failure. The previous shape — a hand-maintained list of things to
57
- * CLEAR — failed in the dangerous direction: a forgotten property silently
58
- * carried the previous file's state into the next one, and because node ids
59
- * are path-derived (node-0-0-1), a stale `collapsed` or `showOnly` id matched
60
- * a real node in the new tree and folded it. Downstream composed properties
61
- * (e.g. the jbrowse plugin's) are not on the list, so they reset too.
195
+ * reset() applies a default snapshot filtered to this list, so any property
196
+ * left off it resets, including downstream composed ones (e.g. the jbrowse
197
+ * plugin's). Node ids are path-derived (node-0-0-1), so a carried-over
198
+ * `collapsed` or `showOnly` id would match a real node in the next tree and
199
+ * fold it.
62
200
  *
63
- * Exported for modelReset.test.ts, which checks that everything off this list
64
- * matches a freshly created model after reset().
201
+ * Exported for modelReset.test.ts.
65
202
  */
66
203
  export const preservedOnReset = new Set([
67
204
  'id',
@@ -69,6 +206,7 @@ export const preservedOnReset = new Set([
69
206
  'height',
70
207
  'drawMsaLetters',
71
208
  'scrollZoom',
209
+ 'scrollZoomAxis',
72
210
  'bgColor',
73
211
  'colorSchemeName',
74
212
  'showColumnStats',
@@ -79,19 +217,166 @@ export const preservedOnReset = new Set([
79
217
  'showBranchLen',
80
218
  'drawTree',
81
219
  'drawNodeBubbles',
220
+ 'drawNodeLabels',
221
+ 'showTreeOverview',
222
+ 'overviewHeight',
82
223
  'autoTreeAreaWidth',
83
224
  'turnedOffTracks',
225
+ 'trackHeights',
84
226
  'hideGaps',
85
227
  'allowedGappyness',
86
228
  'subFeatureRows',
87
229
  'showDomainLegend',
88
230
  ]);
89
- // `turnedOffTracks` records the user's explicit choices only: an id is absent
90
- // until they touch that track, and then its value is whether the track is OFF.
91
- // Reading the default through this is what lets a track ship hidden without
92
- // writing an entry into every snapshot and shared URL.
93
- function trackIsOff(turnedOffTracks, id) {
94
- return turnedOffTracks.get(id) ?? defaultOffTracks.has(id);
231
+ // `turnedOffTracks` holds only the user's explicit choices, so a hidden-by-default
232
+ // track writes nothing into the snapshot. A file-supplied track passes its own
233
+ // `defaultOff`, since the count varies by file: a Pfam seed has a couple of #=GR
234
+ // lines, an Rfam family one per row.
235
+ function trackIsOff(turnedOffTracks, id, defaultOff) {
236
+ return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id));
237
+ }
238
+ // shared empty results, so observers don't see a fresh [] as a change
239
+ const noDomains = [];
240
+ const noClades = [];
241
+ /**
242
+ * The rows a clade covers and the node an `mrca` names, or undefined when the
243
+ * clade does not resolve: a tip name the tree does not have or has twice, or a
244
+ * leaf count `tips` disagrees with. `index` and `rowNamesSet` are the memoized
245
+ * passes over the tree.
246
+ */
247
+ function cladeRows(clade, root, index, rowNamesSet) {
248
+ if (clade.range) {
249
+ const [a, b] = clade.range.map(name => index.get(name) ? rowNamesSet.get(name) : undefined);
250
+ if (a === undefined || b === undefined) {
251
+ return undefined;
252
+ }
253
+ const rows = a <= b ? [a, b] : [b, a];
254
+ return rows[1] - rows[0] + 1 === clade.tips ? { rows } : undefined;
255
+ }
256
+ const node = clade.mrca ? mrca(root, clade.mrca, index) : undefined;
257
+ if (!node) {
258
+ return undefined;
259
+ }
260
+ const tips = leaves(node);
261
+ if (tips.length !== clade.tips) {
262
+ return undefined;
263
+ }
264
+ let first = Infinity;
265
+ let last = -Infinity;
266
+ for (const tip of tips) {
267
+ const row = rowNamesSet.get(tip.data.name);
268
+ if (row !== undefined) {
269
+ first = Math.min(first, row);
270
+ last = Math.max(last, row);
271
+ }
272
+ }
273
+ return first <= last
274
+ ? { rows: [first, last], nodeId: node.data.id }
275
+ : undefined;
276
+ }
277
+ /**
278
+ * Every clade that resolves, with its fill color settled, against the leaf
279
+ * order `rowNamesSet` gives. The tree panel resolves against the displayed
280
+ * rows and the overview against its own, so both take the tree they draw. A
281
+ * `range` record names no node, so `collapse` and `focus`, which need one,
282
+ * drop it.
283
+ */
284
+ function resolveClades(clades, root, rowNamesSet) {
285
+ const index = leafIndex(root);
286
+ return clades.flatMap(clade => {
287
+ const resolved = cladeRows(clade, root, index, rowNamesSet);
288
+ const seeding = clade.mark === 'collapse' || clade.mark === 'focus';
289
+ return resolved && !(seeding && resolved.nodeId === undefined)
290
+ ? [
291
+ {
292
+ ...resolved,
293
+ mark: clade.mark,
294
+ color: withAlpha(clade.color ?? cladeHighlightColor, cladeHighlightAlpha),
295
+ markColor: clade.color,
296
+ label: clade.label,
297
+ },
298
+ ]
299
+ : [];
300
+ });
301
+ }
302
+ /**
303
+ * The tree with the display transforms applied: leaf counts summed, children
304
+ * sorted by branch length, `showOnly` taken as the new root, and each
305
+ * `collapsed` clade folded. The overview builds the same tree without the
306
+ * focus, so it shows the focused subtree inside the whole.
307
+ */
308
+ function buildTreeRoot(tree, collapsed, showOnly) {
309
+ let hier = hierarchy(tree, d => d.children);
310
+ hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1));
311
+ sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1));
312
+ if (showOnly) {
313
+ const res = find(hier, n => n.data.id === showOnly);
314
+ if (res) {
315
+ hier = res;
316
+ }
317
+ }
318
+ for (const collapsedId of collapsed) {
319
+ const node = find(hier, n => n.data.id === collapsedId);
320
+ if (node) {
321
+ if (node.children) {
322
+ collapse(node);
323
+ }
324
+ else if (node.parent?.children) {
325
+ node.parent.children = node.parent.children.filter(c => c.data.id !== collapsedId);
326
+ }
327
+ }
328
+ }
329
+ return hier;
330
+ }
331
+ // the inclusive tip indices a node covers, from the row-space extent
332
+ // clusterLayout writes to xMin/xMax as tip centers
333
+ function tipRange(node) {
334
+ return [Math.round(node.xMin - 0.5), Math.round(node.xMax - 0.5)];
335
+ }
336
+ // the channels reading the feature table; every other channel reads rowData
337
+ const featureChannels = new Set([
338
+ 'featureFill',
339
+ 'featureLabel',
340
+ ]);
341
+ // seqPos -> column indexes per row, keyed on the parse so they are garbage
342
+ // collected with it. A computed would rebuild every row's index when read
343
+ // outside a reactive context.
344
+ const seqPosIndexCache = new WeakMap();
345
+ // A segment is a 1:1 run. A segment whose sides differ in length is malformed,
346
+ // and the lookups treat it like an uncovered position.
347
+ function sameLength(segment) {
348
+ return (segment.rowEnd - segment.rowStart ===
349
+ segment.structEnd - segment.structStart);
350
+ }
351
+ // fit() divides the viewport by the row or column count and multiplies back, so
352
+ // an exact fit can land a fraction of a pixel over. A plain `>` would then show
353
+ // a scrollbar that shrinks the viewport and leaves a gap.
354
+ function overflows(content, viewport) {
355
+ return content - viewport > 0.5;
356
+ }
357
+ function inRanges(ranges, position) {
358
+ return !!ranges?.some(([start, end]) => position >= start && position <= end);
359
+ }
360
+ // the value every one of a node's children reports, or undefined where one of
361
+ // them has no value or they disagree
362
+ function sharedValue(values) {
363
+ const [first] = values;
364
+ return first !== undefined && values.every(v => v === first)
365
+ ? first
366
+ : undefined;
367
+ }
368
+ // preorder, so reversing the list puts every node after its descendants
369
+ function preorder(tree) {
370
+ const order = [];
371
+ const stack = [tree];
372
+ while (stack.length > 0) {
373
+ const node = stack.pop();
374
+ order.push(node);
375
+ for (const child of node.children) {
376
+ stack.push(child);
377
+ }
378
+ }
379
+ return order;
95
380
  }
96
381
  /**
97
382
  * #stateModel MsaView
@@ -128,10 +413,8 @@ function stateModelFactory() {
128
413
  /**
129
414
  * #property
130
415
  * whether the domain legend is expanded. The legend floats over the
131
- * top-right of the alignment, so on a tall panel it covers real
132
- * residues -- persisting the state is what lets a reader collapse it
133
- * and keep it collapsed, and what lets a session or a figure open with
134
- * it already out of the way.
416
+ * top-right of the alignment and covers residues, so a session or
417
+ * figure can open with it collapsed.
135
418
  */
136
419
  showDomainLegend: stripDefault(types.boolean, defaultShowDomainLegend),
137
420
  /**
@@ -160,6 +443,11 @@ function stateModelFactory() {
160
443
  * zoom in/out on plain mouse-wheel without holding ctrl
161
444
  */
162
445
  scrollZoom: stripDefault(types.boolean, defaultScrollZoom),
446
+ /**
447
+ * #property
448
+ * which cell dimensions a wheel zoom scales, while `scrollZoom` is on
449
+ */
450
+ scrollZoomAxis: stripDefault(types.enumeration('ScrollZoomAxis', [...scrollZoomAxes]), defaultScrollZoomAxis),
163
451
  /**
164
452
  * #property
165
453
  * height of the div containing the view, px
@@ -231,11 +519,27 @@ function stateModelFactory() {
231
519
  turnedOffTracks: stripDefault(types.map(types.boolean), {}),
232
520
  /**
233
521
  * #property
234
- * tracks supplied as data rather than computed from the alignment:
235
- * per-column values drawn as bars, or a per-column string drawn as a
236
- * text track. See docs/layers.md
522
+ * the height of every track one divider resizes, keyed by `heightKey`:
523
+ * the `kind` for the computed tracks, `own:<id>` for a data track. A
524
+ * key is absent until the user drags that divider, and
525
+ * `defaultTrackHeights` answers for it until then, so an untouched
526
+ * viewer adds nothing to the shared URL.
527
+ */
528
+ trackHeights: stripDefault(types.map(types.number), {}),
529
+ /**
530
+ * #property
531
+ * tracks supplied as data: per-column values drawn as bars, or a
532
+ * per-column string drawn as a text track. See docs/layers.md
237
533
  */
238
534
  columnTracks: stripDefault(types.array(types.frozen()), []),
535
+ /**
536
+ * #property
537
+ * row-to-structure residue correspondence, computed outside the viewer
538
+ * (e.g. from SIFTS). Matching by sequence equality places a tagged
539
+ * construct, a truncation or a subsequence row on the wrong residue.
540
+ * See docs/layers.md
541
+ */
542
+ residueMappings: stripDefault(types.array(types.frozen()), []),
239
543
  /**
240
544
  * #property
241
545
  * data from the loaded tree/msa/treeMetadata, generally loaded by
@@ -248,8 +552,12 @@ function stateModelFactory() {
248
552
  }),
249
553
  /**
250
554
  * #property
555
+ * the user's explicit hide choices per annotation accession, keyed by
556
+ * accession with the value meaning "off", like `turnedOffTracks`. An
557
+ * untouched accession is absent and drawn, so the shared URL grows
558
+ * only with the user's filters
251
559
  */
252
- featureFilters: stripDefault(types.map(types.boolean), {}),
560
+ turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
253
561
  /**
254
562
  * #property
255
563
  */
@@ -268,24 +576,53 @@ function stateModelFactory() {
268
576
  * labeled highlights in 1-based inclusive coordinates: a column span
269
577
  * `{start, end}`, a residue span `{row, start, end}` of a named row,
270
578
  * or a row set `{rows}`, each with an optional `label` and `color`.
271
- * Persists in the snapshot, so a computed answer travels in the URL.
579
+ * Persists in the snapshot and the URL.
272
580
  */
273
581
  highlights: stripDefault(types.array(types.frozen()), []),
582
+ /**
583
+ * #property
584
+ * clades of the tree with a mark drawn over them. `mrca` names tips
585
+ * whose common ancestor is the clade, or `range` its first and last
586
+ * tip in display order, and `tips` is the leaf count the producer
587
+ * measured. `mark` is `highlight`, `bracket`, `collapse` or `focus`.
588
+ * See docs/layers.md
589
+ */
590
+ clades: stripDefault(types.array(types.frozen()), []),
591
+ /**
592
+ * #property
593
+ * what the viewer's own marks read from `rowData`:
594
+ * `{channel, field, scale?}` per channel, where `channel` is
595
+ * `tipLabel` or `rowTint`. See docs/layers.md
596
+ */
597
+ encodings: stripDefault(types.array(types.frozen()), []),
598
+ /**
599
+ * #property
600
+ * panels drawn between the tree and the alignment:
601
+ * `{kind: "strip", field, scale?, width?, header?}` colors one cell
602
+ * per row from a `rowData` field, and
603
+ * `{kind: "features", x, encoding?, transform?, width?, header?}`
604
+ * draws the spans the GFF carries. See docs/layers.md
605
+ */
606
+ rowPanels: stripDefault(types.array(types.frozen()), []),
274
607
  }))
275
608
  .volatile(() => ({
276
609
  /**
277
610
  * #volatile
278
611
  */
279
612
  headerHeight: 0,
613
+ /**
614
+ * #volatile
615
+ * leaves the toolbar out, for a host drawing its own controls. Kept out
616
+ * of the snapshot so a link opened in the full app shows the toolbar.
617
+ */
618
+ hideHeader: false,
280
619
  /**
281
620
  * #volatile
282
621
  */
283
622
  status: undefined,
284
623
  /**
285
624
  * #volatile
286
- * high resolution scale factor, helps make canvas look better on hi-dpi
287
- * screens. derived from the device pixel ratio so canvases are crisp on
288
- * retina/4k displays and not needlessly oversized on standard ones
625
+ * canvas scale factor, from the device pixel ratio
289
626
  */
290
627
  highResScaleFactor: typeof window === 'undefined' ? 1 : window.devicePixelRatio,
291
628
  /**
@@ -340,6 +677,12 @@ function stateModelFactory() {
340
677
  * array of column indices to highlight
341
678
  */
342
679
  highlightedColumns: undefined,
680
+ /**
681
+ * #volatile
682
+ * transient highlights keyed by owner, so a structure viewer's hover and
683
+ * a genome view's hover each clear only their own. Not persisted.
684
+ */
685
+ transientHighlights: {},
343
686
  /**
344
687
  * #volatile
345
688
  */
@@ -347,29 +690,36 @@ function stateModelFactory() {
347
690
  /**
348
691
  * #volatile
349
692
  */
350
- conservationTrackHeight: 40,
693
+ marginLeft: 20,
694
+ /**
695
+ * #volatile
696
+ */
697
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
698
+ error: undefined,
351
699
  /**
352
700
  * #volatile
353
- * taller than the conservation track by default: the logo spends its
354
- * height on stacked glyphs, and a 40px stack of four residues leaves each
355
- * one too short to identify
701
+ * non-fatal load problems: an optional layer that failed to load, an
702
+ * overlay that failed to parse. `error` replaces the view and is for the
703
+ * alignment itself
356
704
  */
357
- sequenceLogoTrackHeight: 80,
705
+ warnings: [],
358
706
  /**
359
707
  * #volatile
708
+ * bumped by reset(). The error boundary above the view uses it as its key,
709
+ * since the boundary keeps its caught error until remounted
360
710
  */
361
- marginLeft: 20,
711
+ resetCount: 0,
362
712
  /**
363
713
  * #volatile
714
+ * set by a host that restores the loaded documents itself, such as a
715
+ * jbrowse session or a page that refetches them. `unshareableData` then
716
+ * reports nothing
364
717
  */
365
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
366
- error: undefined,
718
+ hostCarriesData: false,
367
719
  /**
368
720
  * #volatile
369
- * overlay annotations drawn on the alignment, whatever their source.
370
- * Every source -- InterProScan, GFF, a user upload -- converts to this
371
- * flat list before it reaches the model, so nothing downstream of here
372
- * knows which one it came from
721
+ * overlay annotations drawn on the alignment. InterProScan JSON, GFF and
722
+ * user uploads all convert to this flat list
373
723
  */
374
724
  annotations: [],
375
725
  }))
@@ -415,8 +765,8 @@ function stateModelFactory() {
415
765
  },
416
766
  /**
417
767
  * #action
418
- * high-res scale factor, tracks the device pixel ratio so canvases stay
419
- * crisp when the window moves between monitors or the browser zooms
768
+ * update the canvas scale factor when the device pixel ratio changes
769
+ * (moving between monitors, browser zoom)
420
770
  */
421
771
  setHighResScaleFactor(arg) {
422
772
  self.highResScaleFactor = arg;
@@ -435,13 +785,35 @@ function stateModelFactory() {
435
785
  setError(error) {
436
786
  self.error = error;
437
787
  },
788
+ /**
789
+ * #action
790
+ * record a non-fatal load problem: a layer that failed to load, a file
791
+ * that failed to parse
792
+ */
793
+ addWarning(warning) {
794
+ self.warnings = [...self.warnings, warning];
795
+ },
796
+ /**
797
+ * #action
798
+ */
799
+ clearWarnings() {
800
+ self.warnings = [];
801
+ },
802
+ /**
803
+ * #action
804
+ * declare that this host restores the loaded documents itself, which
805
+ * hides the "Not in the link" warning. See `hostCarriesData`
806
+ */
807
+ setHostCarriesData(arg) {
808
+ self.hostCarriesData = arg;
809
+ },
438
810
  /**
439
811
  * #action
440
812
  * set mouse position (row, column) in the MSA
441
813
  *
442
- * CROSS-REPO CONTRACT: jbrowse-plugin-protein3d calls this (and reads the
443
- * `mouseCol` volatile) to sync MSA<->3D-structure hover. Keep the name and
444
- * signature stable; see that repo's ProteinToMsaHoverSync.tsx.
814
+ * Public API: a host calls this (and reads `mouseCol`) to sync hover with
815
+ * its own view, such as a genome view or 3D structure. Keep the name and
816
+ * signature stable.
445
817
  */
446
818
  setMousePos(col, row) {
447
819
  self.mouseCol = col;
@@ -451,9 +823,9 @@ function stateModelFactory() {
451
823
  * #action
452
824
  * set highlighted columns
453
825
  *
454
- * CROSS-REPO CONTRACT: called by jbrowse-plugin-msaview
455
- * (afterCreateAutoruns.ts) to highlight alignment columns. It has no
456
- * in-repo caller, so do not flag it as dead code — it is public API.
826
+ * Public API: jbrowse-plugin-msaview calls this from its
827
+ * afterCreateAutoruns, and MSAViewer passes its `highlightColumns` prop
828
+ * through it.
457
829
  */
458
830
  setHighlightedColumns(columns) {
459
831
  self.highlightedColumns = columns;
@@ -464,6 +836,34 @@ function stateModelFactory() {
464
836
  setHighlights(highlights) {
465
837
  self.highlights.replace(highlights);
466
838
  },
839
+ /**
840
+ * #action
841
+ * replace the clades the viewer marks (see docs/layers.md)
842
+ */
843
+ setClades(clades) {
844
+ self.clades.replace(clades);
845
+ },
846
+ /**
847
+ * #action
848
+ * show `highlights` for `owner`, replacing that owner's previous ones and
849
+ * leaving other owners' in place
850
+ */
851
+ applyHighlight(owner, highlights) {
852
+ self.transientHighlights = {
853
+ ...self.transientHighlights,
854
+ [owner]: highlights,
855
+ };
856
+ },
857
+ /**
858
+ * #action
859
+ * remove `owner`'s highlights, leaving other owners' in place
860
+ */
861
+ clearHighlight(owner) {
862
+ if (owner in self.transientHighlights) {
863
+ const { [owner]: _dropped, ...rest } = self.transientHighlights;
864
+ self.transientHighlights = rest;
865
+ }
866
+ },
467
867
  /**
468
868
  * #action
469
869
  * toggle the annotation overlay on the alignment
@@ -508,10 +908,22 @@ function stateModelFactory() {
508
908
  },
509
909
  /**
510
910
  * #action
511
- *
911
+ * switch to another alignment of a multi-alignment file (Stockholm).
912
+ * Clears the collapsed node ids, the subtree in focus, the reference row
913
+ * and the scroll position, which all refer to the previous alignment
512
914
  */
513
915
  setCurrentAlignment(n) {
514
- self.currentAlignment = n;
916
+ if (n === self.currentAlignment) {
917
+ return;
918
+ }
919
+ transaction(() => {
920
+ self.currentAlignment = n;
921
+ self.collapsed.clear();
922
+ this.setShowOnly(undefined);
923
+ this.drawRelativeTo(undefined);
924
+ self.scrollX = 0;
925
+ self.scrollY = 0;
926
+ });
515
927
  },
516
928
  /**
517
929
  * #action
@@ -583,6 +995,16 @@ function stateModelFactory() {
583
995
  setTreeMetadata(result) {
584
996
  self.data.setTreeMetadata(result);
585
997
  },
998
+ /**
999
+ * #action
1000
+ * store the GFF text in the snapshot like the alignment and tree. The
1001
+ * parsed annotations are volatile and a blob filehandle is cleared once
1002
+ * read, so the text is the only persisted copy. An autorun parses it
1003
+ * into annotations.
1004
+ */
1005
+ setGFF(result) {
1006
+ self.data.setGFF(result);
1007
+ },
586
1008
  }))
587
1009
  .views(self => ({
588
1010
  /**
@@ -605,6 +1027,20 @@ function stateModelFactory() {
605
1027
  get actuallyShowDomains() {
606
1028
  return self.showDomains && self.annotations.length > 0;
607
1029
  },
1030
+ /**
1031
+ * #getter
1032
+ * whether the host restores the loaded documents outside the snapshot.
1033
+ * When true, `unshareableData` is empty.
1034
+ *
1035
+ * A simple host sets `hostCarriesData`. A host where this depends on how
1036
+ * the view was opened overrides the getter in its own composed model's
1037
+ * `.views` block; jbrowse-plugin-msaview's indexed-location views refetch
1038
+ * from a URL the session holds, while its data-store views do not.
1039
+ * `unshareableData` reads it off `self`, so an override takes effect.
1040
+ */
1041
+ get hostRestoresData() {
1042
+ return self.hostCarriesData;
1043
+ },
608
1044
  get viewInitialized() {
609
1045
  return self.volatileWidth !== undefined;
610
1046
  },
@@ -619,19 +1055,12 @@ function stateModelFactory() {
619
1055
  },
620
1056
  }))
621
1057
  .views(self => ({
622
- /**
623
- * #method
624
- * unused here, but can be used by derived classes to add extra items
625
- */
626
- extraViewMenuItems() {
627
- return [];
628
- },
629
1058
  /**
630
1059
  * #getter
631
1060
  */
632
1061
  get colorScheme() {
633
- // colorSchemeName is a free string (menus, snapshots, URL params); fall
634
- // back to the default rather than returning undefined on a stale name
1062
+ // colorSchemeName is a free string (menus, snapshots, URL params), so a
1063
+ // stale name falls back to the default
635
1064
  return (colorSchemes[self.colorSchemeName] ??
636
1065
  colorSchemes[defaultColorSchemeName]);
637
1066
  },
@@ -656,18 +1085,53 @@ function stateModelFactory() {
656
1085
  get noDomains() {
657
1086
  return self.annotations.length === 0;
658
1087
  },
1088
+ /**
1089
+ * #getter
1090
+ * loaded documents left out of the snapshot, largest first. A file opened
1091
+ * from disk or pasted in becomes inline text, and DataModel drops an
1092
+ * inline document past `maxInlineSnapshotBytes`.
1093
+ *
1094
+ * The header lists these, and the standalone app stops rewriting the
1095
+ * address bar while the list is non-empty, so a copied link does not
1096
+ * open an empty viewer unannounced. A document fetched from a URL never
1097
+ * appears here, since the snapshot keeps its filehandle.
1098
+ *
1099
+ * Empty when `hostRestoresData` is true.
1100
+ */
1101
+ get unshareableData() {
1102
+ if (self.hostRestoresData) {
1103
+ return [];
1104
+ }
1105
+ const { data } = self;
1106
+ // data tracks past the limit also leave the snapshot
1107
+ const trackBytes = columnTrackSizes(self.columnTracks)
1108
+ .filter(bytes => bytes > maxInlineSnapshotBytes)
1109
+ .reduce((a, b) => a + b, 0);
1110
+ return [
1111
+ ['alignment', data.msa, self.msaFilehandle],
1112
+ ['tree', data.tree, self.treeFilehandle],
1113
+ ['annotations', data.gff, self.gffFilehandle],
1114
+ ['row metadata', data.treeMetadata, self.treeMetadataFilehandle],
1115
+ ]
1116
+ .flatMap(([what, text, filehandle]) => !filehandle && text && text.length > maxInlineSnapshotBytes
1117
+ ? [{ what, bytes: text.length }]
1118
+ : [])
1119
+ .concat(trackBytes ? [{ what: 'data tracks', bytes: trackBytes }] : [])
1120
+ .sort((a, b) => b.bytes - a.bytes);
1121
+ },
659
1122
  menuItems() {
660
1123
  return [];
661
1124
  },
662
1125
  /**
663
1126
  * #getter
664
- * extra per-row attributes, keyed by row name. Parsed defensively: the
665
- * source is a user-supplied document (treeMetadataFilehandle, or a
666
- * session snapshot), and this computed is read by labelWidthMap on every
667
- * layout, so a malformed file would otherwise throw out of rendering and
668
- * take the whole view down over a decorative field.
1127
+ * the row table: extra fields per row, keyed by row name, which the
1128
+ * `encodings` channels read. It is stored as the JSON string
1129
+ * `data.treeMetadata`, the name that travels in existing links, so the
1130
+ * inline size limit and `treeMetadataFilehandle` cover it. labelWidthMap
1131
+ * reads it on every layout, so a malformed user-supplied file returns {}
1132
+ * instead of throwing out of rendering.
669
1133
  */
670
- get treeMetadata() {
1134
+ get rowData() {
671
1135
  const text = self.data.treeMetadata;
672
1136
  if (!text) {
673
1137
  return {};
@@ -679,10 +1143,31 @@ function stateModelFactory() {
679
1143
  : {};
680
1144
  }
681
1145
  catch (e) {
682
- console.error('failed to parse treeMetadata', e);
1146
+ console.error('failed to parse rowData', e);
683
1147
  return {};
684
1148
  }
685
1149
  },
1150
+ /**
1151
+ * #method
1152
+ * one row's fields, the single reader of the row table
1153
+ */
1154
+ rowDataOf(name) {
1155
+ return this.rowData[name];
1156
+ },
1157
+ /**
1158
+ * #getter
1159
+ * the field names the row table carries, sorted, for a producer or a UI
1160
+ * choosing one to encode
1161
+ */
1162
+ get rowFields() {
1163
+ const fields = new Set();
1164
+ for (const row of Object.values(this.rowData)) {
1165
+ for (const field of Object.keys(row ?? {})) {
1166
+ fields.add(field);
1167
+ }
1168
+ }
1169
+ return [...fields].sort((a, b) => a.localeCompare(b));
1170
+ },
686
1171
  /**
687
1172
  * #getter
688
1173
  */
@@ -728,6 +1213,33 @@ function stateModelFactory() {
728
1213
  get rowNamesSet() {
729
1214
  return new Map(this.leaves.map((leaf, index) => [leaf.data.name, index]));
730
1215
  },
1216
+ /**
1217
+ * #getter
1218
+ * `clades` resolved to the rows each one covers. The tip names resolve
1219
+ * against `tree` rather than `root`, so a clade whose ancestor the user
1220
+ * collapsed keeps its rows. One leaf pass over the tree serves every
1221
+ * clade. A `range` record names no node, so `collapse` and `focus`, which
1222
+ * need one, drop it.
1223
+ */
1224
+ get resolvedClades() {
1225
+ if (self.clades.length === 0) {
1226
+ return noClades;
1227
+ }
1228
+ return resolveClades(self.clades, hierarchy(this.tree, d => d.children), this.rowNamesSet);
1229
+ },
1230
+ /**
1231
+ * #getter
1232
+ * the pixel column reserved at the right of the tree area for the bracket
1233
+ * mark, which the tip labels and the tree itself stay clear of. Zero
1234
+ * where no clade draws a bar or a label.
1235
+ */
1236
+ get cladeGutterWidth() {
1237
+ return cladeGutterWidth({
1238
+ clades: this.resolvedClades,
1239
+ rowHeight: self.rowHeight,
1240
+ fontSize: this.fontSize,
1241
+ });
1242
+ },
731
1243
  /**
732
1244
  * #getter
733
1245
  */
@@ -760,40 +1272,128 @@ function stateModelFactory() {
760
1272
  * #getter
761
1273
  */
762
1274
  get root() {
763
- let hier = hierarchy(this.tree, d => d.children);
764
- hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1));
765
- sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1));
766
- if (self.showOnly) {
767
- const res = find(hier, n => n.data.id === self.showOnly);
768
- if (res) {
769
- hier = res;
770
- }
1275
+ return buildTreeRoot(this.tree, self.collapsed, self.showOnly);
1276
+ },
1277
+ /**
1278
+ * #getter
1279
+ * height of the band the tree overview draws in, zero when it is off
1280
+ */
1281
+ get treeOverviewHeight() {
1282
+ return self.showTreeOverview ? self.overviewHeight : 0;
1283
+ },
1284
+ /**
1285
+ * #getter
1286
+ * the whole tree laid out for the overview, or undefined when the
1287
+ * overview is off. The focus is left out, so the focused subtree draws
1288
+ * inside the whole tree, and the collapsed clades are folded, since
1289
+ * those are rows the view no longer has. `x` is in tip-index space and
1290
+ * `len` is a fraction of the root-to-tip length, so one layout serves
1291
+ * any band size.
1292
+ */
1293
+ get treeOverviewLayout() {
1294
+ if (!self.showTreeOverview) {
1295
+ return undefined;
771
1296
  }
772
- for (const collapsedId of self.collapsed) {
773
- const node = find(hier, n => n.data.id === collapsedId);
774
- if (node) {
775
- if (node.children) {
776
- collapse(node);
777
- }
778
- else if (node.parent?.children) {
779
- node.parent.children = node.parent.children.filter(c => c.data.id !== collapsedId);
780
- }
781
- }
1297
+ const root = buildTreeRoot(this.tree, self.collapsed);
1298
+ const numTips = leaves(root).length;
1299
+ clusterLayout(root, numTips, 1);
1300
+ const rootLen = Math.max(root.data.length || 0, 0);
1301
+ const extent = maxLength(root) - rootLen;
1302
+ setBrLength(root, -rootLen, extent ? 1 / extent : 0);
1303
+ return {
1304
+ root,
1305
+ numTips,
1306
+ maxDepthToLeaf: calcDepthToLeaf(root),
1307
+ showBranchLen: self.showBranchLen && extent > 0,
1308
+ };
1309
+ },
1310
+ /**
1311
+ * #getter
1312
+ * the `clades` highlights in the overview's own row space, which the
1313
+ * focus does not narrow
1314
+ */
1315
+ get treeOverviewClades() {
1316
+ const layout = this.treeOverviewLayout;
1317
+ if (!layout || self.clades.length === 0) {
1318
+ return noClades;
1319
+ }
1320
+ const rowNames = new Map(leaves(layout.root).map((leaf, index) => [leaf.data.name, index]));
1321
+ return resolveClades(self.clades, layout.root, rowNames);
1322
+ },
1323
+ /**
1324
+ * #getter
1325
+ * the inclusive tip rows the focused subtree covers in the overview,
1326
+ * which is the box drawn on it. undefined with no focus
1327
+ */
1328
+ get treeOverviewFocusRows() {
1329
+ const layout = this.treeOverviewLayout;
1330
+ if (!layout || !self.showOnly) {
1331
+ return undefined;
1332
+ }
1333
+ const node = find(layout.root, n => n.data.id === self.showOnly);
1334
+ return node ? tipRange(node) : undefined;
1335
+ },
1336
+ /**
1337
+ * #method
1338
+ * the subtree a point `y` pixels down the tree overview picks: the
1339
+ * deepest one whose tip range covers every row under that pixel, with
1340
+ * the rows it covers. A pixel stands for several tips on a large tree,
1341
+ * which is what keeps the pick off the individual tips. undefined when
1342
+ * the overview is off or the point picks the whole tree.
1343
+ */
1344
+ treeOverviewHit(y) {
1345
+ const layout = this.treeOverviewLayout;
1346
+ if (!layout) {
1347
+ return undefined;
782
1348
  }
783
- return hier;
1349
+ const { root, numTips } = layout;
1350
+ const perPixel = numTips / self.overviewHeight;
1351
+ const first = clamp(Math.floor(y * perPixel), 0, numTips - 1);
1352
+ const last = clamp(Math.ceil((y + 1) * perPixel) - 1, first, numTips - 1);
1353
+ let node = nodeCoveringRows(root, first + 0.5, last + 0.5);
1354
+ // focusing one tip leaves a single row on screen, so the pick lifts to
1355
+ // the subtree that tip sits in
1356
+ while (!node.children && node.parent) {
1357
+ node = node.parent;
1358
+ }
1359
+ return node === root
1360
+ ? undefined
1361
+ : { id: node.data.id, rows: tipRange(node) };
1362
+ },
1363
+ /**
1364
+ * #getter
1365
+ * the pixel column the row panels occupy between the tree and the
1366
+ * alignment, the sum of each record's width
1367
+ */
1368
+ get rowPanelsWidth() {
1369
+ return sum(self.rowPanels.map(p => rowPanelWidth(p, self.rowHeight)));
1370
+ },
1371
+ /**
1372
+ * #getter
1373
+ * height of the band the row panel headers draw in, which is zero with
1374
+ * no row panels and leaves the top area as it was
1375
+ */
1376
+ get rowPanelsHeaderHeight() {
1377
+ return self.rowPanels.length > 0 ? rowPanelHeaderHeight : 0;
784
1378
  },
785
1379
  /**
786
1380
  * #getter
787
- * widget width minus the tree area gives the space for the MSA
1381
+ * widget width minus the tree area and the row panels gives the space
1382
+ * for the MSA
788
1383
  */
789
1384
  get msaAreaWidth() {
790
- return self.width - self.treeAreaWidth - self.resizeHandleWidth;
1385
+ return (self.width -
1386
+ self.treeAreaWidth -
1387
+ this.rowPanelsWidth -
1388
+ self.resizeHandleWidth);
791
1389
  },
792
1390
  /**
793
1391
  * #getter
1392
+ * the right edge the tip labels end at, which is the tree area less the
1393
+ * margin and the bracket gutter
794
1394
  */
795
1395
  get treeAreaWidthMinusMargin() {
796
- return self.treeAreaWidth - self.marginLeft;
1396
+ return self.treeAreaWidth - self.marginLeft - this.cladeGutterWidth;
797
1397
  },
798
1398
  /**
799
1399
  * #getter
@@ -863,28 +1463,50 @@ function stateModelFactory() {
863
1463
  },
864
1464
  /**
865
1465
  * #getter
866
- * number of rows the alignment occupies on screen. This is the leaf count,
867
- * not `rows.length`: a tree leaf with no matching MSA row still takes up a
868
- * row of vertical space (drawn blank), so row hit-testing and fit-to-height
869
- * must count it.
1466
+ * number of rows on screen: the leaf count, which includes tree leaves
1467
+ * with no matching MSA row (drawn blank), unlike `rows.length`.
870
1468
  */
871
1469
  get numRows() {
872
1470
  return this.leaves.length;
873
1471
  },
874
1472
  /**
875
- * #getter
876
- * per-row index of the global column holding each ungapped sequence
877
- * position, so seqPos -> column is a lookup rather than a scan of the row.
878
- * The domain overlay resolves thousands of these per redraw.
1473
+ * #method
1474
+ * index of the global column holding each ungapped sequence position of a
1475
+ * row. The domain overlay resolves thousands of these per redraw. Built
1476
+ * lazily per row and cached on the parse.
879
1477
  */
880
- get seqPosGlobalColIndex() {
881
- return new Map(this.rows.map(([name, seq]) => [name, buildSeqPosIndex(seq)]));
1478
+ seqPosIndex(rowName) {
1479
+ const MSA = this.MSA;
1480
+ if (!MSA) {
1481
+ return undefined;
1482
+ }
1483
+ let cache = seqPosIndexCache.get(MSA);
1484
+ if (!cache) {
1485
+ cache = new Map();
1486
+ seqPosIndexCache.set(MSA, cache);
1487
+ }
1488
+ let index = cache.get(rowName);
1489
+ if (!index) {
1490
+ const seq = MSA.getRow(rowName);
1491
+ if (!seq) {
1492
+ return undefined;
1493
+ }
1494
+ index = buildSeqPosIndex(seq);
1495
+ cache.set(rowName, index);
1496
+ }
1497
+ return index;
882
1498
  },
883
1499
  /**
884
1500
  * #getter
1501
+ * every sequence in the alignment, keyed by row name, including rows a
1502
+ * collapsed clade hides. `rows` holds only the rows on screen; lookups
1503
+ * by row name use this
885
1504
  */
886
1505
  get rowMap() {
887
- return new Map(this.rows);
1506
+ const MSA = this.MSA;
1507
+ return new Map(MSA?.getNames()
1508
+ .map(name => [name, MSA.getRow(name)])
1509
+ .filter(([, seq]) => !!seq));
888
1510
  },
889
1511
  /**
890
1512
  * #getter
@@ -1009,10 +1631,11 @@ function stateModelFactory() {
1009
1631
  get hierarchy() {
1010
1632
  const r = this.root;
1011
1633
  clusterLayout(r, this.totalHeight, self.treeWidth);
1012
- r.data.length = 0;
1013
- const max = maxLength(r);
1634
+ const max = this.rootToTipLength;
1014
1635
  const k = max ? self.treeWidth / max : 0;
1015
- setBrLength(r, 0, k);
1636
+ // the displayed root starts at x=0, so subtract its length here; `root`
1637
+ // returns the cached parse, which must not be mutated
1638
+ setBrLength(r, -Math.max(r.data.length || 0, 0), k);
1016
1639
  // for each collapsed clade, record the pixel x-position of its farthest
1017
1640
  // tip so the renderer can draw a triangle spanning the branch-length
1018
1641
  // extent of the hidden subtree
@@ -1038,10 +1661,29 @@ function stateModelFactory() {
1038
1661
  },
1039
1662
  /**
1040
1663
  * #getter
1041
- * max branch length across the tree, used to scale phylogram x-positions
1664
+ * branch-length extent of the displayed tree, root to farthest tip, in
1665
+ * the tree's own units
1666
+ */
1667
+ get rootToTipLength() {
1668
+ const r = this.root;
1669
+ return maxLength(r) - Math.max(r.data.length || 0, 0);
1670
+ },
1671
+ /**
1672
+ * #getter
1673
+ * x-position of the farthest tip in a phylogram, px: treeWidth, or 0 for
1674
+ * a tree with no branch lengths (drawn as a cladogram)
1042
1675
  */
1043
1676
  get maxBranchLength() {
1044
- return findMaxBranchLen(this.hierarchy);
1677
+ return this.rootToTipLength ? self.treeWidth : 0;
1678
+ },
1679
+ /**
1680
+ * #getter
1681
+ * pixels per unit of branch length in the phylogram layout, 0 in
1682
+ * cladogram mode. The tree's scale bar uses it.
1683
+ */
1684
+ get pxPerBranchLength() {
1685
+ const max = maxLength(this.root);
1686
+ return this.showBranchLenEffective && max ? self.treeWidth / max : 0;
1045
1687
  },
1046
1688
  /**
1047
1689
  * #getter
@@ -1054,7 +1696,7 @@ function stateModelFactory() {
1054
1696
  * #getter
1055
1697
  */
1056
1698
  get allBranchesLength0() {
1057
- return links(this.hierarchy).every(s => !s.source.data.length);
1699
+ return this.rootToTipLength === 0;
1058
1700
  },
1059
1701
  /**
1060
1702
  * #getter
@@ -1076,88 +1718,711 @@ function stateModelFactory() {
1076
1718
  /**
1077
1719
  * #getter
1078
1720
  */
1079
- get dataInitialized() {
1080
- // truthiness, not `!== ''`: these are types.maybe, and DataModel's
1081
- // postProcessSnapshot drops a document over 50kb, so a restored session
1082
- // that inlined a large alignment comes back `undefined` here -- which
1083
- // `!== ''` reads as initialized and renders an empty view instead of
1084
- // the import form
1085
- return !!(self.data.msa || self.data.tree) && !self.error;
1086
- },
1087
- /**
1088
- * #getter
1089
- */
1090
- get blocksX() {
1091
- return calculateBlocks({
1092
- viewportSize: self.msaAreaWidth,
1093
- viewportPos: -self.scrollX,
1094
- blockSize: self.blockSize,
1095
- mapSize: self.totalWidth,
1096
- });
1097
- },
1098
- /**
1099
- * #getter
1100
- */
1101
- get blocksY() {
1102
- return calculateBlocks({
1103
- viewportSize: this.visibleMsaHeight,
1104
- viewportPos: -self.scrollY,
1105
- blockSize: self.blockSize,
1106
- mapSize: self.totalHeight,
1107
- });
1721
+ get showMsaLetters() {
1722
+ return (self.drawMsaLetters &&
1723
+ self.rowHeight >= minLetterRowHeight &&
1724
+ self.colWidth >= minLetterColWidth &&
1725
+ self.colWidth > self.rowHeight / 2);
1108
1726
  },
1109
1727
  /**
1110
1728
  * #getter
1111
- * height of the alignment viewport, px. The same subtraction as
1112
- * msaAreaHeight, which is defined later in the views chain
1113
1729
  */
1114
- get visibleMsaHeight() {
1115
- return (self.height -
1116
- self.headerHeight -
1117
- (self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0));
1730
+ get showTreeText() {
1731
+ return self.drawLabels && self.rowHeight >= minLetterRowHeight;
1118
1732
  },
1119
1733
  }))
1120
1734
  .views(self => ({
1121
1735
  /**
1122
1736
  * #getter
1123
1737
  */
1124
- get blocks2d() {
1125
- return self.blocksY.flatMap(by => self.blocksX.map(bx => [bx, by]));
1738
+ get labelWidthMap() {
1739
+ const { showTreeText, leaves } = self;
1740
+ // gated on the renderer's label condition, so hidden labels reserve no
1741
+ // gutter. Measured once at a reference size and scaled by
1742
+ // labelWidthScale: re-measuring per vertical-zoom frame cost ~200ms on a
1743
+ // 50k-leaf tree
1744
+ return showTreeText
1745
+ ? new Map(leaves.map(node => {
1746
+ const { name } = node.data;
1747
+ // `||`, matching renderTreeLabels: an empty genome falls back
1748
+ // to the row name
1749
+ const displayName = self.rowDataOf(name)?.genome || name;
1750
+ return [
1751
+ name,
1752
+ measureTextCanvas(displayName, labelReferenceFontSize),
1753
+ ];
1754
+ }))
1755
+ : new Map();
1126
1756
  },
1127
1757
  /**
1128
1758
  * #getter
1759
+ * factor turning a labelWidthMap entry into its width at the current
1760
+ * font size
1129
1761
  */
1130
- get isLoading() {
1131
- return self.loadingMSA || self.loadingTree;
1762
+ get labelWidthScale() {
1763
+ return self.fontSize / labelReferenceFontSize;
1764
+ },
1765
+ get labelsWidth() {
1766
+ // a loop, not Math.max(...): spreading passes one argument per leaf,
1767
+ // and the ~125k argument limit throws RangeError on the 230k-tip COVID
1768
+ // tree
1769
+ let max = 0;
1770
+ for (const width of this.labelWidthMap.values()) {
1771
+ if (width > max) {
1772
+ max = width;
1773
+ }
1774
+ }
1775
+ return max * this.labelWidthScale;
1132
1776
  },
1133
1777
  /**
1134
1778
  * #getter
1135
1779
  */
1136
- get maxScrollX() {
1137
- return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0);
1780
+ get secondaryStructureConsensus() {
1781
+ return self.MSA?.secondaryStructureConsensus;
1138
1782
  },
1139
1783
  /**
1140
1784
  * #getter
1141
- * most-negative allowed scrollY, keeping the last row in view rather than
1142
- * letting the whole alignment scroll off the top.
1143
1785
  */
1144
- get maxScrollY() {
1145
- return Math.min(-self.totalHeight + self.visibleMsaHeight, 0);
1786
+ get seqConsensus() {
1787
+ return self.MSA?.seqConsensus;
1146
1788
  },
1147
1789
  /**
1148
1790
  * #getter
1791
+ * the base pairs of the consensus secondary structure, as arcs, in
1792
+ * visible column space (hidden columns are removed before parsing)
1149
1793
  */
1150
- get showMsaLetters() {
1151
- return (self.drawMsaLetters &&
1152
- self.rowHeight >= minLetterRowHeight &&
1153
- self.colWidth >= minLetterColWidth &&
1154
- self.colWidth > self.rowHeight / 2);
1794
+ get secondaryStructureArcs() {
1795
+ const { blanks, hideGapsEffective } = self;
1796
+ const ss = this.secondaryStructureConsensus;
1797
+ if (!ss) {
1798
+ return undefined;
1799
+ }
1800
+ return parseWuss(hideGapsEffective ? skipBlanks(blanks, ss) : ss).map(({ start, end, pseudoknot }) => ({
1801
+ start,
1802
+ end,
1803
+ color: pseudoknot ? PSEUDOKNOT_ARC : HELIX_ARC,
1804
+ }));
1155
1805
  },
1156
1806
  /**
1157
1807
  * #getter
1158
1808
  */
1159
- get showTreeText() {
1160
- return self.drawLabels && self.rowHeight >= minLetterRowHeight;
1809
+ get adapterTrackModels() {
1810
+ const { MSA, hideGapsEffective, blanks } = self;
1811
+ const tracks = (MSA?.tracks ?? []).filter(t => !!t.data);
1812
+ if (tracks.length === 0) {
1813
+ // return before reading rowHeight, so zooming does not rebuild the
1814
+ // list and redraw every track canvas
1815
+ return [];
1816
+ }
1817
+ const { rowHeight } = self;
1818
+ return tracks.map(t => ({
1819
+ model: {
1820
+ ...t,
1821
+ kind: 'text',
1822
+ data: hideGapsEffective ? skipBlanks(blanks, t.data) : t.data,
1823
+ height: rowHeight,
1824
+ },
1825
+ ReactComponent: TrackBlocks,
1826
+ }));
1827
+ },
1828
+ /**
1829
+ * #getter
1830
+ * a data track's values or string, projected from its row's residues
1831
+ * onto alignment columns when it names a row
1832
+ */
1833
+ get columnTrackContent() {
1834
+ const { MSA, blanks, hideGapsEffective } = self;
1835
+ const width = MSA?.getWidth() ?? 0;
1836
+ const project = (track, items, fill) => {
1837
+ if (!track.row) {
1838
+ return items;
1839
+ }
1840
+ const out = Array.from({ length: width }, () => fill);
1841
+ const index = self.seqPosIndex(track.row);
1842
+ items.forEach((item, seqPos) => {
1843
+ const col = index?.[seqPos];
1844
+ if (col !== undefined) {
1845
+ out[col] = item;
1846
+ }
1847
+ });
1848
+ return out;
1849
+ };
1850
+ const skip = (items) => hideGapsEffective ? dropBlanks(blanks, items) : items;
1851
+ // an arc endpoint maps row residue -> column -> visible column.
1852
+ // visibleColsBefore, not globalColToVisibleCol, so an endpoint in a
1853
+ // hidden column moves to the neighboring visible one and the arc stays
1854
+ const resolve = (track, pos) => {
1855
+ const col = track.row
1856
+ ? self.seqPosIndex(track.row)?.[pos - 1]
1857
+ : pos - 1;
1858
+ if (col === undefined || col < 0 || col >= width) {
1859
+ return undefined;
1860
+ }
1861
+ return hideGapsEffective ? visibleColsBefore(blanks, col) : col;
1862
+ };
1863
+ return new Map(self.columnTracks.map(track => {
1864
+ if (track.kind === 'arc') {
1865
+ const arcs = (track.arcs ?? [])
1866
+ .map(arc => {
1867
+ const start = resolve(track, Math.min(arc.start, arc.end));
1868
+ const end = resolve(track, Math.max(arc.start, arc.end));
1869
+ return start !== undefined && end !== undefined && start < end
1870
+ ? { start, end, color: arc.color }
1871
+ : undefined;
1872
+ })
1873
+ .filter(notEmpty);
1874
+ return [track.id, { arcs }];
1875
+ }
1876
+ if (track.kind === 'bar') {
1877
+ const max = track.max ?? 1;
1878
+ const values = skip(project(track, track.values ?? [], 0)).map(v => Math.min(1, Math.max(0, v / max)));
1879
+ return [track.id, { values }];
1880
+ }
1881
+ const data = skip(project(track, (track.data ?? '').split(''), ' '));
1882
+ return [track.id, { data: data.join('') }];
1883
+ }));
1884
+ },
1885
+ /**
1886
+ * #method
1887
+ * the height a track draws at: what the user dragged its divider to,
1888
+ * then the height its snapshot asked for, then its kind's default. Only
1889
+ * a text track falls through to rowHeight, and `??` short-circuits
1890
+ * before reading it, so vertical zoom does not rebuild the other tracks
1891
+ */
1892
+ trackHeight(kind, heightKey = kind, given) {
1893
+ return (self.trackHeights.get(heightKey) ??
1894
+ given ??
1895
+ defaultTrackHeights[kind] ??
1896
+ self.rowHeight);
1897
+ },
1898
+ /**
1899
+ * #getter
1900
+ */
1901
+ get columnTrackModels() {
1902
+ return self.columnTracks.map(track => {
1903
+ const heightKey = resizableKinds.has(track.kind)
1904
+ ? ownHeightKey(track.id)
1905
+ : undefined;
1906
+ return {
1907
+ model: {
1908
+ id: track.id,
1909
+ name: track.name,
1910
+ kind: track.kind,
1911
+ heightKey,
1912
+ height: this.trackHeight(track.kind, heightKey, track.height),
1913
+ barColor: track.color,
1914
+ arcColor: track.color,
1915
+ customColorScheme: track.colors,
1916
+ data: this.columnTrackContent.get(track.id)?.data,
1917
+ arcs: this.columnTrackContent.get(track.id)?.arcs,
1918
+ },
1919
+ ReactComponent: TrackBlocks,
1920
+ };
1921
+ });
1922
+ },
1923
+ /**
1924
+ * #getter
1925
+ * the consensus secondary structure as a track, when there is one. A
1926
+ * separate getter keeps the object stable across zoom, so its canvas
1927
+ * does not redraw
1928
+ */
1929
+ get basePairTrackModels() {
1930
+ const arcs = this.secondaryStructureArcs;
1931
+ return arcs?.length
1932
+ ? [
1933
+ {
1934
+ model: {
1935
+ id: 'base-pairs',
1936
+ name: 'Base pairs',
1937
+ kind: 'arc',
1938
+ heightKey: 'arc',
1939
+ height: this.trackHeight('arc'),
1940
+ arcs,
1941
+ },
1942
+ ReactComponent: TrackBlocks,
1943
+ },
1944
+ ]
1945
+ : [];
1946
+ },
1947
+ /**
1948
+ * #getter
1949
+ * the tracks computed from the alignment; they depend on their heights
1950
+ * and the alphabet, not on zoom
1951
+ */
1952
+ get computedTrackModels() {
1953
+ return [
1954
+ {
1955
+ id: 'conservation',
1956
+ name: 'Conservation',
1957
+ kind: 'bar',
1958
+ barColor: 'gray',
1959
+ },
1960
+ ...(self.sequenceType === 'amino'
1961
+ ? [
1962
+ {
1963
+ id: 'property-conservation',
1964
+ name: 'Property conservation',
1965
+ kind: 'bar',
1966
+ barColor: '#6a51a3',
1967
+ },
1968
+ ]
1969
+ : []),
1970
+ {
1971
+ id: 'sequence-logo',
1972
+ name: 'Sequence logo',
1973
+ kind: 'logo',
1974
+ },
1975
+ // last, so it sits against the alignment it numbers
1976
+ {
1977
+ id: 'position-ruler',
1978
+ name: 'Position',
1979
+ kind: 'ruler',
1980
+ },
1981
+ ].map(model => ({
1982
+ // every computed track of a kind shares that kind's height, so the
1983
+ // kind is its key
1984
+ model: {
1985
+ ...model,
1986
+ heightKey: resizableKinds.has(model.kind) ? model.kind : undefined,
1987
+ height: this.trackHeight(model.kind),
1988
+ },
1989
+ ReactComponent: TrackBlocks,
1990
+ }));
1991
+ },
1992
+ get tracks() {
1993
+ return [
1994
+ ...this.adapterTrackModels,
1995
+ ...this.basePairTrackModels,
1996
+ ...this.columnTrackModels,
1997
+ // every computed track reads the alignment's columns, and a tree, a
1998
+ // GFF and a features panel draw a figure with none
1999
+ ...(self.numColumns > 0 ? this.computedTrackModels : []),
2000
+ ];
2001
+ },
2002
+ /**
2003
+ * #getter
2004
+ */
2005
+ get turnedOnTracks() {
2006
+ return this.tracks.filter(f => !trackIsOff(self.turnedOffTracks, f.model.id, f.model.defaultOff));
2007
+ },
2008
+ /**
2009
+ * #getter
2010
+ */
2011
+ get showHorizontalScrollbar() {
2012
+ return overflows(self.totalWidth, self.msaAreaWidth);
2013
+ },
2014
+ /**
2015
+ * #method
2016
+ * Return a row-specific letter at a visible column, or undefined if gap.
2017
+ *
2018
+ * @param rowName - The name of the row
2019
+ * @param visibleCol - The visible column index (what the user sees on screen)
2020
+ * @returns The letter at that position, or undefined if it's a gap
2021
+ */
2022
+ visibleColToRowLetter(rowName, visibleCol) {
2023
+ return self.rowMap.get(rowName)?.[this.visibleColToGlobalCol(visibleCol)];
2024
+ },
2025
+ /**
2026
+ * #method
2027
+ * Convert a visible column to a row-specific sequence position (0-based).
2028
+ * Returns undefined if the position is a gap in the sequence.
2029
+ *
2030
+ * Public API, like the sibling converters (visibleColToGlobalCol,
2031
+ * seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) hosts
2032
+ * use to translate between columns and residue positions. Keep them
2033
+ * stable.
2034
+ *
2035
+ * @param rowName - The name of the row
2036
+ * @param visibleCol - The visible column index
2037
+ * @returns The sequence position (0-based), or undefined if it's a gap
2038
+ */
2039
+ visibleColToSeqPos(rowName, visibleCol) {
2040
+ // binary search: this runs on every mouse move, and scanning a
2041
+ // 30k-column row per event takes the whole frame
2042
+ return seqPosOfGlobalCol(self.seqPosIndex(rowName), this.visibleColToGlobalCol(visibleCol));
2043
+ },
2044
+ /**
2045
+ * #method
2046
+ * Convert a visible column to a row-specific sequence position (1-based).
2047
+ * Returns undefined if the position is a gap in the sequence.
2048
+ *
2049
+ * @param rowName - The name of the row
2050
+ * @param visibleCol - The visible column index
2051
+ * @returns The sequence position (1-based), or undefined if it's a gap
2052
+ */
2053
+ visibleColToSeqPosOneBased(rowName, visibleCol) {
2054
+ const val = this.visibleColToSeqPos(rowName, visibleCol);
2055
+ return val !== undefined ? val + 1 : undefined;
2056
+ },
2057
+ /**
2058
+ * #method
2059
+ * Convert a global column index to a visible column index.
2060
+ * Returns undefined if the column is hidden (in blanks).
2061
+ * This is the inverse of visibleColToGlobalCol.
2062
+ *
2063
+ * @param globalCol - The global column index in the full MSA
2064
+ * @returns The visible column index, or undefined if the column is hidden
2065
+ */
2066
+ globalColToVisibleCol(globalCol) {
2067
+ const { blanks, hideGapsEffective } = self;
2068
+ if (!hideGapsEffective) {
2069
+ return globalCol;
2070
+ }
2071
+ return globalColToVisibleCol(blanks, globalCol);
2072
+ },
2073
+ /**
2074
+ * #method
2075
+ * Convert a visible column index (what a mouse handler reports) to a
2076
+ * column of the full alignment. A host indexing its own per-column data
2077
+ * needs this when columns are hidden.
2078
+ *
2079
+ * @param visibleCol - The visible column index
2080
+ * @returns The global column index in the full MSA
2081
+ */
2082
+ visibleColToGlobalCol(visibleCol) {
2083
+ const { blanks, hideGapsEffective } = self;
2084
+ return hideGapsEffective
2085
+ ? visibleColToGlobalCol(blanks, visibleCol)
2086
+ : visibleCol;
2087
+ },
2088
+ /**
2089
+ * #method
2090
+ * Convert a sequence position (ungapped) to a global column index.
2091
+ * Returns undefined for a row name the alignment does not have.
2092
+ *
2093
+ * @param rowName - The name of the row
2094
+ * @param seqPos - The sequence position (0-based, ungapped)
2095
+ * @returns The global column index in the full MSA, or undefined
2096
+ */
2097
+ seqPosToGlobalCol(rowName, seqPos) {
2098
+ const seq = self.rowMap.get(rowName);
2099
+ if (seq === undefined) {
2100
+ return undefined;
2101
+ }
2102
+ const col = self.seqPosIndex(rowName)?.[seqPos];
2103
+ // past the end of the ungapped sequence: 0 for the degenerate all-gap
2104
+ // row, otherwise one past the last column
2105
+ return col ?? (seqPos === 0 ? 0 : seq.length);
2106
+ },
2107
+ /**
2108
+ * #method
2109
+ * Convert a sequence position (ungapped) directly to a visible column index.
2110
+ * This combines seqPosToGlobalCol and globalColToVisibleCol.
2111
+ *
2112
+ * @param rowName - The name of the row
2113
+ * @param seqPos - The sequence position (0-based, ungapped)
2114
+ * @returns The visible column index, or undefined if the column is hidden
2115
+ */
2116
+ seqPosToVisibleCol(rowName, seqPos) {
2117
+ const globalCol = this.seqPosToGlobalCol(rowName, seqPos);
2118
+ return globalCol === undefined
2119
+ ? undefined
2120
+ : this.globalColToVisibleCol(globalCol);
2121
+ },
2122
+ /**
2123
+ * #method
2124
+ * the visible columns a span covers, in highlight coordinates: `start`
2125
+ * and `end` are 1-based residues of `row`, or columns of the file
2126
+ * without it. A fractional position, as a zoom gesture reports one,
2127
+ * widens to the whole residue or column it falls in. A span entirely on
2128
+ * hidden columns, or naming a row the alignment lacks, gives undefined.
2129
+ */
2130
+ visibleSpan({ row, start: rawStart, end: rawEnd }) {
2131
+ const { blanks } = self;
2132
+ const start = Math.max(1, Math.floor(rawStart));
2133
+ const end = Math.ceil(rawEnd);
2134
+ let startGlobal = start - 1;
2135
+ let endGlobal = end - 1;
2136
+ if (row !== undefined) {
2137
+ const rowStart = this.seqPosToGlobalCol(row, start - 1);
2138
+ const rowEnd = this.seqPosToGlobalCol(row, end - 1);
2139
+ if (rowStart === undefined || rowEnd === undefined) {
2140
+ return undefined;
2141
+ }
2142
+ startGlobal = rowStart;
2143
+ endGlobal = rowEnd;
2144
+ }
2145
+ const startCol = this.globalColToVisibleCol(startGlobal) ??
2146
+ visibleColsBefore(blanks, startGlobal);
2147
+ const endCol = this.globalColToVisibleCol(endGlobal) ??
2148
+ visibleColsBefore(blanks, endGlobal) - 1;
2149
+ return startCol <= endCol ? { startCol, endCol } : undefined;
2150
+ },
2151
+ /**
2152
+ * #getter
2153
+ * why each ignored residue mapping is ignored, so a host can tell a
2154
+ * missing structure from a mapping made against a different alignment.
2155
+ */
2156
+ get residueMappingProblems() {
2157
+ const problems = [];
2158
+ for (const mapping of self.residueMappings) {
2159
+ const where = { row: mapping.row, structureId: mapping.structure.id };
2160
+ const residues = self.seqPosIndex(mapping.row)?.length;
2161
+ if (residues === undefined) {
2162
+ problems.push({
2163
+ ...where,
2164
+ scope: 'mapping',
2165
+ reason: 'no such row in the alignment',
2166
+ });
2167
+ continue;
2168
+ }
2169
+ if (mapping.rowLength !== undefined &&
2170
+ mapping.rowLength !== residues) {
2171
+ problems.push({
2172
+ ...where,
2173
+ scope: 'mapping',
2174
+ reason: `computed against a ${mapping.rowLength}-residue row; this one has ${residues}`,
2175
+ });
2176
+ continue;
2177
+ }
2178
+ const overrun = mapping.segments.find(segment => segment.rowEnd > residues || segment.rowStart < 1);
2179
+ if (overrun) {
2180
+ problems.push({
2181
+ ...where,
2182
+ scope: 'mapping',
2183
+ reason: `segment ${overrun.rowStart}-${overrun.rowEnd} does not fit a ${residues}-residue row`,
2184
+ });
2185
+ continue;
2186
+ }
2187
+ for (const segment of mapping.segments) {
2188
+ if (!sameLength(segment)) {
2189
+ problems.push({
2190
+ ...where,
2191
+ scope: 'segment',
2192
+ reason: `segment ${segment.rowStart}-${segment.rowEnd} maps to ${segment.structStart}-${segment.structEnd}, which is a different length`,
2193
+ });
2194
+ }
2195
+ }
2196
+ }
2197
+ return problems;
2198
+ },
2199
+ /**
2200
+ * #getter
2201
+ * the mappings that fit the loaded alignment. A row-level problem drops
2202
+ * the whole mapping; a malformed segment drops only that segment.
2203
+ */
2204
+ get usableResidueMappings() {
2205
+ const unusable = new Set(this.residueMappingProblems
2206
+ .filter(problem => problem.scope === 'mapping')
2207
+ .map(problem => `${problem.row}\u0000${problem.structureId}`));
2208
+ return self.residueMappings
2209
+ .filter(m => !unusable.has(`${m.row}\u0000${m.structure.id}`))
2210
+ .map(m => ({
2211
+ ...m,
2212
+ segments: m.segments.filter(sameLength),
2213
+ }));
2214
+ },
2215
+ /**
2216
+ * #getter
2217
+ * the structures with usable mappings. A row can map onto several, such
2218
+ * as an experimental entry and a predicted model.
2219
+ */
2220
+ get mappedStructures() {
2221
+ return this.usableResidueMappings.map(m => ({
2222
+ row: m.row,
2223
+ structure: m.structure,
2224
+ }));
2225
+ },
2226
+ /**
2227
+ * #method
2228
+ * The structure residue for a row residue. Returns undefined when no
2229
+ * segment covers `seqPos`, or when the row maps onto several structures
2230
+ * and `structureId` does not pick one (see `mappedStructures`).
2231
+ *
2232
+ * Positions are 1-based, like `residueMappings` and `highlights`; the
2233
+ * column helpers above are 0-based.
2234
+ *
2235
+ * @param rowName - The alignment row
2236
+ * @param seqPos - Residue of that row, 1-based
2237
+ * @param structureId - Which structure, when the row maps onto several
2238
+ */
2239
+ structureResidue(rowName, seqPos, structureId) {
2240
+ const hits = [];
2241
+ for (const mapping of this.usableResidueMappings) {
2242
+ if (mapping.row !== rowName) {
2243
+ continue;
2244
+ }
2245
+ if (structureId !== undefined &&
2246
+ mapping.structure.id !== structureId) {
2247
+ continue;
2248
+ }
2249
+ const segment = mapping.segments.find(seg => seqPos >= seg.rowStart && seqPos <= seg.rowEnd);
2250
+ if (segment) {
2251
+ const position = segment.structStart + (seqPos - segment.rowStart);
2252
+ hits.push({
2253
+ structure: mapping.structure,
2254
+ position,
2255
+ observed: !inRanges(mapping.unobserved, position),
2256
+ });
2257
+ }
2258
+ }
2259
+ return hits.length === 1 ? hits[0] : undefined;
2260
+ },
2261
+ /**
2262
+ * #method
2263
+ * The row residue for a structure residue; the inverse of
2264
+ * `structureResidue`, returning undefined in the same cases. `asymId`
2265
+ * picks a chain when several mappings share an entry id, as in a
2266
+ * homodimer.
2267
+ *
2268
+ * @param structureId - The structure's id, as the mapping names it
2269
+ * @param position - Residue of that structure, 1-based label_seq_id
2270
+ * @param asymId - Which chain, when the id alone is ambiguous
2271
+ */
2272
+ rowResidue(structureId, position, asymId) {
2273
+ const hits = [];
2274
+ for (const mapping of this.usableResidueMappings) {
2275
+ if (mapping.structure.id !== structureId) {
2276
+ continue;
2277
+ }
2278
+ if (asymId !== undefined && mapping.structure.asymId !== asymId) {
2279
+ continue;
2280
+ }
2281
+ const segment = mapping.segments.find(seg => position >= seg.structStart && position <= seg.structEnd);
2282
+ if (segment) {
2283
+ hits.push({
2284
+ rowName: mapping.row,
2285
+ seqPos: segment.rowStart + (position - segment.structStart),
2286
+ });
2287
+ }
2288
+ }
2289
+ return hits.length === 1 ? hits[0] : undefined;
2290
+ },
2291
+ }))
2292
+ .views(self => ({
2293
+ /**
2294
+ * #getter
2295
+ * the branch-length scale bar over the tree, undefined in cladogram mode
2296
+ * or when the tree area is too narrow for one
2297
+ */
2298
+ get treeScaleBar() {
2299
+ return scaleBarLength(self.pxPerBranchLength, self.treeAreaWidth - self.marginLeft * 2);
2300
+ },
2301
+ /**
2302
+ * #getter
2303
+ * the band across the top: the minimap over the alignment, the row panel
2304
+ * headers, and the tree overview stacked on the scale bar, as tall as the
2305
+ * tallest of the three
2306
+ */
2307
+ get topBandHeight() {
2308
+ return Math.max(self.showHorizontalScrollbar ? self.minimapHeight : 0, self.rowPanelsHeaderHeight, self.treeOverviewHeight +
2309
+ (this.treeScaleBar ? treeScaleBarHeight : 0));
2310
+ },
2311
+ /**
2312
+ * #getter
2313
+ * the vertical space for alignment rows: the widget height less the
2314
+ * header, the top band and the tracks. Shared by blocksY, maxScrollY, the
2315
+ * vertical scrollbar and fitVertically.
2316
+ */
2317
+ get msaAreaHeight() {
2318
+ return (self.height -
2319
+ this.topBandHeight -
2320
+ self.headerHeight -
2321
+ this.totalTrackAreaHeight);
2322
+ },
2323
+ /**
2324
+ * #getter
2325
+ * total height of track area (px)
2326
+ */
2327
+ get totalTrackAreaHeight() {
2328
+ return sum(self.turnedOnTracks.map(r => r.model.height));
2329
+ },
2330
+ /**
2331
+ * one representative annotation per accession, for the legend, the
2332
+ * filter dialog and the palettes
2333
+ */
2334
+ get annotationTypes() {
2335
+ // first occurrence wins; only name, description and segment start are
2336
+ // read from it
2337
+ const types = new Map();
2338
+ for (const annot of self.annotations) {
2339
+ if (!types.has(annot.accession)) {
2340
+ types.set(annot.accession, annot);
2341
+ }
2342
+ }
2343
+ return types;
2344
+ },
2345
+ get filteredAnnotations() {
2346
+ return self.annotations.filter(r => !self.turnedOffFeatures.get(r.accession));
2347
+ },
2348
+ get annotationsByRow() {
2349
+ return groupBy(this.filteredAnnotations, r => r.id);
2350
+ },
2351
+ }))
2352
+ .views(self => ({
2353
+ /**
2354
+ * #getter
2355
+ */
2356
+ get showVerticalScrollbar() {
2357
+ return overflows(self.totalHeight, self.msaAreaHeight);
2358
+ },
2359
+ }))
2360
+ .views(self => ({
2361
+ /**
2362
+ * #getter
2363
+ */
2364
+ get dataInitialized() {
2365
+ // truthiness, not `!== ''`: DataModel drops an inline document over
2366
+ // 50kb from the snapshot, so a restored session can hold `undefined`
2367
+ return !!(self.data.msa || self.data.tree) && !self.error;
2368
+ },
2369
+ /**
2370
+ * #getter
2371
+ */
2372
+ get blocksX() {
2373
+ return calculateBlocks({
2374
+ viewportSize: self.msaAreaWidth,
2375
+ viewportPos: -self.scrollX,
2376
+ blockSize: self.blockSize,
2377
+ mapSize: self.totalWidth,
2378
+ });
2379
+ },
2380
+ /**
2381
+ * #getter
2382
+ */
2383
+ get blocksY() {
2384
+ return calculateBlocks({
2385
+ viewportSize: self.msaAreaHeight,
2386
+ viewportPos: -self.scrollY,
2387
+ blockSize: self.blockSize,
2388
+ mapSize: self.totalHeight,
2389
+ });
2390
+ },
2391
+ }))
2392
+ .views(self => ({
2393
+ /**
2394
+ * #getter
2395
+ */
2396
+ get blocks2d() {
2397
+ return self.blocksY.flatMap(by => self.blocksX.map(bx => [bx, by]));
2398
+ },
2399
+ /**
2400
+ * #getter
2401
+ */
2402
+ get isLoading() {
2403
+ return self.loadingMSA || self.loadingTree;
2404
+ },
2405
+ /**
2406
+ * #getter
2407
+ */
2408
+ get maxScrollX() {
2409
+ return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0);
2410
+ },
2411
+ /**
2412
+ * #getter
2413
+ * most-negative allowed scrollY, which keeps the last row in view
2414
+ */
2415
+ get maxScrollY() {
2416
+ return Math.min(-self.totalHeight + self.msaAreaHeight, 0);
2417
+ },
2418
+ /**
2419
+ * #getter
2420
+ * axis a wheel zoom scales, for ctrl+wheel as much as for scroll-zoom.
2421
+ * With scroll-zoom off the toolbar shows no axis, so ctrl+wheel takes
2422
+ * both.
2423
+ */
2424
+ get wheelZoomAxis() {
2425
+ return self.scrollZoom ? self.scrollZoomAxis : 'both';
1161
2426
  },
1162
2427
  }))
1163
2428
  .actions(self => ({
@@ -1173,15 +2438,19 @@ function stateModelFactory() {
1173
2438
  setScrollZoom(arg) {
1174
2439
  self.scrollZoom = arg;
1175
2440
  },
2441
+ /**
2442
+ * #action
2443
+ */
2444
+ setScrollZoomAxis(arg) {
2445
+ self.scrollZoomAxis = arg;
2446
+ },
1176
2447
  /**
1177
2448
  * #action
1178
2449
  * set hovered tree node and its descendants
1179
2450
  */
1180
2451
  setHoveredTreeNode(nodeId) {
1181
- // the tree's mousemove handler calls this on every event, and both the
1182
- // lookup and the write are expensive: `find` walks the whole hierarchy,
1183
- // and a fresh object here invalidates hoveredRowIndices and redraws the
1184
- // tree and MSA overlays. Re-hovering the same node is the common case
2452
+ // called on every tree mousemove; `find` walks the whole hierarchy and a
2453
+ // new object redraws the tree and MSA overlays
1185
2454
  if (nodeId === self.hoveredTreeNode?.nodeId) {
1186
2455
  return;
1187
2456
  }
@@ -1199,14 +2468,34 @@ function stateModelFactory() {
1199
2468
  },
1200
2469
  /**
1201
2470
  * #action
1202
- * Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
2471
+ * Calculate a neighbor joining tree from the current MSA using BLOSUM62
2472
+ * distances. Throws above `maxNeighborJoiningRows`: the join loop is
2473
+ * cubic and runs on the main thread, and 800 rows freeze the tab for ten
2474
+ * seconds with no cancel.
1203
2475
  */
1204
2476
  calculateNeighborJoiningTreeFromMSA() {
1205
- if (self.rows.length < 2) {
2477
+ // every sequence, including rows in collapsed clades
2478
+ const rows = [...self.rowMap];
2479
+ if (rows.length < 2) {
1206
2480
  throw new Error('Need at least 2 sequences to build a tree');
1207
2481
  }
1208
- const newickTree = calculateNeighborJoiningTree(self.rows);
1209
- self.setTree(newickTree);
2482
+ if (rows.length > maxNeighborJoiningRows) {
2483
+ throw new Error(`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`);
2484
+ }
2485
+ this.replaceTree(calculateNeighborJoiningTree(rows));
2486
+ },
2487
+ /**
2488
+ * #action
2489
+ * swap in a different tree over the same alignment. Clears `collapsed`
2490
+ * and `showOnly`, since path-derived node ids (node-0-0-1) from the old
2491
+ * tree would match unrelated nodes in the new one.
2492
+ */
2493
+ replaceTree(newick) {
2494
+ transaction(() => {
2495
+ self.collapsed.clear();
2496
+ self.setShowOnly(undefined);
2497
+ self.setTree(newick);
2498
+ });
1210
2499
  },
1211
2500
  /**
1212
2501
  * #action
@@ -1264,23 +2553,26 @@ function stateModelFactory() {
1264
2553
  * #action
1265
2554
  * Smoothly zoom by a continuous scaleFactor. The column under the cursor
1266
2555
  * (offsetX/offsetY, px relative to the MSA area) stays anchored
1267
- * horizontally. Vertically the anchor is biased toward the top: when the
1268
- * alignment nearly fits the viewport, snap to y=0 rather than pinning a
1269
- * random row under the cursor, with the bias fading out as the alignment
1270
- * grows taller than the viewport (where cursor-anchoring is useful).
1271
- * Drives wheel/trackpad-pinch zoom.
1272
- */
1273
- zoomToPos(scaleFactor, offsetX, offsetY) {
2556
+ * horizontally. Vertically the anchor is biased toward y=0 when the
2557
+ * alignment nearly fits the viewport, fading to cursor-anchoring as the
2558
+ * alignment grows taller than the viewport.
2559
+ * Drives wheel/trackpad-pinch zoom. `axis` holds one cell dimension
2560
+ * fixed; the held axis still re-anchors its scroll offset, since the
2561
+ * other one can change how much of the alignment fits.
2562
+ */
2563
+ zoomToPos(scaleFactor, offsetX, offsetY, axis = 'both') {
1274
2564
  transaction(() => {
1275
2565
  const colInView = (-self.scrollX + offsetX) / self.colWidth;
1276
2566
  const rowInView = (-self.scrollY + offsetY) / self.rowHeight;
1277
- self.colWidth = clamp(self.colWidth * scaleFactor, minColWidth, maxCellSize);
1278
- self.rowHeight = clamp(self.rowHeight * scaleFactor, minRowHeight, maxCellSize);
2567
+ if (axis !== 'vertical') {
2568
+ self.colWidth = clamp(self.colWidth * scaleFactor, minColWidth, maxCellSize);
2569
+ }
2570
+ if (axis !== 'horizontal') {
2571
+ self.rowHeight = clamp(self.rowHeight * scaleFactor, minRowHeight, maxCellSize);
2572
+ }
1279
2573
  self.scrollX = clamp(offsetX - colInView * self.colWidth, self.maxScrollX, 0);
1280
2574
  const anchoredScrollY = offsetY - rowInView * self.rowHeight;
1281
- // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
1282
- // overflows, so -maxScrollY is exactly that overflow past the
1283
- // scrollable MSA viewport (0 when it fits)
2575
+ // -maxScrollY is the overflow past the viewport, 0 when it fits
1284
2576
  const overflow = Math.max(0, -self.maxScrollY);
1285
2577
  const visibleHeight = self.totalHeight - overflow;
1286
2578
  const topBias = visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1;
@@ -1302,13 +2594,14 @@ function stateModelFactory() {
1302
2594
  },
1303
2595
  /**
1304
2596
  * #action
1305
- * Set the overlay annotations and reveal the overlay in a single step (an
1306
- * empty list clears both). Every source funnels through here after its
1307
- * own adapter has flattened it: InterProScan, GFF, user uploads, NCBI CDD.
2597
+ * Set the overlay annotations (an empty list clears them). InterProScan,
2598
+ * GFF, user uploads and NCBI CDD all arrive here as Annotation[].
2599
+ *
2600
+ * Leaves `showDomains` alone, because a restored snapshot reloads its
2601
+ * GFF and must keep a hidden overlay hidden.
1308
2602
  */
1309
2603
  setAnnotations(annotations) {
1310
2604
  self.annotations = annotations;
1311
- self.setShowDomains(annotations.length > 0);
1312
2605
  },
1313
2606
  /**
1314
2607
  * #action
@@ -1322,382 +2615,147 @@ function stateModelFactory() {
1322
2615
  applyGFFText(gffText) {
1323
2616
  this.setAnnotations(gffToAnnotations(parseGFF(gffText)));
1324
2617
  },
1325
- /**
1326
- * #action
1327
- */
1328
- doScrollX(deltaX) {
1329
- this.setScrollX(self.scrollX + deltaX);
1330
- },
1331
- /**
1332
- * #action
1333
- */
1334
- setScrollX(n) {
1335
- self.scrollX = clamp(n, self.maxScrollX, 0);
1336
- },
1337
- /**
1338
- * #action
1339
- */
1340
- setColumnTracks(tracks) {
1341
- self.columnTracks.replace(tracks);
1342
- },
1343
- /**
1344
- * #action
1345
- */
1346
- toggleTrack(id) {
1347
- // the stored value is "is off", so the current shown state is exactly
1348
- // what the flipped entry should hold
1349
- self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id));
1350
- },
1351
- /**
1352
- * #action
1353
- */
1354
- setStatus(status) {
1355
- self.status = status;
1356
- },
1357
- }))
1358
- .views(self => ({
1359
- /**
1360
- * #getter
1361
- */
1362
- get labelWidthMap() {
1363
- const { showTreeText, leaves, treeMetadata } = self;
1364
- // gated on the same condition the renderer draws labels under, so the
1365
- // gutter labelsWidth reserves and the labels actually drawn cannot
1366
- // disagree -- and so turning labels off hands their space to the tree.
1367
- // Measured at a fixed reference size and scaled by labelWidthScale:
1368
- // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
1369
- // 50k-leaf tree
1370
- return showTreeText
1371
- ? new Map(leaves.map(node => {
1372
- const { name } = node.data;
1373
- // `||`, matching renderTreeLabels: an empty genome falls back
1374
- // to the row name, and measuring '' would size the gutter (and
1375
- // the label's click target) to nothing
1376
- const displayName = treeMetadata[name]?.genome || name;
1377
- return [
1378
- name,
1379
- measureTextCanvas(displayName, labelReferenceFontSize),
1380
- ];
1381
- }))
1382
- : new Map();
1383
- },
1384
- /**
1385
- * #getter
1386
- * factor turning a labelWidthMap entry into its width at the current
1387
- * font size
1388
- */
1389
- get labelWidthScale() {
1390
- return self.fontSize / labelReferenceFontSize;
1391
- },
1392
- get labelsWidth() {
1393
- // a loop, not Math.max(...widths.values()): spreading a map of every
1394
- // leaf passes one argument per row, and the argument limit is somewhere
1395
- // around 125k -- so the bundled 230k-tip COVID tree threw
1396
- // "RangeError: Maximum call stack size exceeded" out of a getter the
1397
- // treeWidth autorun reads on load
1398
- let max = 0;
1399
- for (const width of this.labelWidthMap.values()) {
1400
- if (width > max) {
1401
- max = width;
1402
- }
1403
- }
1404
- return max * this.labelWidthScale;
1405
- },
1406
- /**
1407
- * #getter
1408
- */
1409
- get secondaryStructureConsensus() {
1410
- return self.MSA?.secondaryStructureConsensus;
1411
- },
1412
- /**
1413
- * #getter
1414
- */
1415
- get seqConsensus() {
1416
- return self.MSA?.seqConsensus;
1417
- },
1418
- /**
1419
- * #getter
1420
- */
1421
- get adapterTrackModels() {
1422
- const { rowHeight, MSA, hideGapsEffective, blanks } = self;
1423
- const tracks = MSA?.tracks ?? [];
1424
- return tracks
1425
- .filter(t => !!t.data)
1426
- .map(t => ({
1427
- model: {
1428
- ...t,
1429
- kind: 'text',
1430
- data: hideGapsEffective ? skipBlanks(blanks, t.data) : t.data,
1431
- height: rowHeight,
1432
- },
1433
- ReactComponent: TrackBlocks,
1434
- }));
1435
- },
1436
- /**
1437
- * #getter
1438
- * a data track's values or string, projected from its row's residues
1439
- * onto alignment columns when it names a row
1440
- */
1441
- get columnTrackContent() {
1442
- const { MSA, blanks, hideGapsEffective } = self;
1443
- const width = MSA?.getWidth() ?? 0;
1444
- const project = (track, items, fill) => {
1445
- if (!track.row) {
1446
- return items;
1447
- }
1448
- const out = Array.from({ length: width }, () => fill);
1449
- const index = self.seqPosGlobalColIndex.get(track.row);
1450
- items.forEach((item, seqPos) => {
1451
- const col = index?.[seqPos];
1452
- if (col !== undefined) {
1453
- out[col] = item;
1454
- }
1455
- });
1456
- return out;
1457
- };
1458
- const skip = (items) => hideGapsEffective ? dropBlanks(blanks, items) : items;
1459
- return new Map(self.columnTracks.map(track => {
1460
- if (track.kind === 'bar') {
1461
- const max = track.max ?? 1;
1462
- const values = skip(project(track, track.values ?? [], 0)).map(v => Math.min(1, Math.max(0, v / max)));
1463
- return [track.id, { values }];
1464
- }
1465
- const data = skip(project(track, (track.data ?? '').split(''), ' '));
1466
- return [track.id, { data: data.join('') }];
1467
- }));
1468
- },
1469
- /**
1470
- * #getter
1471
- */
1472
- get columnTrackModels() {
1473
- return self.columnTracks.map(track => ({
1474
- model: {
1475
- id: track.id,
1476
- name: track.name,
1477
- kind: track.kind,
1478
- height: track.height ??
1479
- (track.kind === 'bar'
1480
- ? self.conservationTrackHeight
1481
- : self.rowHeight),
1482
- barColor: track.color,
1483
- customColorScheme: track.colors,
1484
- data: this.columnTrackContent.get(track.id)?.data,
1485
- },
1486
- ReactComponent: TrackBlocks,
1487
- }));
1488
- },
1489
- /**
1490
- * #getter
1491
- */
1492
- get tracks() {
1493
- const conservationTrack = {
1494
- id: 'conservation',
1495
- name: 'Conservation',
1496
- kind: 'bar',
1497
- height: self.conservationTrackHeight,
1498
- barColor: 'gray',
1499
- };
1500
- const propertyConservationTrack = {
1501
- id: 'property-conservation',
1502
- name: 'Property conservation',
1503
- kind: 'bar',
1504
- height: self.conservationTrackHeight,
1505
- barColor: '#6a51a3',
1506
- };
1507
- const sequenceLogoTrack = {
1508
- id: 'sequence-logo',
1509
- name: 'Sequence logo',
1510
- kind: 'logo',
1511
- height: self.sequenceLogoTrackHeight,
1512
- };
1513
- return [
1514
- ...this.adapterTrackModels,
1515
- ...this.columnTrackModels,
1516
- ...[
1517
- conservationTrack,
1518
- ...(self.sequenceType === 'amino'
1519
- ? [propertyConservationTrack]
1520
- : []),
1521
- sequenceLogoTrack,
1522
- ].map(model => ({ model, ReactComponent: TrackBlocks })),
1523
- ];
2618
+ /**
2619
+ * #action
2620
+ */
2621
+ doScrollX(deltaX) {
2622
+ this.setScrollX(self.scrollX + deltaX);
1524
2623
  },
1525
2624
  /**
1526
- * #getter
2625
+ * #action
1527
2626
  */
1528
- get turnedOnTracks() {
1529
- return this.tracks.filter(f => !trackIsOff(self.turnedOffTracks, f.model.id));
2627
+ setScrollX(n) {
2628
+ self.scrollX = clamp(n, self.maxScrollX, 0);
1530
2629
  },
1531
2630
  /**
1532
- * #getter
2631
+ * #action
1533
2632
  */
1534
- get showHorizontalScrollbar() {
1535
- return self.msaAreaWidth < self.totalWidth;
2633
+ setColumnTracks(tracks) {
2634
+ self.columnTracks.replace(tracks);
1536
2635
  },
1537
2636
  /**
1538
- * #method
1539
- * Return a row-specific letter at a visible column, or undefined if gap.
1540
- *
1541
- * @param rowName - The name of the row
1542
- * @param visibleCol - The visible column index (what the user sees on screen)
1543
- * @returns The letter at that position, or undefined if it's a gap
2637
+ * #action
2638
+ * replace the row table, which the model keeps as the JSON string
2639
+ * `data.treeMetadata` (see docs/layers.md)
1544
2640
  */
1545
- visibleColToRowLetter(rowName, visibleCol) {
1546
- const { rowMap, blanks } = self;
1547
- return rowMap.get(rowName)?.[visibleColToGlobalCol(blanks, visibleCol)];
2641
+ setRowData(rowData) {
2642
+ self.data.setTreeMetadata(JSON.stringify(rowData));
1548
2643
  },
1549
2644
  /**
1550
- * #method
1551
- * Convert a visible column to a row-specific sequence position (0-based).
1552
- * Returns undefined if the position is a gap in the sequence.
1553
- *
1554
- * CROSS-REPO CONTRACT: this and the sibling coordinate converters
1555
- * (seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) are used
1556
- * by jbrowse-plugin-protein3d to translate between alignment columns and
1557
- * structure/sequence residue positions across gaps. Keep them stable.
1558
- *
1559
- * @param rowName - The name of the row
1560
- * @param visibleCol - The visible column index
1561
- * @returns The sequence position (0-based), or undefined if it's a gap
2645
+ * #action
2646
+ * replace what the viewer's marks read from the row table
1562
2647
  */
1563
- visibleColToSeqPos(rowName, visibleCol) {
1564
- return visibleColToSeqPosForRow({
1565
- rowName,
1566
- visibleCol,
1567
- rowMap: self.rowMap,
1568
- blanks: self.blanks,
1569
- });
2648
+ setEncodings(encodings) {
2649
+ self.encodings.replace(encodings);
1570
2650
  },
1571
2651
  /**
1572
- * #method
1573
- * Convert a visible column to a row-specific sequence position (1-based).
1574
- * Returns undefined if the position is a gap in the sequence.
1575
- *
1576
- * @param rowName - The name of the row
1577
- * @param visibleCol - The visible column index
1578
- * @returns The sequence position (1-based), or undefined if it's a gap
2652
+ * #action
2653
+ * replace the panels drawn between the tree and the alignment
1579
2654
  */
1580
- visibleColToSeqPosOneBased(rowName, visibleCol) {
1581
- const val = this.visibleColToSeqPos(rowName, visibleCol);
1582
- return val !== undefined ? val + 1 : undefined;
2655
+ setRowPanels(panels) {
2656
+ self.rowPanels.replace(panels);
1583
2657
  },
1584
2658
  /**
1585
- * #method
1586
- * Convert a global column index to a visible column index.
1587
- * Returns undefined if the column is hidden (in blanks).
1588
- * This is the inverse of visibleColToGlobalCol.
1589
- *
1590
- * @param globalCol - The global column index in the full MSA
1591
- * @returns The visible column index, or undefined if the column is hidden
2659
+ * #action
2660
+ * replace the alignment<->structure correspondence (see docs/layers.md)
1592
2661
  */
1593
- globalColToVisibleCol(globalCol) {
1594
- const { blanks, hideGapsEffective } = self;
1595
- if (!hideGapsEffective) {
1596
- return globalCol;
1597
- }
1598
- return globalColToVisibleCol(blanks, globalCol);
2662
+ setResidueMappings(mappings) {
2663
+ self.residueMappings.replace(mappings);
1599
2664
  },
1600
2665
  /**
1601
- * #method
1602
- * Convert a sequence position (ungapped) to a global column index.
1603
- *
1604
- * @param rowName - The name of the row
1605
- * @param seqPos - The sequence position (0-based, ungapped)
1606
- * @returns The global column index in the full MSA
2666
+ * #action
1607
2667
  */
1608
- seqPosToGlobalCol(rowName, seqPos) {
1609
- const seq = self.rowMap.get(rowName);
1610
- if (seq === undefined) {
1611
- return 0;
1612
- }
1613
- const col = self.seqPosGlobalColIndex.get(rowName)?.[seqPos];
1614
- // past the end of the ungapped sequence: 0 for the degenerate all-gap
1615
- // row, otherwise one past the last column
1616
- return col ?? (seqPos === 0 ? 0 : seq.length);
2668
+ toggleTrack(id) {
2669
+ const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff;
2670
+ self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id, defaultOff));
1617
2671
  },
1618
2672
  /**
1619
- * #method
1620
- * Convert a sequence position (ungapped) directly to a visible column index.
1621
- * This combines seqPosToGlobalCol and globalColToVisibleCol.
1622
- *
1623
- * @param rowName - The name of the row
1624
- * @param seqPos - The sequence position (0-based, ungapped)
1625
- * @returns The visible column index, or undefined if the column is hidden
2673
+ * #action
1626
2674
  */
1627
- seqPosToVisibleCol(rowName, seqPos) {
1628
- const globalCol = this.seqPosToGlobalCol(rowName, seqPos);
1629
- return this.globalColToVisibleCol(globalCol);
2675
+ setStatus(status) {
2676
+ self.status = status;
1630
2677
  },
1631
2678
  }))
1632
2679
  .views(self => ({
1633
2680
  /**
1634
2681
  * #getter
1635
- * widget width minus the tree area gives the space for the MSA
1636
2682
  */
1637
- get msaAreaHeight() {
1638
- return (self.height -
1639
- (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
1640
- self.headerHeight);
2683
+ get verticalScrollbarWidth() {
2684
+ return self.showVerticalScrollbar ? 20 : 0;
1641
2685
  },
1642
2686
  /**
1643
2687
  * #getter
1644
- * total height of track area (px)
2688
+ * width of the alignment canvas: the msa area less the vertical
2689
+ * scrollbar. showHorizontalScrollbar must not read it, since that feeds
2690
+ * msaAreaHeight -> showVerticalScrollbar and would form a cycle
1645
2691
  */
1646
- get totalTrackAreaHeight() {
1647
- return sum(self.turnedOnTracks.map(r => r.model.height));
2692
+ get msaCanvasWidth() {
2693
+ return self.msaAreaWidth - this.verticalScrollbarWidth;
1648
2694
  },
1649
2695
  /**
1650
- * one representative annotation per accession, which is what the legend,
1651
- * the filter dialog and the palettes key off
1652
- */
1653
- get annotationTypes() {
1654
- // first occurrence wins. The representative supplies only the name,
1655
- // description and -- for ordinal segments -- the start that orders
1656
- // them, and those agree across an accession's instances
1657
- const types = new Map();
1658
- for (const annot of self.annotations) {
1659
- if (!types.has(annot.accession)) {
1660
- types.set(annot.accession, annot);
1661
- }
1662
- }
1663
- return types;
1664
- },
1665
- get filteredAnnotations() {
1666
- return self.annotations.filter(r => self.featureFilters.get(r.accession));
1667
- },
1668
- get annotationsByRow() {
1669
- return groupBy(this.filteredAnnotations, r => r.id);
2696
+ * #method
2697
+ * the cell at a visible column and row index, in the coordinates a host
2698
+ * writes highlights in
2699
+ */
2700
+ cellAt(visibleCol, rowIndex) {
2701
+ const column = self.visibleColToGlobalCol(visibleCol);
2702
+ const row = rowIndex === undefined ? undefined : self.leaves[rowIndex]?.data.name;
2703
+ return row === undefined
2704
+ ? { column: column + 1 }
2705
+ : {
2706
+ column: column + 1,
2707
+ row,
2708
+ residue: self.visibleColToSeqPosOneBased(row, visibleCol),
2709
+ letter: self.rowMap.get(row)?.[column],
2710
+ };
1670
2711
  },
1671
- }))
1672
- .views(self => ({
1673
2712
  /**
1674
2713
  * #getter
2714
+ * the cell under the pointer. Public API: MSAViewer's onCellHover
2715
+ * reports it.
1675
2716
  */
1676
- get showVerticalScrollbar() {
1677
- return self.msaAreaHeight < self.totalHeight;
2717
+ get hoveredCell() {
2718
+ const { mouseCol, mouseRow } = self;
2719
+ return mouseCol === undefined
2720
+ ? undefined
2721
+ : this.cellAt(mouseCol, mouseRow);
1678
2722
  },
1679
- }))
1680
- .views(self => ({
1681
2723
  /**
1682
2724
  * #getter
2725
+ * the cell a click pinned. Public API: MSAViewer's onCellClick reports it.
1683
2726
  */
1684
- get verticalScrollbarWidth() {
1685
- return self.showVerticalScrollbar ? 20 : 0;
2727
+ get clickedCell() {
2728
+ const { mouseClickCol, mouseClickRow } = self;
2729
+ return mouseClickCol === undefined
2730
+ ? undefined
2731
+ : this.cellAt(mouseClickCol, mouseClickRow);
1686
2732
  },
1687
2733
  /**
1688
2734
  * #getter
1689
- * width of the alignment canvas itself: the msa area less the vertical
1690
- * scrollbar sitting in it. Not usable from showHorizontalScrollbar, which
1691
- * feeds msaAreaHeight -> showVerticalScrollbar and would close a cycle
2735
+ * the columns on screen. Public API: MSAViewer's onViewportChange reports
2736
+ * it.
1692
2737
  */
1693
- get msaCanvasWidth() {
1694
- return self.msaAreaWidth - this.verticalScrollbarWidth;
2738
+ get viewport() {
2739
+ const { scrollX, colWidth, numColumns, viewInitialized } = self;
2740
+ if (numColumns === 0 || !viewInitialized) {
2741
+ return undefined;
2742
+ }
2743
+ const { xStart, xEnd } = visibleColRange({
2744
+ offsetX: -scrollX,
2745
+ blockWidth: this.msaCanvasWidth,
2746
+ colWidth,
2747
+ });
2748
+ const last = Math.max(0, Math.min(xEnd, numColumns) - 1);
2749
+ return {
2750
+ startColumn: self.visibleColToGlobalCol(Math.min(xStart, last)) + 1,
2751
+ endColumn: self.visibleColToGlobalCol(last) + 1,
2752
+ };
1695
2753
  },
1696
2754
  /**
1697
2755
  * #getter
1698
2756
  * ordinal segment types (exons etc.), ordered by sequence position so
1699
- * exon-1..exon-14 read left-to-right; colored by alternating shade and
1700
- * labeled by number rather than each getting a distinct hue + legend row
2757
+ * exon-1..exon-14 run left-to-right; colored by alternating shade and
2758
+ * labeled by number, with no legend row
1701
2759
  */
1702
2760
  get segmentDomainTypes() {
1703
2761
  return [...self.annotationTypes.values()]
@@ -1723,9 +2781,38 @@ function stateModelFactory() {
1723
2781
  get strokePalette() {
1724
2782
  return transform(this.fillPalette, ([key, val]) => [
1725
2783
  key,
1726
- colord(val).darken(0.1).toHex(),
2784
+ outlineColor(val),
1727
2785
  ]);
1728
2786
  },
2787
+ /**
2788
+ * #getter
2789
+ * the encoding coloring the overlay's spans, undefined when none does,
2790
+ * which leaves each span the color its accession takes in `fillPalette`
2791
+ */
2792
+ get featureFillEncoding() {
2793
+ return this.resolvedEncodings.find(e => e.channel === 'featureFill');
2794
+ },
2795
+ /**
2796
+ * #getter
2797
+ * the fill and outline of every feature's span: its own GFF `color=`
2798
+ * first, then the `featureFill` scale, then the accession palette.
2799
+ * Computed once per change of the features, the encodings or the palette
2800
+ */
2801
+ get featureColors() {
2802
+ return featureColorMap(self.filteredAnnotations, this.featureFillEncoding, this.fillPalette);
2803
+ },
2804
+ /**
2805
+ * #getter
2806
+ * the text the `featureLabel` channel draws inside each span, undefined
2807
+ * when no encoding names the channel. A data channel, so it draws
2808
+ * whether or not the residue letters do
2809
+ */
2810
+ get featureLabels() {
2811
+ const encoding = this.resolvedEncodings.find(e => e.channel === 'featureLabel');
2812
+ return encoding
2813
+ ? featureLabelMap(self.filteredAnnotations, encoding.field)
2814
+ : undefined;
2815
+ },
1729
2816
  /**
1730
2817
  * #getter
1731
2818
  * accession -> number drawn on each segment band: the trailing number of
@@ -1740,24 +2827,92 @@ function stateModelFactory() {
1740
2827
  /**
1741
2828
  * #getter
1742
2829
  * the domain types currently drawn on the alignment (filtered-on), shared
1743
- * by the on-screen legend and the SVG export legend. Ordinal segments
1744
- * (exons) are excluded — they read as a numbered gene model, not a color
1745
- * key — so this is the categorical types ordered by sequence position
2830
+ * by the on-screen legend and the SVG export legend: the categorical types
2831
+ * ordered by sequence position. Ordinal segments (exons) are numbered on
2832
+ * the band instead
1746
2833
  */
1747
2834
  get visibleDomainTypes() {
1748
2835
  return this.categoricalDomainTypes
1749
- .filter(d => self.featureFilters.get(d.accession))
2836
+ .filter(d => !self.turnedOffFeatures.get(d.accession))
1750
2837
  .toSorted((a, b) => a.start - b.start);
1751
2838
  },
2839
+ /**
2840
+ * #getter
2841
+ * the categorical color keys drawn for this view, shared by the on-screen
2842
+ * legend overlay and the SVG export's reserved column. The domain overlay
2843
+ * produces the first, listing the `featureFill` scale where an encoding
2844
+ * names one. Every field a row-table encoding or a row panel reads
2845
+ * produces one more, so two channels over one field, or two strips over
2846
+ * it, list that field once. A strip's `legend` names the title it lists
2847
+ * under, so strips over eight fields with one set of colors list one
2848
+ * legend between them
2849
+ */
2850
+ get legends() {
2851
+ const { featureFillEncoding, fillPalette, visibleDomainTypes } = this;
2852
+ const byKey = new Map();
2853
+ // the accession palette keys on no field, so it takes a key of its own
2854
+ const add = (field, entries, id) => {
2855
+ if (entries.length === 0) {
2856
+ return;
2857
+ }
2858
+ const key = field === undefined ? 'domains' : `field:${field}`;
2859
+ const legend = byKey.get(key);
2860
+ if (legend) {
2861
+ const seen = new Set(legend.entries.map(e => e.id));
2862
+ legend.entries.push(...entries.filter(e => !seen.has(e.id)));
2863
+ }
2864
+ else {
2865
+ byKey.set(key, {
2866
+ id: id ?? (field === undefined ? 'domains' : `rowData-${field}`),
2867
+ title: field ?? 'Domains',
2868
+ entries: [...entries],
2869
+ });
2870
+ }
2871
+ };
2872
+ // the overlay draws the domain key; with no columns it draws nothing,
2873
+ // and a features panel taking the same colors lists them instead
2874
+ if (self.actuallyShowDomains && self.numColumns > 0) {
2875
+ add(featureFillEncoding?.field, featureFillEncoding
2876
+ ? featureFillEncoding.legend
2877
+ : visibleDomainTypes.map(d => ({
2878
+ id: d.accession,
2879
+ label: d.name,
2880
+ color: fillPalette[d.accession],
2881
+ })), 'domains');
2882
+ }
2883
+ for (const { channel, field, legend } of this.resolvedEncodings) {
2884
+ if (!featureChannels.has(channel)) {
2885
+ add(field, legend);
2886
+ }
2887
+ }
2888
+ for (const { legendTitle, legend } of this.resolvedRowPanels) {
2889
+ add(legendTitle, legend);
2890
+ }
2891
+ return [...byKey.values()];
2892
+ },
2893
+ /**
2894
+ * #getter
2895
+ * whether the overlay marks each domain with a bar under its row instead
2896
+ * of filling the row behind the letters. Letter-color mode hands the
2897
+ * background to the color scheme, so a filled box would paint over it and
2898
+ * leave the setting with nothing to show. Sub-row layout already stacks
2899
+ * the boxes clear of the letters, and with the letters too small to draw
2900
+ * the filled box is the only thing left to read.
2901
+ */
2902
+ get domainUnderline() {
2903
+ return (self.actuallyShowDomains &&
2904
+ !self.bgColor &&
2905
+ !self.subFeatureRows &&
2906
+ self.showMsaLetters);
2907
+ },
1752
2908
  /**
1753
2909
  * #getter
1754
2910
  * every filtered-on annotation resolved to the visible column span it is
1755
2911
  * drawn across, keyed by row name. Each row is ordered longest-first so a
1756
- * short domain nested inside a long one draws on top of it rather than
1757
- * under it. Resolving these once here rather than inside each canvas
1758
- * block removes a per-feature, per-block sequence position conversion
1759
- * from every redraw, and gives the letter renderer the band colors it
1760
- * needs to keep residues readable on top of the boxes.
2912
+ * nested short domain draws on top, and each band carries the lane the
2913
+ * sub-row layout puts it in. Resolved once here instead of per canvas
2914
+ * block per redraw; the letter renderer also reads the band colors to
2915
+ * pick legible letter colors.
1761
2916
  */
1762
2917
  get domainBands() {
1763
2918
  const { blanks } = self;
@@ -1766,26 +2921,27 @@ function stateModelFactory() {
1766
2921
  const rowBands = annotations
1767
2922
  .toSorted((a, b) => len(b) - len(a))
1768
2923
  .map(annotation => {
1769
- // annotation positions are 1-based and inclusive. Both ends count
1770
- // the visible columns in front of a global column, so endCol is
1771
- // the exclusive column after the last residue's own column --
1772
- // the band stops there rather than stretching across a following
1773
- // gap run -- and a residue whose column is itself hidden
1774
- // collapses onto the neighbouring boundary instead of dropping
1775
- // the band. A band whose every column is hidden spans nothing
1776
- // and is left out.
1777
- const startCol = visibleColsBefore(blanks, self.seqPosToGlobalCol(name, annotation.start - 1));
1778
- const endCol = visibleColsBefore(blanks, self.seqPosToGlobalCol(name, annotation.end - 1) + 1);
2924
+ // annotation positions are 1-based and inclusive. endCol is
2925
+ // exclusive, one past the last residue's column, so the band does
2926
+ // not extend over a following gap run. An endpoint in a hidden
2927
+ // column moves to the neighboring boundary. Bands with no visible
2928
+ // columns, or naming a missing row, are dropped.
2929
+ const start = self.seqPosToGlobalCol(name, annotation.start - 1);
2930
+ const end = self.seqPosToGlobalCol(name, annotation.end - 1);
2931
+ if (start === undefined || end === undefined) {
2932
+ return undefined;
2933
+ }
2934
+ const startCol = visibleColsBefore(blanks, start);
2935
+ const endCol = visibleColsBefore(blanks, end + 1);
1779
2936
  return endCol > startCol
1780
2937
  ? { annotation, startCol, endCol }
1781
2938
  : undefined;
1782
2939
  })
1783
2940
  .filter(notEmpty)
1784
- // numbered after the drop, so a band that resolved to nothing does
1785
- // not leave an empty sub-row behind it
2941
+ // numbered after filtering, so a dropped band leaves no gap
1786
2942
  .map((band, stackIndex) => ({ ...band, stackIndex }));
1787
2943
  if (rowBands.length > 0) {
1788
- bands.set(name, rowBands);
2944
+ bands.set(name, packDomainLanes(rowBands));
1789
2945
  }
1790
2946
  }
1791
2947
  return bands;
@@ -1809,11 +2965,13 @@ function stateModelFactory() {
1809
2965
  get mouseOverDomains() {
1810
2966
  const { mouseCol } = self;
1811
2967
  const name = self.mouseOverRowName;
1812
- return name !== undefined && mouseCol !== undefined
1813
- ? (this.domainBands.get(name) ?? [])
1814
- .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
1815
- .map(b => b.annotation)
1816
- : [];
2968
+ if (name === undefined || mouseCol === undefined) {
2969
+ return noDomains;
2970
+ }
2971
+ const hits = (this.domainBands.get(name) ?? [])
2972
+ .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
2973
+ .map(b => b.annotation);
2974
+ return hits.length > 0 ? hits : noDomains;
1817
2975
  },
1818
2976
  /**
1819
2977
  * #getter
@@ -1829,8 +2987,7 @@ function stateModelFactory() {
1829
2987
  * #getter
1830
2988
  * row indices highlighted by the current tree hover (a hovered internal
1831
2989
  * node highlights every tip below it). Shared by the tree and MSA overlay
1832
- * canvases so they cannot disagree, and resolved through the memoized
1833
- * name->index map rather than rebuilding a lookup on each mouse move.
2990
+ * canvases, via the memoized name->index map.
1834
2991
  */
1835
2992
  get hoveredRowIndices() {
1836
2993
  const { hoveredTreeNode, rowNamesSet } = self;
@@ -1843,8 +3000,8 @@ function stateModelFactory() {
1843
3000
  /**
1844
3001
  * #getter
1845
3002
  * contiguous runs of `highlightedColumns`, so a run of highlighted columns
1846
- * draws as one bordered band. Computed here because the overlay canvas
1847
- * redraws on every mouse move while the highlight itself rarely changes.
3003
+ * draws as one bordered band. Memoized because the overlay canvas redraws
3004
+ * on every mouse move.
1848
3005
  */
1849
3006
  get highlightedColumnRuns() {
1850
3007
  const { highlightedColumns } = self;
@@ -1868,12 +3025,13 @@ function stateModelFactory() {
1868
3025
  * is dropped. Row names that match no row are ignored.
1869
3026
  */
1870
3027
  get resolvedHighlights() {
1871
- const { blanks, rowNamesSet, rowMap } = self;
1872
- const toVisible = (globalCol) => {
1873
- const visible = self.globalColToVisibleCol(globalCol);
1874
- return visible ?? visibleColsBefore(blanks, globalCol);
1875
- };
1876
- return self.highlights.flatMap(({ row, rows, start, end, label, color }) => {
3028
+ const { rowNamesSet, transientHighlights } = self;
3029
+ // persisted highlights first, so transient ones draw on top
3030
+ const all = [
3031
+ ...self.highlights,
3032
+ ...Object.values(transientHighlights).flat(),
3033
+ ];
3034
+ return all.flatMap(({ row, rows, start, end, label, color }) => {
1877
3035
  const base = { label, color };
1878
3036
  if (rows) {
1879
3037
  const rowIndices = rows
@@ -1884,68 +3042,238 @@ function stateModelFactory() {
1884
3042
  if (start === undefined || end === undefined) {
1885
3043
  return [];
1886
3044
  }
1887
- let startGlobal = start - 1;
1888
- let endGlobal = end - 1;
1889
- if (row !== undefined) {
1890
- if (!rowMap.has(row)) {
1891
- return [];
3045
+ const span = self.visibleSpan({ row, start, end });
3046
+ return span ? [{ ...base, ...span, rowIndices: [] }] : [];
3047
+ });
3048
+ },
3049
+ /**
3050
+ * #method
3051
+ * per-column summary statistics: consensus residue and its identity
3052
+ * fraction, both conservation scores, gap fraction, and the sorted non-gap
3053
+ * residue distribution. undefined past the end of the alignment or for an
3054
+ * all-gap column.
3055
+ */
3056
+ columnStatsAt(col) {
3057
+ const { colStats, conservation, propertyConservation } = self;
3058
+ return columnStats({
3059
+ col,
3060
+ colStats,
3061
+ conservation,
3062
+ propertyConservation,
3063
+ });
3064
+ },
3065
+ /**
3066
+ * #getter
3067
+ * `columnStatsAt` for the hovered column, undefined when nothing is
3068
+ * hovered
3069
+ */
3070
+ get mouseOverColumnStats() {
3071
+ const { mouseCol, colStats, conservation, propertyConservation } = self;
3072
+ return mouseCol === undefined
3073
+ ? undefined
3074
+ : columnStats({
3075
+ col: mouseCol,
3076
+ colStats,
3077
+ conservation,
3078
+ propertyConservation,
3079
+ });
3080
+ },
3081
+ /**
3082
+ * #method
3083
+ */
3084
+ getRowData(name) {
3085
+ return {
3086
+ data: self.MSA?.getRowData(name),
3087
+ rowData: self.rowDataOf(name),
3088
+ };
3089
+ },
3090
+ /**
3091
+ * #getter
3092
+ * each encoding with its scale resolved against the values its field
3093
+ * takes: a feature channel reads them across the features drawn, every
3094
+ * other channel across the row table. Resolved once per change of that
3095
+ * table or the encodings, never per row per frame.
3096
+ */
3097
+ get resolvedEncodings() {
3098
+ const rows = Object.values(self.rowData);
3099
+ const features = self.filteredAnnotations;
3100
+ return self.encodings.map(encoding => ({
3101
+ ...encoding,
3102
+ ...resolveScale(encoding.scale, featureChannels.has(encoding.channel)
3103
+ ? features
3104
+ .map(a => featureField(a, encoding.field))
3105
+ .filter(notEmpty)
3106
+ : rows.map(row => row?.[encoding.field]).filter(notEmpty)),
3107
+ }));
3108
+ },
3109
+ /**
3110
+ * #getter
3111
+ * each row panel with its scale resolved against the values its field
3112
+ * takes across the row table, giving the color per row name, the pixel
3113
+ * column it draws in, and the entries its legend lists. Resolved once
3114
+ * per change of that table or the panels, never per block per frame.
3115
+ */
3116
+ get resolvedRowPanels() {
3117
+ const rows = Object.entries(self.rowData);
3118
+ const { fillPalette, featureFillEncoding, featureLabels } = this;
3119
+ const annotations = self.filteredAnnotations;
3120
+ let offsetX = 0;
3121
+ return self.rowPanels.map((panel, index) => {
3122
+ const width = rowPanelWidth(panel, self.rowHeight);
3123
+ const base = { id: `rowpanel-${index}`, width, offsetX };
3124
+ offsetX += width;
3125
+ if (panel.kind === 'features') {
3126
+ const color = panel.encoding?.color;
3127
+ const encoding = color
3128
+ ? resolveFeatureScale(color.field, color.scale, annotations)
3129
+ : featureFillEncoding;
3130
+ const label = panel.encoding?.label;
3131
+ const align = panel.transform?.find(t => t.type === 'align');
3132
+ return {
3133
+ ...base,
3134
+ kind: panel.kind,
3135
+ x: panel.x,
3136
+ header: panel.header ?? '',
3137
+ field: encoding?.field,
3138
+ legendTitle: encoding?.field,
3139
+ spans: featurePanelSpans({
3140
+ panel,
3141
+ width,
3142
+ rowHeight: self.rowHeight,
3143
+ colWidth: self.colWidth,
3144
+ domainBands: this.domainBands,
3145
+ annotationsByRow: self.annotationsByRow,
3146
+ shifts: align
3147
+ ? this.featureAlignShifts.get(align.on)
3148
+ : undefined,
3149
+ }),
3150
+ colors: featureColorMap(annotations, encoding, fillPalette),
3151
+ labels: label
3152
+ ? featureLabelMap(annotations, label)
3153
+ : featureLabels,
3154
+ legend: encoding?.legend ??
3155
+ this.visibleDomainTypes.map(d => ({
3156
+ id: d.accession,
3157
+ label: d.name,
3158
+ color: fillPalette[d.accession],
3159
+ })),
3160
+ };
3161
+ }
3162
+ const { colorOf, legend } = resolveScale(panel.scale, rows.map(([, row]) => row?.[panel.field]).filter(notEmpty));
3163
+ const colors = new Map();
3164
+ for (const [name, row] of rows) {
3165
+ const value = row?.[panel.field];
3166
+ const color = value === undefined ? undefined : colorOf(value);
3167
+ if (color) {
3168
+ colors.set(name, color);
1892
3169
  }
1893
- startGlobal = self.seqPosToGlobalCol(row, start - 1);
1894
- endGlobal = self.seqPosToGlobalCol(row, end - 1);
1895
3170
  }
1896
- const startCol = toVisible(startGlobal);
1897
- const endVisible = self.globalColToVisibleCol(endGlobal);
1898
- const endCol = endVisible ?? visibleColsBefore(blanks, endGlobal) - 1;
1899
- return startCol <= endCol
1900
- ? [{ ...base, startCol, endCol, rowIndices: [] }]
1901
- : [];
3171
+ return {
3172
+ ...base,
3173
+ kind: panel.kind,
3174
+ field: panel.field,
3175
+ header: panel.header ?? panel.field,
3176
+ legendTitle: panel.legend ?? panel.field,
3177
+ colors,
3178
+ legend,
3179
+ };
1902
3180
  });
1903
3181
  },
1904
3182
  /**
1905
3183
  * #getter
1906
- * per-column summary statistics for the hovered column: consensus residue
1907
- * and its identity fraction, conservation score, gap fraction, and the
1908
- * sorted non-gap residue distribution. undefined when nothing is hovered.
3184
+ * the shift an `align` transform gives each row, keyed by the feature
3185
+ * name it aligns on: the offset putting the first feature of that name
3186
+ * at zero. A row carrying no such feature is absent, and keeps its own
3187
+ * origin.
3188
+ */
3189
+ get featureAlignShifts() {
3190
+ const names = new Set(self.rowPanels.flatMap(panel => panel.kind === 'features' && panel.x === 'position'
3191
+ ? (panel.transform ?? [])
3192
+ .filter(t => t.type === 'align')
3193
+ .map(t => t.on)
3194
+ : []));
3195
+ return new Map([...names].map(on => [
3196
+ on,
3197
+ new Map(Object.entries(self.annotationsByRow)
3198
+ .map(([name, annotations]) => {
3199
+ const hit = annotations.find(a => featureName(a) === on);
3200
+ return hit ? [name, 1 - hit.start] : undefined;
3201
+ })
3202
+ .filter(notEmpty)),
3203
+ ]));
3204
+ },
3205
+ /**
3206
+ * #getter
3207
+ * the color the `tipLabel` channel gives each row, by row name. Undefined
3208
+ * when no encoding names the channel, which leaves the labels the theme's
3209
+ * text color.
1909
3210
  */
1910
- get mouseOverColumnStats() {
1911
- const { mouseCol } = self;
1912
- if (mouseCol === undefined) {
3211
+ get tipLabelColors() {
3212
+ const encoding = this.resolvedEncodings.find(e => e.channel === 'tipLabel');
3213
+ if (!encoding) {
1913
3214
  return undefined;
1914
3215
  }
1915
- const { colStats } = self;
1916
- if (mouseCol >= colStats.numColumns) {
1917
- return undefined;
3216
+ const colors = new Map();
3217
+ for (const [name, row] of Object.entries(self.rowData)) {
3218
+ const value = row?.[encoding.field];
3219
+ const color = value === undefined ? undefined : encoding.colorOf(value);
3220
+ if (color) {
3221
+ colors.set(name, color);
3222
+ }
1918
3223
  }
1919
- const total = colStats.total(mouseCol);
1920
- if (!total) {
3224
+ return colors;
3225
+ },
3226
+ /**
3227
+ * #getter
3228
+ * the wash the `rowTint` channel draws over each row, indexed by row, or
3229
+ * undefined when no encoding names the channel. The overlay draws these,
3230
+ * so a tint stays out of the raster tile cache and its keys.
3231
+ */
3232
+ get rowTints() {
3233
+ const encoding = this.resolvedEncodings.find(e => e.channel === 'rowTint');
3234
+ if (!encoding) {
1921
3235
  return undefined;
1922
3236
  }
1923
- const gaps = colStats.gapCount(mouseCol);
1924
- const distribution = colStats
1925
- .residueEntries(mouseCol)
1926
- .sort((a, b) => b[1] - a[1]);
1927
- const consensus = distribution[0];
1928
- return {
1929
- col: mouseCol,
1930
- total,
1931
- gaps,
1932
- gapFraction: gaps / total,
1933
- conservation: self.conservation[mouseCol] ?? 0,
1934
- propertyConservation: self.propertyConservation[mouseCol],
1935
- consensusLetter: consensus?.[0] ?? '',
1936
- consensusCount: consensus?.[1] ?? 0,
1937
- consensusFraction: consensus ? consensus[1] / total : 0,
1938
- distribution,
1939
- };
3237
+ return self.rowNames.map(name => {
3238
+ const value = self.rowDataOf(name)?.[encoding.field];
3239
+ const color = value === undefined ? undefined : encoding.colorOf(value);
3240
+ if (!color) {
3241
+ return undefined;
3242
+ }
3243
+ return withAlpha(color, rowTintAlpha);
3244
+ });
1940
3245
  },
1941
3246
  /**
1942
- * #method
3247
+ * #getter
3248
+ * the color the `branch` channel gives each tree edge, by the node id at
3249
+ * the edge's far end, or undefined when no encoding names the channel. A
3250
+ * node takes the field value its tips agree on, so a clade of one value
3251
+ * colors down from where it splits off, and a node whose tips disagree or
3252
+ * whose value has no color is absent and draws in the default color.
3253
+ *
3254
+ * The pass runs over the whole tree, never `root`, so a collapsed or
3255
+ * focused clade keeps the color the full tree gives it.
1943
3256
  */
1944
- getRowData(name) {
1945
- return {
1946
- data: self.MSA?.getRowData(name),
1947
- treeMetadata: self.treeMetadata[name],
1948
- };
3257
+ get branchColors() {
3258
+ const encoding = this.resolvedEncodings.find(e => e.channel === 'branch');
3259
+ if (!encoding) {
3260
+ return undefined;
3261
+ }
3262
+ const order = preorder(self.tree);
3263
+ const values = new Map();
3264
+ const colors = new Map();
3265
+ for (let i = order.length - 1; i >= 0; i--) {
3266
+ const node = order[i];
3267
+ const value = node.children.length > 0
3268
+ ? sharedValue(node.children.map(child => values.get(child)))
3269
+ : self.rowDataOf(node.name)?.[encoding.field];
3270
+ values.set(node, value);
3271
+ const color = value === undefined ? undefined : encoding.colorOf(value);
3272
+ if (color) {
3273
+ colors.set(node.id, color);
3274
+ }
3275
+ }
3276
+ return colors;
1949
3277
  },
1950
3278
  }))
1951
3279
  .actions(self => ({
@@ -1958,27 +3286,43 @@ function stateModelFactory() {
1958
3286
  /**
1959
3287
  * #action
1960
3288
  */
1961
- setConservationTrackHeight(arg) {
1962
- self.conservationTrackHeight = arg;
3289
+ setHideHeader(arg) {
3290
+ self.hideHeader = arg;
3291
+ },
3292
+ /**
3293
+ * #action
3294
+ * focus the subtree a click `y` pixels down the tree overview lands on.
3295
+ * A click inside the box already drawn there clears the focus, the way
3296
+ * clicking the focused branch again does.
3297
+ */
3298
+ treeOverviewClick(y) {
3299
+ const hit = self.treeOverviewHit(y);
3300
+ const focus = self.treeOverviewFocusRows;
3301
+ const inside = !!focus && !!hit && hit.rows[0] >= focus[0] && hit.rows[1] <= focus[1];
3302
+ self.setShowOnly(inside || !hit ? undefined : hit.id);
1963
3303
  },
1964
3304
  /**
1965
3305
  * #action
3306
+ * resize every track sharing a `heightKey`; see `trackHeights`
1966
3307
  */
1967
- setSequenceLogoTrackHeight(arg) {
1968
- self.sequenceLogoTrackHeight = arg;
3308
+ setTrackHeight(heightKey, height) {
3309
+ self.trackHeights.set(heightKey, height);
1969
3310
  },
1970
3311
  /**
1971
3312
  * #action
1972
- * Return to the import form: every property off `preservedOnReset`
1973
- * (data, filehandles, collapsed/showOnly, zoom, scroll, ...) goes back
1974
- * to its default, then the file-derived volatiles applySnapshot cannot
1975
- * reach are cleared by hand.
3313
+ * Return to the import form: reset every property not in
3314
+ * `preservedOnReset` to its default, then clear the file-derived
3315
+ * volatiles applySnapshot does not touch.
1976
3316
  */
1977
3317
  reset() {
3318
+ self.resetCount++;
3319
+ self.clearWarnings();
3320
+ self.setStatus(undefined);
1978
3321
  applySnapshot(self, Object.fromEntries(Object.entries(getSnapshot(self)).filter(([key]) => preservedOnReset.has(key))));
1979
3322
  self.setError(undefined);
1980
3323
  self.setAnnotations([]);
1981
3324
  self.setHighlightedColumns(undefined);
3325
+ self.transientHighlights = {};
1982
3326
  self.setMousePos(undefined, undefined);
1983
3327
  self.setMouseClickPos(undefined, undefined);
1984
3328
  self.setHoveredTreeNode(undefined);
@@ -1992,20 +3336,31 @@ function stateModelFactory() {
1992
3336
  const blob = new Blob([html], { type: 'image/svg+xml' });
1993
3337
  saveAs(blob, exportFileName(self.msaFilehandle, 'svg'));
1994
3338
  },
1995
- initFilter(arg) {
1996
- if (!self.featureFilters.has(arg)) {
1997
- self.featureFilters.set(arg, true);
3339
+ /**
3340
+ * #action
3341
+ * show or hide an annotation type. Only hidden types are recorded; see
3342
+ * `turnedOffFeatures`
3343
+ */
3344
+ setFilter(accession, shown) {
3345
+ if (shown) {
3346
+ self.turnedOffFeatures.delete(accession);
3347
+ }
3348
+ else {
3349
+ self.turnedOffFeatures.set(accession, true);
1998
3350
  }
1999
- },
2000
- setFilter(arg, flag) {
2001
- self.featureFilters.set(arg, flag);
2002
3351
  },
2003
3352
  /**
2004
3353
  * #action
2005
3354
  */
2006
3355
  fit() {
2007
- this.fitVertically();
2008
- this.fitHorizontally();
3356
+ // two passes: each direction's viewport depends on whether the other
3357
+ // overflows (the minimap takes height, the vertical scrollbar width)
3358
+ transaction(() => {
3359
+ for (let pass = 0; pass < 2; pass++) {
3360
+ this.fitHorizontally();
3361
+ this.fitVertically();
3362
+ }
3363
+ });
2009
3364
  },
2010
3365
  /**
2011
3366
  * #action
@@ -2016,34 +3371,64 @@ function stateModelFactory() {
2016
3371
  }
2017
3372
  self.scrollY = 0;
2018
3373
  },
3374
+ /**
3375
+ * #action
3376
+ * zoom and scroll so a span fills the alignment's width, in highlight
3377
+ * coordinates (see visibleSpan). Does nothing before the viewer knows
3378
+ * its width, or for a span that resolves to no visible column.
3379
+ */
3380
+ zoomToRegion(region) {
3381
+ const span = self.visibleSpan(region);
3382
+ if (!span || !self.viewInitialized) {
3383
+ return;
3384
+ }
3385
+ transaction(() => {
3386
+ self.colWidth = clamp(self.msaCanvasWidth / (span.endCol - span.startCol + 1), minColWidth, maxCellSize);
3387
+ self.scrollX = clamp(-span.startCol * self.colWidth, self.maxScrollX, 0);
3388
+ });
3389
+ },
2019
3390
  /**
2020
3391
  * #action
2021
3392
  */
2022
3393
  fitHorizontally() {
2023
3394
  if (self.numColumns > 0) {
2024
- // fitting to msaAreaWidth instead left the last ~20px of columns off
2025
- // the right edge -- and short of the width that shows a minimap, so
2026
- // nothing on screen said they were there
3395
+ // msaCanvasWidth excludes the vertical scrollbar's 20px
2027
3396
  self.colWidth = clamp(self.msaCanvasWidth / self.numColumns, minColWidth, maxCellSize);
2028
3397
  }
2029
3398
  self.scrollX = 0;
2030
3399
  },
2031
3400
  afterCreate() {
2032
- // seed the highlighted-columns overlay from the declarative property so
2033
- // a shared snapshot/URL opens with those columns highlighted (the
2034
- // volatile highlightedColumns can later be driven by genome-hover sync)
2035
3401
  if (self.highlightColumns?.length) {
2036
3402
  self.setHighlightedColumns(self.highlightColumns);
2037
3403
  }
3404
+ // The `collapse` and `focus` clade marks seed the collapsed list and
3405
+ // the subtree in focus, which the tree, `hideGapsEffective` and the
3406
+ // alignment all read. The tree arrives with the model for inline data
3407
+ // and later for a filehandle, so the seeding waits for it and then runs
3408
+ // once: expanding a seeded clade sticks, and the record collapses it
3409
+ // again only on reload.
3410
+ let cladesSeeded = false;
2038
3411
  addDisposer(self, autorun(() => {
2039
- for (const key of self.annotationTypes.keys()) {
2040
- this.initFilter(key);
3412
+ if (cladesSeeded ||
3413
+ !self.dataInitialized ||
3414
+ self.clades.length === 0) {
3415
+ return;
3416
+ }
3417
+ cladesSeeded = true;
3418
+ for (const { mark, nodeId } of self.resolvedClades) {
3419
+ if (nodeId === undefined) {
3420
+ continue;
3421
+ }
3422
+ if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
3423
+ self.toggleCollapsed(nodeId);
3424
+ }
3425
+ else if (mark === 'focus') {
3426
+ self.setShowOnly(nodeId);
3427
+ }
2041
3428
  }
2042
3429
  }));
2043
- // track the live device pixel ratio so canvas backing stores re-scale
2044
- // when the window moves between monitors or the browser zooms. The
2045
- // matchMedia query is pinned to the current ratio, so each change
2046
- // re-registers against the new one to keep tracking further moves.
3430
+ // the matchMedia query is pinned to the current device pixel ratio, so
3431
+ // each change re-registers against the new one
2047
3432
  if (typeof window !== 'undefined' &&
2048
3433
  typeof window.matchMedia === 'function') {
2049
3434
  const query = () => window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
@@ -2063,32 +3448,41 @@ function stateModelFactory() {
2063
3448
  * Fetch a filehandle whenever it changes, and hand the text to
2064
3449
  * `onLoad`.
2065
3450
  *
2066
- * Every loader carries a generation guard: the filehandle can change
2067
- * mid-fetch (a second file picked while the first is still in flight),
2068
- * and the slower earlier request must not clobber the data, status, or
2069
- * loading flag belonging to the newer one.
3451
+ * A generation guard keeps a slower superseded request from overwriting
3452
+ * the newer one's data, status or loading flag. A superseded or cleared
3453
+ * request is aborted and clears its status line.
3454
+ *
3455
+ * `clearFilehandle` runs after a local file loads, since a blob has no
3456
+ * URL to refetch, and when the user cancels, which returns the view to
3457
+ * the import form.
2070
3458
  *
2071
- * `clearFilehandle` serves two purposes for the loaders that pass it.
2072
- * A local file has no URL to refetch from, so the handle is dropped
2073
- * once its bytes are in the model; and a fetch the user cancels drops
2074
- * it too, returning the view to the import form rather than leaving a
2075
- * stuck spinner.
3459
+ * `what` names the layer in a failure message. An `optional` layer
3460
+ * (annotations, row metadata) that fails adds a warning instead of an
3461
+ * error.
2076
3462
  */
2077
- const loadOnFilehandleChange = ({ getFilehandle, onLoad, setLoading, clearFilehandle, }) => {
3463
+ const loadOnFilehandleChange = ({ what, getFilehandle, onLoad, setLoading, clearFilehandle, optional = false, }) => {
2078
3464
  let generation = 0;
3465
+ let cancelInFlight;
3466
+ addDisposer(self, () => {
3467
+ cancelInFlight?.();
3468
+ });
2079
3469
  addDisposer(self, autorun(async () => {
2080
3470
  const filehandle = getFilehandle();
2081
- // a cleared filehandle bumps the generation too, so a reset()
2082
- // mid-download invalidates the in-flight fetch instead of letting
2083
- // its onLoad land on the emptied model. That also orphans the
2084
- // invalidated run's `finally`, so the loading flag is cleared
2085
- // here on its behalf
2086
3471
  const current = ++generation;
3472
+ cancelInFlight?.();
3473
+ cancelInFlight = undefined;
2087
3474
  if (!filehandle) {
2088
3475
  setLoading?.(false);
2089
3476
  return;
2090
3477
  }
2091
- const isCurrent = () => current === generation;
3478
+ const isCurrent = () => current === generation && isAlive(self);
3479
+ const controller = new AbortController();
3480
+ cancelInFlight = () => {
3481
+ controller.abort();
3482
+ if (isAlive(self)) {
3483
+ self.setStatus(undefined);
3484
+ }
3485
+ };
2092
3486
  try {
2093
3487
  setLoading?.(true);
2094
3488
  self.setError(undefined);
@@ -2096,7 +3490,7 @@ function stateModelFactory() {
2096
3490
  if (isCurrent()) {
2097
3491
  self.setStatus(status);
2098
3492
  }
2099
- });
3493
+ }, { controller });
2100
3494
  if (isCurrent()) {
2101
3495
  transaction(() => {
2102
3496
  onLoad(text);
@@ -2113,18 +3507,25 @@ function stateModelFactory() {
2113
3507
  }
2114
3508
  else {
2115
3509
  console.error(e);
2116
- self.setError(e);
3510
+ if (optional) {
3511
+ self.addWarning(`The ${what} did not load: ${e}`);
3512
+ }
3513
+ else {
3514
+ self.setError(e);
3515
+ }
2117
3516
  }
2118
3517
  }
2119
3518
  }
2120
3519
  finally {
2121
3520
  if (isCurrent()) {
3521
+ cancelInFlight = undefined;
2122
3522
  setLoading?.(false);
2123
3523
  }
2124
3524
  }
2125
3525
  }));
2126
3526
  };
2127
3527
  loadOnFilehandleChange({
3528
+ what: 'tree',
2128
3529
  getFilehandle: () => self.treeFilehandle,
2129
3530
  onLoad: text => {
2130
3531
  self.setTree(text);
@@ -2136,38 +3537,49 @@ function stateModelFactory() {
2136
3537
  self.setTreeFilehandle(undefined);
2137
3538
  },
2138
3539
  });
2139
- // treeMetadata is decorative and has no import-form step of its own, so
2140
- // it keeps no loading flag and nothing to return to on cancel
3540
+ // treeMetadata has no import-form step, so no loading flag or cancel
2141
3541
  loadOnFilehandleChange({
3542
+ what: 'row metadata',
3543
+ optional: true,
2142
3544
  getFilehandle: () => self.treeMetadataFilehandle,
2143
3545
  onLoad: text => {
2144
3546
  self.setTreeMetadata(text);
2145
3547
  },
2146
3548
  });
2147
- // autorun parses inline gff text from data.gff
3549
+ // parses data.gff into annotations. Clearing the text clears only the
3550
+ // annotations this autorun applied, not ones a host set directly
3551
+ let appliedGFF = false;
2148
3552
  addDisposer(self, autorun(() => {
2149
3553
  const gffText = self.data.gff;
2150
3554
  if (gffText) {
2151
3555
  try {
2152
3556
  self.applyGFFText(gffText);
3557
+ appliedGFF = true;
2153
3558
  }
2154
3559
  catch (e) {
2155
3560
  console.error(e);
2156
- self.setError(e);
3561
+ self.addWarning(`The annotations did not parse: ${e}`);
2157
3562
  }
2158
3563
  }
3564
+ else if (appliedGFF) {
3565
+ appliedGFF = false;
3566
+ self.setAnnotations([]);
3567
+ }
2159
3568
  }));
2160
- // gffFilehandle carries overlay annotations
3569
+ // gffFilehandle loads into data.gff, which the autorun above parses
2161
3570
  loadOnFilehandleChange({
3571
+ what: 'annotations',
3572
+ optional: true,
2162
3573
  getFilehandle: () => self.gffFilehandle,
2163
3574
  onLoad: text => {
2164
- self.applyGFFText(text);
3575
+ self.setGFF(text);
2165
3576
  },
2166
3577
  clearFilehandle: () => {
2167
3578
  self.setGFFFilehandle(undefined);
2168
3579
  },
2169
3580
  });
2170
3581
  loadOnFilehandleChange({
3582
+ what: 'alignment',
2171
3583
  getFilehandle: () => self.msaFilehandle,
2172
3584
  onLoad: text => {
2173
3585
  self.setMSA(text);
@@ -2179,19 +3591,12 @@ function stateModelFactory() {
2179
3591
  self.setMSAFilehandle(undefined);
2180
3592
  },
2181
3593
  });
2182
- // Keep the parse chain warm: reading self.columns transitively holds
2183
- // self.MSA (parseMSA) computed alive, so it is parsed once per data
2184
- // change rather than re-parsed on every non-reactive access. Do not
2185
- // remove.
3594
+ // Keep computeds alive that are read outside reactions, so they are not
3595
+ // recomputed on every access: self.columns (and through it the
3596
+ // parseMSA result), and the column statistics that dynamic color
3597
+ // schemes and the mousemove-driven hover tooltip read.
2186
3598
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
2187
3599
  // xref problem https://github.com/GMOD/react-msaview/issues/75
2188
- //
2189
- // The column statistics are held for the same reason whenever something
2190
- // reads them off the reactive path: dynamic color schemes, and the hover
2191
- // tooltip, which is read from a mousemove handler. Without this the
2192
- // tooltip's cost depends on whether some visible track happens to be
2193
- // observing them, so closing the conservation track would silently turn
2194
- // every mouse move into a full-alignment recount.
2195
3600
  addDisposer(self, autorun(() => {
2196
3601
  if (self.colorSchemeName.includes('dynamic')) {
2197
3602
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -2209,27 +3614,39 @@ function stateModelFactory() {
2209
3614
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2210
3615
  self.columns;
2211
3616
  }));
2212
- // autorun: when autoTreeAreaWidth is set and no tree is drawn, shrink the
2213
- // tree area to fit the row labels rather than padding it to the fixed
2214
- // default. Gated on noTree/!drawTree so it never fights the treeWidth sync
2215
- // below (treeAreaWidth here depends only on labelsWidth, not treeWidth).
3617
+ // with autoTreeAreaWidth and no tree drawn, size the tree area to the
3618
+ // labels. Gated on noTree/!drawTree so it does not loop with the
3619
+ // treeWidth autorun below
2216
3620
  addDisposer(self, autorun(() => {
2217
3621
  if (self.autoTreeAreaWidth &&
2218
3622
  (self.noTree || !self.drawTree) &&
2219
3623
  self.labelsWidth) {
2220
- self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12);
3624
+ self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12 + self.cladeGutterWidth);
2221
3625
  }
2222
3626
  }));
2223
- // autorun synchronizes treeWidth with treeAreaWidth
3627
+ // treeWidth follows the tree area less the labels. A non-default
3628
+ // snapshot treeWidth (jbrowse-plugin-msaview opens at 100 in a 200px
3629
+ // area) is kept until the tree area changes. Not a getter, because
3630
+ // labelsWidth is measured off leaves laid out against treeWidth.
3631
+ let pinnedAreaWidth = self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth;
2224
3632
  addDisposer(self, autorun(() => {
2225
- self.setTreeWidth(Math.max(50, self.treeAreaWidth - self.labelsWidth - 10 - self.marginLeft));
3633
+ const areaWidth = self.treeAreaWidth;
3634
+ const labelsWidth = self.labelsWidth;
3635
+ if (pinnedAreaWidth === areaWidth) {
3636
+ return;
3637
+ }
3638
+ pinnedAreaWidth = undefined;
3639
+ self.setTreeWidth(Math.max(50, areaWidth -
3640
+ labelsWidth -
3641
+ 10 -
3642
+ self.marginLeft -
3643
+ self.cladeGutterWidth));
2226
3644
  }));
2227
3645
  },
2228
3646
  }))
2229
3647
  .postProcessSnapshot(({ data, columnTracks, ...rest }) => ({
2230
- // per-property defaults are stripped by the stripDefault helper; the only thing
2231
- // it can't express is this cross-field rule: drop inline tree/msa/metadata
2232
- // when a sibling filehandle can refetch them, keeping sessions/URLs small
3648
+ // stripDefault handles per-property defaults; this drops inline documents
3649
+ // whose filehandle can refetch them
2233
3650
  ...rest,
2234
3651
  ...smallColumnTracks(columnTracks),
2235
3652
  data: {