react-msaview 7.0.0 → 8.1.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 (335) hide show
  1. package/README.md +11 -10
  2. package/bundle/index.js +122 -111
  3. package/bundle/index.js.map +4 -4
  4. package/dist/calculateBlocks.js +5 -3
  5. package/dist/calculateBlocks.js.map +1 -1
  6. package/dist/colorSchemes.js +1 -2
  7. package/dist/colorSchemes.js.map +1 -1
  8. package/dist/columnStats.d.ts +28 -0
  9. package/dist/columnStats.js +34 -0
  10. package/dist/columnStats.js.map +1 -0
  11. package/dist/components/DragHandle.d.ts +3 -2
  12. package/dist/components/DragHandle.js +11 -5
  13. package/dist/components/DragHandle.js.map +1 -1
  14. package/dist/components/Loading.js +2 -8
  15. package/dist/components/Loading.js.map +1 -1
  16. package/dist/components/MSAView.js +17 -6
  17. package/dist/components/MSAView.js.map +1 -1
  18. package/dist/components/MSAViewer.d.ts +64 -7
  19. package/dist/components/MSAViewer.js +128 -23
  20. package/dist/components/MSAViewer.js.map +1 -1
  21. package/dist/components/PortalTooltip.d.ts +11 -0
  22. package/dist/components/PortalTooltip.js +29 -0
  23. package/dist/components/PortalTooltip.js.map +1 -0
  24. package/dist/components/ResizeHandles.js +11 -29
  25. package/dist/components/ResizeHandles.js.map +1 -1
  26. package/dist/components/SequenceTextArea.js +3 -4
  27. package/dist/components/SequenceTextArea.js.map +1 -1
  28. package/dist/components/Track.js +43 -24
  29. package/dist/components/Track.js.map +1 -1
  30. package/dist/components/dialogs/AboutDialog.js +6 -6
  31. package/dist/components/dialogs/AboutDialog.js.map +1 -1
  32. package/dist/components/dialogs/AnnotationFileDialog.js +5 -2
  33. package/dist/components/dialogs/AnnotationFileDialog.js.map +1 -1
  34. package/dist/components/dialogs/ExportSVGDialog.js +6 -4
  35. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  36. package/dist/components/dialogs/FeatureDialog.js +8 -3
  37. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  38. package/dist/components/dialogs/TrackInfoDialog.js +1 -1
  39. package/dist/components/dialogs/TrackInfoDialog.js.map +1 -1
  40. package/dist/components/header/GappynessSlider.js +6 -11
  41. package/dist/components/header/GappynessSlider.js.map +1 -1
  42. package/dist/components/header/Header.js +4 -1
  43. package/dist/components/header/Header.js.map +1 -1
  44. package/dist/components/header/LoadWarnings.js +6 -23
  45. package/dist/components/header/LoadWarnings.js.map +1 -1
  46. package/dist/components/header/ScrollZoomIcons.d.ts +6 -0
  47. package/dist/components/header/ScrollZoomIcons.js +35 -0
  48. package/dist/components/header/ScrollZoomIcons.js.map +1 -0
  49. package/dist/components/header/TreeSettingsMenu.js +1 -3
  50. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  51. package/dist/components/header/UnshareableDataWarning.js +2 -4
  52. package/dist/components/header/UnshareableDataWarning.js.map +1 -1
  53. package/dist/components/header/ZoomControls.js +49 -6
  54. package/dist/components/header/ZoomControls.js.map +1 -1
  55. package/dist/components/header/getAnnotationsMenu.js +3 -6
  56. package/dist/components/header/getAnnotationsMenu.js.map +1 -1
  57. package/dist/components/header/settingsMenuItems.js +12 -9
  58. package/dist/components/header/settingsMenuItems.js.map +1 -1
  59. package/dist/components/import/ImportForm.js +1 -1
  60. package/dist/components/import/ImportForm.js.map +1 -1
  61. package/dist/components/import/util.js +1 -2
  62. package/dist/components/import/util.js.map +1 -1
  63. package/dist/components/minimap/Minimap.js +1 -2
  64. package/dist/components/minimap/Minimap.js.map +1 -1
  65. package/dist/components/msa/AnnotationLegend.js +27 -19
  66. package/dist/components/msa/AnnotationLegend.js.map +1 -1
  67. package/dist/components/msa/MSACanvas.js +2 -4
  68. package/dist/components/msa/MSACanvas.js.map +1 -1
  69. package/dist/components/msa/MSACanvasBlock.js +13 -15
  70. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  71. package/dist/components/msa/MSAMouseoverCanvas.js +2 -1
  72. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  73. package/dist/components/msa/domainBandCursor.d.ts +7 -11
  74. package/dist/components/msa/domainBandCursor.js +7 -11
  75. package/dist/components/msa/domainBandCursor.js.map +1 -1
  76. package/dist/components/msa/drawFeatureSpans.d.ts +59 -0
  77. package/dist/components/msa/drawFeatureSpans.js +113 -0
  78. package/dist/components/msa/drawFeatureSpans.js.map +1 -0
  79. package/dist/components/msa/legendRows.d.ts +8 -0
  80. package/dist/components/msa/legendRows.js +16 -0
  81. package/dist/components/msa/legendRows.js.map +1 -0
  82. package/dist/components/msa/msaRaster.d.ts +24 -31
  83. package/dist/components/msa/msaRaster.js +30 -39
  84. package/dist/components/msa/msaRaster.js.map +1 -1
  85. package/dist/components/msa/packDomainLanes.d.ts +20 -0
  86. package/dist/components/msa/packDomainLanes.js +33 -0
  87. package/dist/components/msa/packDomainLanes.js.map +1 -0
  88. package/dist/components/msa/renderBoxFeatureCanvasBlock.d.ts +3 -1
  89. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +50 -87
  90. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  91. package/dist/components/msa/renderHighlights.d.ts +15 -13
  92. package/dist/components/msa/renderHighlights.js +43 -20
  93. package/dist/components/msa/renderHighlights.js.map +1 -1
  94. package/dist/components/msa/renderMSABlock.js +12 -12
  95. package/dist/components/msa/renderMSABlock.js.map +1 -1
  96. package/dist/components/msa/tileColor.js +4 -7
  97. package/dist/components/msa/tileColor.js.map +1 -1
  98. package/dist/components/msa/useMsaBlockMouse.d.ts +5 -3
  99. package/dist/components/msa/useMsaBlockMouse.js +6 -6
  100. package/dist/components/msa/useMsaBlockMouse.js.map +1 -1
  101. package/dist/components/rowpanels/RowPanelBlock.d.ts +9 -0
  102. package/dist/components/rowpanels/RowPanelBlock.js +86 -0
  103. package/dist/components/rowpanels/RowPanelBlock.js.map +1 -0
  104. package/dist/components/rowpanels/RowPanelHeaders.d.ts +12 -0
  105. package/dist/components/rowpanels/RowPanelHeaders.js +38 -0
  106. package/dist/components/rowpanels/RowPanelHeaders.js.map +1 -0
  107. package/dist/components/{msa/ColumnStats.d.ts → rowpanels/RowPanels.d.ts} +2 -2
  108. package/dist/components/rowpanels/RowPanels.js +32 -0
  109. package/dist/components/rowpanels/RowPanels.js.map +1 -0
  110. package/dist/components/rowpanels/headerLayout.d.ts +1 -0
  111. package/dist/components/rowpanels/headerLayout.js +5 -0
  112. package/dist/components/rowpanels/headerLayout.js.map +1 -0
  113. package/dist/components/rowpanels/renderFeaturePanel.d.ts +21 -0
  114. package/dist/components/rowpanels/renderFeaturePanel.js +37 -0
  115. package/dist/components/rowpanels/renderFeaturePanel.js.map +1 -0
  116. package/dist/components/rowpanels/renderRowPanel.d.ts +23 -0
  117. package/dist/components/rowpanels/renderRowPanel.js +22 -0
  118. package/dist/components/rowpanels/renderRowPanel.js.map +1 -0
  119. package/dist/components/rowpanels/renderStrip.d.ts +20 -0
  120. package/dist/components/rowpanels/renderStrip.js +26 -0
  121. package/dist/components/rowpanels/renderStrip.js.map +1 -0
  122. package/dist/components/tracks/TrackBlocks.js +3 -5
  123. package/dist/components/tracks/TrackBlocks.js.map +1 -1
  124. package/dist/components/tracks/TrackTooltipContent.d.ts +15 -0
  125. package/dist/components/tracks/TrackTooltipContent.js +109 -0
  126. package/dist/components/tracks/TrackTooltipContent.js.map +1 -0
  127. package/dist/components/tracks/drawTracks.d.ts +4 -4
  128. package/dist/components/tracks/drawTracks.js +25 -39
  129. package/dist/components/tracks/drawTracks.js.map +1 -1
  130. package/dist/components/tracks/useTrackHover.d.ts +22 -0
  131. package/dist/components/tracks/useTrackHover.js +30 -0
  132. package/dist/components/tracks/useTrackHover.js.map +1 -0
  133. package/dist/components/tree/CladeLabels.d.ts +12 -0
  134. package/dist/components/tree/CladeLabels.js +48 -0
  135. package/dist/components/tree/CladeLabels.js.map +1 -0
  136. package/dist/components/tree/TreeBranchMenu.js +1 -1
  137. package/dist/components/tree/TreeBranchMenu.js.map +1 -1
  138. package/dist/components/tree/TreeCanvas.js +5 -4
  139. package/dist/components/tree/TreeCanvas.js.map +1 -1
  140. package/dist/components/tree/TreeCanvasBlock.js +4 -21
  141. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  142. package/dist/components/tree/TreeNodeMenu.js +1 -1
  143. package/dist/components/tree/TreeNodeMenu.js.map +1 -1
  144. package/dist/components/tree/TreeOverview.d.ts +14 -0
  145. package/dist/components/tree/TreeOverview.js +75 -0
  146. package/dist/components/tree/TreeOverview.js.map +1 -0
  147. package/dist/components/tree/TreeRuler.js +9 -13
  148. package/dist/components/tree/TreeRuler.js.map +1 -1
  149. package/dist/components/tree/cladeBrackets.d.ts +25 -0
  150. package/dist/components/tree/cladeBrackets.js +51 -0
  151. package/dist/components/tree/cladeBrackets.js.map +1 -0
  152. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js +2 -2
  153. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js.map +1 -1
  154. package/dist/components/tree/renderTreeCanvas.js +160 -25
  155. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  156. package/dist/components/tree/renderTreeOverview.d.ts +52 -0
  157. package/dist/components/tree/renderTreeOverview.js +138 -0
  158. package/dist/components/tree/renderTreeOverview.js.map +1 -0
  159. package/dist/components/tree/useTreeHover.js +2 -4
  160. package/dist/components/tree/useTreeHover.js.map +1 -1
  161. package/dist/constants.d.ts +16 -0
  162. package/dist/constants.js +52 -22
  163. package/dist/constants.js.map +1 -1
  164. package/dist/createPaletteMap.d.ts +7 -6
  165. package/dist/createPaletteMap.js +10 -9
  166. package/dist/createPaletteMap.js.map +1 -1
  167. package/dist/element.d.ts +13 -0
  168. package/dist/element.js +269 -0
  169. package/dist/element.js.map +1 -0
  170. package/dist/featureFields.d.ts +11 -0
  171. package/dist/featureFields.js +17 -0
  172. package/dist/featureFields.js.map +1 -0
  173. package/dist/flatToTree.js +1 -2
  174. package/dist/flatToTree.js.map +1 -1
  175. package/dist/ggplotPalettes.d.ts +10 -0
  176. package/dist/ggplotPalettes.js +58 -0
  177. package/dist/ggplotPalettes.js.map +1 -1
  178. package/dist/hierarchy.d.ts +27 -0
  179. package/dist/hierarchy.js +66 -9
  180. package/dist/hierarchy.js.map +1 -1
  181. package/dist/index.d.ts +9 -1
  182. package/dist/index.js +5 -3
  183. package/dist/index.js.map +1 -1
  184. package/dist/model/DataModel.js +2 -5
  185. package/dist/model/DataModel.js.map +1 -1
  186. package/dist/model/msaModel.d.ts +3 -2
  187. package/dist/model/msaModel.js +3 -2
  188. package/dist/model/msaModel.js.map +1 -1
  189. package/dist/model/treeModel.d.ts +30 -0
  190. package/dist/model/treeModel.js +37 -1
  191. package/dist/model/treeModel.js.map +1 -1
  192. package/dist/model.d.ts +549 -294
  193. package/dist/model.js +1412 -621
  194. package/dist/model.js.map +1 -1
  195. package/dist/mount.d.ts +11 -0
  196. package/dist/mount.js +22 -0
  197. package/dist/mount.js.map +1 -0
  198. package/dist/neighborJoining.js +8 -32
  199. package/dist/neighborJoining.js.map +1 -1
  200. package/dist/renderToStaticMarkup.d.ts +10 -19
  201. package/dist/renderToStaticMarkup.js +13 -24
  202. package/dist/renderToStaticMarkup.js.map +1 -1
  203. package/dist/renderToSvg.js +180 -56
  204. package/dist/renderToSvg.js.map +1 -1
  205. package/dist/rowCoordinateCalculations.d.ts +4 -6
  206. package/dist/rowCoordinateCalculations.js +4 -6
  207. package/dist/rowCoordinateCalculations.js.map +1 -1
  208. package/dist/scales.d.ts +22 -0
  209. package/dist/scales.js +30 -0
  210. package/dist/scales.js.map +1 -0
  211. package/dist/seqPosToGlobalCol.d.ts +2 -3
  212. package/dist/seqPosToGlobalCol.js +2 -3
  213. package/dist/seqPosToGlobalCol.js.map +1 -1
  214. package/dist/sequenceLogo.d.ts +6 -11
  215. package/dist/sequenceLogo.js +6 -11
  216. package/dist/sequenceLogo.js.map +1 -1
  217. package/dist/stripDefault.d.ts +7 -14
  218. package/dist/stripDefault.js +7 -14
  219. package/dist/stripDefault.js.map +1 -1
  220. package/dist/svgTestUtil.d.ts +186 -70
  221. package/dist/types.d.ts +221 -21
  222. package/dist/umd.d.ts +2 -0
  223. package/dist/umd.js +4 -2
  224. package/dist/umd.js.map +1 -1
  225. package/dist/useCanvasAutorun.d.ts +5 -10
  226. package/dist/useCanvasAutorun.js +5 -10
  227. package/dist/useCanvasAutorun.js.map +1 -1
  228. package/dist/useMsaSvgFigure.d.ts +20 -0
  229. package/dist/useMsaSvgFigure.js +46 -0
  230. package/dist/useMsaSvgFigure.js.map +1 -0
  231. package/dist/useWheelScroll.js +0 -4
  232. package/dist/useWheelScroll.js.map +1 -1
  233. package/dist/util.d.ts +5 -6
  234. package/dist/util.js +13 -6
  235. package/dist/util.js.map +1 -1
  236. package/dist/version.d.ts +1 -1
  237. package/dist/version.js +1 -1
  238. package/package.json +19 -12
  239. package/src/calculateBlocks.ts +5 -3
  240. package/src/colorSchemes.ts +1 -2
  241. package/src/columnStats.ts +58 -0
  242. package/src/components/DragHandle.tsx +18 -7
  243. package/src/components/Loading.tsx +4 -36
  244. package/src/components/MSAView.tsx +19 -5
  245. package/src/components/MSAViewer.tsx +240 -28
  246. package/src/components/PortalTooltip.tsx +47 -0
  247. package/src/components/ResizeHandles.tsx +17 -32
  248. package/src/components/SequenceTextArea.tsx +3 -4
  249. package/src/components/Track.tsx +57 -19
  250. package/src/components/dialogs/AboutDialog.tsx +35 -6
  251. package/src/components/dialogs/AnnotationFileDialog.tsx +10 -1
  252. package/src/components/dialogs/ExportSVGDialog.tsx +8 -3
  253. package/src/components/dialogs/FeatureDialog.tsx +11 -3
  254. package/src/components/dialogs/TrackInfoDialog.tsx +1 -1
  255. package/src/components/header/GappynessSlider.tsx +6 -11
  256. package/src/components/header/Header.tsx +7 -1
  257. package/src/components/header/LoadWarnings.tsx +11 -25
  258. package/src/components/header/ScrollZoomIcons.tsx +52 -0
  259. package/src/components/header/TreeSettingsMenu.tsx +1 -3
  260. package/src/components/header/UnshareableDataWarning.tsx +2 -4
  261. package/src/components/header/ZoomControls.tsx +74 -14
  262. package/src/components/header/getAnnotationsMenu.ts +3 -6
  263. package/src/components/header/settingsMenuItems.ts +13 -8
  264. package/src/components/import/ImportForm.tsx +3 -5
  265. package/src/components/import/ImportFormExamples.tsx +2 -2
  266. package/src/components/import/util.ts +1 -2
  267. package/src/components/minimap/Minimap.tsx +1 -2
  268. package/src/components/msa/AnnotationLegend.tsx +48 -38
  269. package/src/components/msa/MSACanvas.tsx +3 -8
  270. package/src/components/msa/MSACanvasBlock.tsx +13 -16
  271. package/src/components/msa/MSAMouseoverCanvas.tsx +2 -1
  272. package/src/components/msa/domainBandCursor.ts +7 -11
  273. package/src/components/msa/drawFeatureSpans.ts +205 -0
  274. package/src/components/msa/legendRows.ts +24 -0
  275. package/src/components/msa/msaRaster.ts +30 -39
  276. package/src/components/msa/packDomainLanes.ts +36 -0
  277. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +67 -123
  278. package/src/components/msa/renderHighlights.ts +53 -20
  279. package/src/components/msa/renderMSABlock.ts +14 -11
  280. package/src/components/msa/tileColor.ts +4 -7
  281. package/src/components/msa/useMsaBlockMouse.ts +6 -6
  282. package/src/components/rowpanels/RowPanelBlock.tsx +139 -0
  283. package/src/components/rowpanels/RowPanelHeaders.tsx +57 -0
  284. package/src/components/rowpanels/RowPanels.tsx +77 -0
  285. package/src/components/rowpanels/headerLayout.ts +5 -0
  286. package/src/components/rowpanels/renderFeaturePanel.ts +66 -0
  287. package/src/components/rowpanels/renderRowPanel.ts +41 -0
  288. package/src/components/rowpanels/renderStrip.ts +46 -0
  289. package/src/components/tracks/TrackBlocks.tsx +3 -5
  290. package/src/components/tracks/TrackTooltipContent.tsx +156 -0
  291. package/src/components/tracks/drawTracks.ts +25 -50
  292. package/src/components/tracks/useTrackHover.ts +42 -0
  293. package/src/components/tree/CladeLabels.tsx +77 -0
  294. package/src/components/tree/TreeBranchMenu.tsx +1 -2
  295. package/src/components/tree/TreeCanvas.tsx +2 -3
  296. package/src/components/tree/TreeCanvasBlock.tsx +8 -32
  297. package/src/components/tree/TreeNodeMenu.tsx +1 -2
  298. package/src/components/tree/TreeOverview.tsx +106 -0
  299. package/src/components/tree/TreeRuler.tsx +11 -22
  300. package/src/components/tree/cladeBrackets.ts +73 -0
  301. package/src/components/tree/dialogs/TreeNodeInfoDialog.tsx +2 -2
  302. package/src/components/tree/renderTreeCanvas.ts +242 -21
  303. package/src/components/tree/renderTreeOverview.ts +212 -0
  304. package/src/components/tree/useTreeHover.ts +2 -4
  305. package/src/constants.ts +61 -22
  306. package/src/createPaletteMap.ts +10 -9
  307. package/src/element.ts +321 -0
  308. package/src/featureFields.ts +19 -0
  309. package/src/flatToTree.ts +1 -2
  310. package/src/ggplotPalettes.ts +60 -0
  311. package/src/hierarchy.ts +77 -9
  312. package/src/index.ts +31 -7
  313. package/src/model/DataModel.ts +2 -5
  314. package/src/model/msaModel.ts +3 -2
  315. package/src/model/treeModel.ts +42 -0
  316. package/src/model.ts +1677 -643
  317. package/src/mount.tsx +32 -0
  318. package/src/neighborJoining.ts +8 -32
  319. package/src/renderToStaticMarkup.ts +13 -24
  320. package/src/renderToSvg.tsx +335 -68
  321. package/src/rowCoordinateCalculations.ts +4 -6
  322. package/src/scales.ts +47 -0
  323. package/src/seqPosToGlobalCol.ts +2 -3
  324. package/src/sequenceLogo.ts +6 -11
  325. package/src/stripDefault.ts +7 -14
  326. package/src/types.ts +260 -45
  327. package/src/umd.ts +4 -2
  328. package/src/useCanvasAutorun.ts +5 -10
  329. package/src/useMsaSvgFigure.ts +72 -0
  330. package/src/useWheelScroll.ts +0 -4
  331. package/src/util.ts +15 -6
  332. package/src/version.ts +1 -1
  333. package/dist/components/msa/ColumnStats.js +0 -42
  334. package/dist/components/msa/ColumnStats.js.map +0 -1
  335. package/src/components/msa/ColumnStats.tsx +0 -54
package/src/model.ts CHANGED
@@ -9,8 +9,7 @@ import {
9
9
  isAlive,
10
10
  types,
11
11
  } from '@jbrowse/mobx-state-tree'
12
- import { colord } from 'colord'
13
- import { autorun, transaction } from 'mobx'
12
+ import { autorun, computed, transaction } from 'mobx'
14
13
  import {
15
14
  generateNodeIds,
16
15
  gffToAnnotations,
@@ -25,19 +24,28 @@ import { calculateBlocks } from './calculateBlocks.ts'
25
24
  import { clustalXColumnColors } from './clustalX.ts'
26
25
  import colorSchemes from './colorSchemes.ts'
27
26
  import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.ts'
27
+ import { columnStats } from './columnStats.ts'
28
+ import { packDomainLanes } from './components/msa/packDomainLanes.ts'
29
+ import { visibleColRange } from './components/msa/visibleColRange.ts'
28
30
  import TrackBlocks from './components/tracks/TrackBlocks.tsx'
31
+ import { cladeGutterWidth } from './components/tree/cladeBrackets.ts'
32
+ import { scaleBarLength } from './components/tree/scaleBar.ts'
29
33
  import {
34
+ cladeHighlightAlpha,
35
+ cladeHighlightColor,
30
36
  defaultAllowedGappyness,
31
37
  defaultColWidth,
32
38
  defaultColorSchemeName,
33
39
  defaultCurrentAlignment,
34
40
  defaultDrawMsaLetters,
41
+ defaultFeaturePanelWidth,
35
42
  defaultHeight,
36
43
  defaultHideGaps,
37
44
  defaultRowHeight,
38
45
  defaultScrollX,
39
46
  defaultScrollY,
40
47
  defaultScrollZoom,
48
+ defaultScrollZoomAxis,
41
49
  defaultShowDomainLegend,
42
50
  defaultShowDomains,
43
51
  defaultSubFeatureRows,
@@ -50,11 +58,16 @@ import {
50
58
  minLetterColWidth,
51
59
  minLetterRowHeight,
52
60
  minRowHeight,
61
+ rowPanelHeaderHeight,
62
+ rowTintAlpha,
63
+ scrollZoomAxes,
53
64
  segmentFeatureTypes,
54
65
  segmentShades,
66
+ treeScaleBarHeight,
55
67
  } from './constants.ts'
56
68
  import { createPaletteMap } from './createPaletteMap.ts'
57
69
  import { exportFileName } from './exportFileName.ts'
70
+ import { featureField, featureName } from './featureFields.ts'
58
71
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
59
72
  import { flatToTree } from './flatToTree.ts'
60
73
  import {
@@ -65,8 +78,11 @@ import {
65
78
  find,
66
79
  forEachDescendant,
67
80
  hierarchy,
81
+ leafIndex,
68
82
  leaves,
69
83
  maxLength,
84
+ mrca,
85
+ nodeCoveringRows,
70
86
  setBrLength,
71
87
  sort,
72
88
  sum as hierarchySum,
@@ -85,6 +101,7 @@ import {
85
101
  visibleColToGlobalCol,
86
102
  visibleColsBefore,
87
103
  } from './rowCoordinateCalculations.ts'
104
+ import { resolveScale } from './scales.ts'
88
105
  import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
89
106
  import { maxBitsFor } from './sequenceLogo.ts'
90
107
  import { stripDefault } from './stripDefault.ts'
@@ -92,33 +109,53 @@ import {
92
109
  computeRowInsertions,
93
110
  dropBlanks,
94
111
  len,
112
+ outlineColor,
95
113
  skipBlanks,
96
- transform,
114
+ withAlpha,
97
115
  } from './util.ts'
98
116
  import { saveAs } from './vendor/fileSaver.ts'
99
117
  import { parseWuss } from './wuss.ts'
100
118
 
119
+ import type { ColumnStats } from './columnStats.ts'
120
+ import type { ScrollZoomAxis } from './constants.ts'
101
121
  import type { HierarchyNode } from './hierarchy.ts'
102
122
  import type { ExportSvgOptions } from './renderToSvg.tsx'
123
+ import type { ScaleSpec } from './scales.ts'
103
124
  import type {
104
125
  Annotation,
105
126
  Arc,
106
127
  BasicTrack,
128
+ Cell,
129
+ Clade,
107
130
  ResidueMappingProblem,
108
131
  ColumnTrackSpec,
109
132
  DomainBand,
133
+ Encoding,
134
+ EncodingChannel,
110
135
  Highlight,
136
+ Legend,
137
+ LegendEntry,
111
138
  NodeWithIds,
112
139
  NodeWithIdsAndLength,
140
+ Region,
113
141
  ResidueMapping,
114
142
  ResidueSegment,
143
+ ResolvedClade,
144
+ ResolvedEncoding,
115
145
  ResolvedHighlight,
146
+ ResolvedRowPanel,
147
+ RowFeaturesSpec,
148
+ RowPanelSpan,
149
+ RowPanelSpec,
116
150
  RowResidue,
117
151
  StructureResidue,
152
+ TrackKind,
118
153
  UnshareableData,
154
+ Viewport,
119
155
  } from './types.ts'
120
156
  import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types'
121
157
  import type { Instance } from '@jbrowse/mobx-state-tree'
158
+ import type { IComputedValue } from 'mobx'
122
159
  import type { InterProScanResults } from 'msa-parsers'
123
160
 
124
161
  function parseTreeText(text: string) {
@@ -128,16 +165,243 @@ function parseTreeText(text: string) {
128
165
  return parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text)
129
166
  }
130
167
 
131
- // Tracks that start hidden. The sequence logo answers a narrower question than
132
- // conservation does and costs three times the vertical space, so it waits to be
133
- // asked for.
168
+ // The height each kind draws at before the user drags a divider. A text track
169
+ // is absent: it is one alignment row tall and follows rowHeight, so the zoom
170
+ // controls already size it.
171
+ export const defaultTrackHeights: Partial<Record<TrackKind, number>> = {
172
+ bar: 40,
173
+ // taller than a bar track: in a 40px stack of four residues each glyph is
174
+ // too short to identify. Twice the height of the tracks above it, so it
175
+ // starts hidden
176
+ logo: 80,
177
+ arc: 50,
178
+ ruler: 20,
179
+ }
180
+
181
+ // the kinds a divider resizes. The ruler is a fixed scale, and a text track
182
+ // follows the vertical zoom.
183
+ const resizableKinds = new Set<TrackKind>(['bar', 'logo', 'arc'])
184
+
134
185
  const defaultOffTracks = new Set(['sequence-logo', 'position-ruler'])
135
186
 
136
- // base-pair arcs: one color for the nested helices, one for a pseudoknot, whose
137
- // whole point is that it crosses them
187
+ // a data track carries its own height rather than its kind's, so its divider
188
+ // resizes it alone
189
+ const ownHeightKey = (id: string) => `own:${id}`
190
+
191
+ // base-pair arcs: one color for nested helices, one for pseudoknots, which cross
192
+ // them
138
193
  const HELIX_ARC = '#4e79a7'
139
194
  const PSEUDOKNOT_ARC = '#e15759'
140
195
 
196
+ // the fill of a span under a scale that gives its value no color, such as a
197
+ // gene a `map` over gene names leaves out
198
+ const UNSCALED_FEATURE = '#d9d9d9'
199
+
200
+ // the fraction of a span a following one may cover before it takes a lane of
201
+ // its own in a `features` panel
202
+ const laneOverlap = 0.1
203
+
204
+ // a row panel's own width, or a default: the row height, which makes a strip
205
+ // cell square, and a gene neighborhood's width for a features panel
206
+ function rowPanelWidth(panel: RowPanelSpec, rowHeight: number) {
207
+ return (
208
+ panel.width ??
209
+ (panel.kind === 'features' ? defaultFeaturePanelWidth : rowHeight)
210
+ )
211
+ }
212
+
213
+ // the fill and outline of every feature in a list: its own GFF `color=` first,
214
+ // then the scale an encoding resolves over one of its fields, then the
215
+ // accession palette
216
+ function featureColorMap(
217
+ annotations: Annotation[],
218
+ encoding:
219
+ | { field: string; colorOf: (value: string) => string | undefined }
220
+ | undefined,
221
+ fillPalette: Record<string, string>,
222
+ ) {
223
+ const strokes = new Map<string, string>()
224
+ const strokeOf = (fill: string) => {
225
+ const hit = strokes.get(fill)
226
+ if (hit !== undefined) {
227
+ return hit
228
+ }
229
+ const stroke = outlineColor(fill)
230
+ strokes.set(fill, stroke)
231
+ return stroke
232
+ }
233
+ return new Map(
234
+ annotations.map(annotation => {
235
+ const value = encoding
236
+ ? featureField(annotation, encoding.field)
237
+ : undefined
238
+ const fill =
239
+ annotation.color ??
240
+ (value === undefined ? undefined : encoding!.colorOf(value)) ??
241
+ (encoding ? UNSCALED_FEATURE : fillPalette[annotation.accession]!)
242
+ return [annotation, { fill, stroke: strokeOf(fill) }]
243
+ }),
244
+ )
245
+ }
246
+
247
+ // the text a label channel draws inside each span, for the features carrying
248
+ // the field it names
249
+ function featureLabelMap(annotations: Annotation[], field: string) {
250
+ const labels = new Map<Annotation, string>()
251
+ for (const annotation of annotations) {
252
+ const value = featureField(annotation, field)
253
+ if (value !== undefined) {
254
+ labels.set(annotation, value)
255
+ }
256
+ }
257
+ return labels
258
+ }
259
+
260
+ /** what `packDomainLanes` needs to lay a row out, before it has its lane */
261
+ type Unlaned = Omit<RowPanelSpan, 'lane' | 'laneCount'> & {
262
+ startCol: number
263
+ endCol: number
264
+ }
265
+
266
+ /**
267
+ * Lanes for `position: "strandpile"`, gggenomes' `position_strandpile`: the
268
+ * forward features stack above the line and the reverse below it, each strand
269
+ * packing on its own. The deepest row on each side sets the grid every row lays
270
+ * out on, so the line between the strands sits at one height down the panel and
271
+ * a reader can scan it. A feature with no strand piles with the forward ones.
272
+ */
273
+ function strandpileLanes(
274
+ rows: [string, Unlaned[]][],
275
+ ): Map<string, RowPanelSpan[]> {
276
+ const piled = rows.map(([name, bands]) => {
277
+ const reverse = packDomainLanes(
278
+ bands.filter(b => b.annotation.strand === -1),
279
+ )
280
+ const forward = packDomainLanes(
281
+ bands.filter(b => b.annotation.strand !== -1),
282
+ )
283
+ return { name, forward, reverse }
284
+ })
285
+ const up = Math.max(0, ...piled.map(p => p.forward[0]?.laneCount ?? 0))
286
+ const down = Math.max(0, ...piled.map(p => p.reverse[0]?.laneCount ?? 0))
287
+ const laneCount = up + down
288
+ return new Map(
289
+ piled.map(({ name, forward, reverse }) => [
290
+ name,
291
+ [
292
+ // forward level 0 is the lane just above the line, stacking upward
293
+ ...forward.map(b => ({ ...b, lane: up - 1 - b.lane, laneCount })),
294
+ ...reverse.map(b => ({ ...b, lane: up + b.lane, laneCount })),
295
+ ],
296
+ ]),
297
+ )
298
+ }
299
+
300
+ /** one row's spans laid out by the panel's `position` */
301
+ function panelLanes(
302
+ panel: RowFeaturesSpec,
303
+ rows: [string, Unlaned[]][],
304
+ ): Map<string, RowPanelSpan[]> {
305
+ return panel.position === 'strandpile'
306
+ ? strandpileLanes(rows)
307
+ : new Map(rows.map(([name, bands]) => [name, packDomainLanes(bands)]))
308
+ }
309
+
310
+ /**
311
+ * The spans a `features` panel draws, keyed by row name and measured in the
312
+ * panel's own pixels. `column` takes the bands the overlay draws, at the
313
+ * alignment's column width. `position` packs each row's features in its own
314
+ * residue positions, shifted by an `align` transform, and maps the extent they
315
+ * cover across every row onto the panel width, so a row with no alignment
316
+ * still has an x.
317
+ */
318
+ function featurePanelSpans({
319
+ panel,
320
+ width,
321
+ colWidth,
322
+ domainBands,
323
+ annotationsByRow,
324
+ shifts,
325
+ }: {
326
+ panel: RowFeaturesSpec
327
+ width: number
328
+ colWidth: number
329
+ domainBands: Map<string, DomainBand[]>
330
+ annotationsByRow: Record<string, Annotation[]>
331
+ shifts: Map<string, number> | undefined
332
+ }): Map<string, RowPanelSpan[]> {
333
+ if (panel.x === 'column') {
334
+ return panelLanes(
335
+ panel,
336
+ [...domainBands].map(([name, bands]) => [
337
+ name,
338
+ bands.map(band => ({
339
+ ...band,
340
+ xStart: band.startCol * colWidth,
341
+ xEnd: band.endCol * colWidth,
342
+ })),
343
+ ]),
344
+ )
345
+ }
346
+ const shifted = Object.entries(annotationsByRow).map(
347
+ ([name, annotations]) => {
348
+ const shift = shifts?.get(name) ?? 0
349
+ return [
350
+ name,
351
+ annotations.map(annotation => ({
352
+ annotation,
353
+ start: annotation.start - 1 + shift,
354
+ end: annotation.end + shift,
355
+ })),
356
+ ] as const
357
+ },
358
+ )
359
+ let min = Infinity
360
+ let max = -Infinity
361
+ for (const [, features] of shifted) {
362
+ for (const { start, end } of features) {
363
+ min = Math.min(min, start)
364
+ max = Math.max(max, end)
365
+ }
366
+ }
367
+ // a gene arrow ends where its feature does, so the extent maps onto the whole
368
+ // panel less the pixel the rightmost stroke needs
369
+ const drawable = Math.max(1, width - 1)
370
+ const scale = max > min ? drawable / (max - min) : 0
371
+ // a lane opens where a span covers more than a tenth of the one before it,
372
+ // which keeps the genes of an operon on one lane: adjacent genes commonly
373
+ // share a few bases, and a stop codon overlapping the next start reads as a
374
+ // second lane over the whole row
375
+ return panelLanes(
376
+ panel,
377
+ shifted.map(([name, features]) => [
378
+ name,
379
+ features.map(({ annotation, start, end }) => ({
380
+ annotation,
381
+ xStart: (start - min) * scale,
382
+ xEnd: (end - min) * scale,
383
+ startCol: start,
384
+ endCol: end - (end - start) * laneOverlap,
385
+ })),
386
+ ]),
387
+ )
388
+ }
389
+
390
+ // a scale over the values a field takes across the features drawn
391
+ function resolveFeatureScale(
392
+ field: string,
393
+ scale: ScaleSpec | undefined,
394
+ annotations: Annotation[],
395
+ ) {
396
+ return {
397
+ field,
398
+ ...resolveScale(
399
+ scale,
400
+ annotations.map(a => featureField(a, field)).filter(notEmpty),
401
+ ),
402
+ }
403
+ }
404
+
141
405
  // a data track over this size stays in the live model but leaves the snapshot,
142
406
  // the same rule DataModel applies to an inline document
143
407
  function columnTrackSizes(tracks?: readonly ColumnTrackSpec[]) {
@@ -154,17 +418,13 @@ function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
154
418
  * The snapshot properties reset() carries across a return to the import form:
155
419
  * display preferences and layout, nothing derived from the loaded file.
156
420
  *
157
- * reset() applies a default snapshot filtered to this list, so the list is the
158
- * whole decision: a property left off it resets to its default, a visible and
159
- * benign failure. The previous shape — a hand-maintained list of things to
160
- * CLEAR — failed in the dangerous direction: a forgotten property silently
161
- * carried the previous file's state into the next one, and because node ids
162
- * are path-derived (node-0-0-1), a stale `collapsed` or `showOnly` id matched
163
- * a real node in the new tree and folded it. Downstream composed properties
164
- * (e.g. the jbrowse plugin's) are not on the list, so they reset too.
421
+ * reset() applies a default snapshot filtered to this list, so any property
422
+ * left off it resets, including downstream composed ones (e.g. the jbrowse
423
+ * plugin's). Node ids are path-derived (node-0-0-1), so a carried-over
424
+ * `collapsed` or `showOnly` id would match a real node in the next tree and
425
+ * fold it.
165
426
  *
166
- * Exported for modelReset.test.ts, which checks that everything off this list
167
- * matches a freshly created model after reset().
427
+ * Exported for modelReset.test.ts.
168
428
  */
169
429
  export const preservedOnReset = new Set([
170
430
  'id',
@@ -172,6 +432,7 @@ export const preservedOnReset = new Set([
172
432
  'height',
173
433
  'drawMsaLetters',
174
434
  'scrollZoom',
435
+ 'scrollZoomAxis',
175
436
  'bgColor',
176
437
  'colorSchemeName',
177
438
  'showColumnStats',
@@ -182,21 +443,22 @@ export const preservedOnReset = new Set([
182
443
  'showBranchLen',
183
444
  'drawTree',
184
445
  'drawNodeBubbles',
446
+ 'drawNodeLabels',
447
+ 'showTreeOverview',
448
+ 'overviewHeight',
185
449
  'autoTreeAreaWidth',
186
450
  'turnedOffTracks',
451
+ 'trackHeights',
187
452
  'hideGaps',
188
453
  'allowedGappyness',
189
454
  'subFeatureRows',
190
455
  'showDomainLegend',
191
456
  ])
192
457
 
193
- // `turnedOffTracks` records the user's explicit choices only: an id is absent
194
- // until they touch that track, and then its value is whether the track is OFF.
195
- // Reading the default through this is what lets a track ship hidden without
196
- // writing an entry into every snapshot and shared URL.
197
- // A track the file itself supplies says whether it starts hidden, since only
198
- // the file knows how many of them there are: a Pfam seed carries a couple of
199
- // #=GR lines, an Rfam family one per row.
458
+ // `turnedOffTracks` holds only the user's explicit choices, so a hidden-by-default
459
+ // track writes nothing into the snapshot. A file-supplied track passes its own
460
+ // `defaultOff`, since the count varies by file: a Pfam seed has a couple of #=GR
461
+ // lines, an Rfam family one per row.
200
462
  function trackIsOff(
201
463
  turnedOffTracks: { get: (id: string) => boolean | undefined },
202
464
  id: string,
@@ -205,20 +467,144 @@ function trackIsOff(
205
467
  return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id))
206
468
  }
207
469
 
208
- // one array for every "nothing under the pointer", since a fresh [] is a fresh
209
- // value to every observer of it
470
+ // shared empty results, so observers don't see a fresh [] as a change
210
471
  const noDomains: Annotation[] = []
472
+ const noClades: ResolvedClade[] = []
473
+
474
+ /**
475
+ * The rows a clade covers and the node an `mrca` names, or undefined when the
476
+ * clade does not resolve: a tip name the tree does not have or has twice, or a
477
+ * leaf count `tips` disagrees with. `index` and `rowNamesSet` are the memoized
478
+ * passes over the tree.
479
+ */
480
+ function cladeRows(
481
+ clade: Clade,
482
+ root: HierarchyNode<NodeWithIds>,
483
+ index: Map<string, HierarchyNode<NodeWithIds> | undefined>,
484
+ rowNamesSet: Map<string, number>,
485
+ ): { rows: [number, number]; nodeId?: string } | undefined {
486
+ if (clade.range) {
487
+ const [a, b] = clade.range.map(name =>
488
+ index.get(name) ? rowNamesSet.get(name) : undefined,
489
+ )
490
+ if (a === undefined || b === undefined) {
491
+ return undefined
492
+ }
493
+ const rows: [number, number] = a <= b ? [a, b] : [b, a]
494
+ return rows[1] - rows[0] + 1 === clade.tips ? { rows } : undefined
495
+ }
496
+ const node = clade.mrca ? mrca(root, clade.mrca, index) : undefined
497
+ if (!node) {
498
+ return undefined
499
+ }
500
+ const tips = leaves(node)
501
+ if (tips.length !== clade.tips) {
502
+ return undefined
503
+ }
504
+ let first = Infinity
505
+ let last = -Infinity
506
+ for (const tip of tips) {
507
+ const row = rowNamesSet.get(tip.data.name)
508
+ if (row !== undefined) {
509
+ first = Math.min(first, row)
510
+ last = Math.max(last, row)
511
+ }
512
+ }
513
+ return first <= last
514
+ ? { rows: [first, last], nodeId: node.data.id }
515
+ : undefined
516
+ }
211
517
 
212
- // seqPos -> column indexes, per row, hung off the parse the rows came from so
213
- // they are collected with it. A computed would rebuild every row's index on
214
- // each miss, and rebuild all of them again whenever it was read outside a
215
- // reactive context.
518
+ /**
519
+ * Every clade that resolves, with its fill color settled, against the leaf
520
+ * order `rowNamesSet` gives. The tree panel resolves against the displayed
521
+ * rows and the overview against its own, so both take the tree they draw. A
522
+ * `range` record names no node, so `collapse` and `focus`, which need one,
523
+ * drop it.
524
+ */
525
+ function resolveClades(
526
+ clades: Clade[],
527
+ root: HierarchyNode<NodeWithIds>,
528
+ rowNamesSet: Map<string, number>,
529
+ ): ResolvedClade[] {
530
+ const index = leafIndex(root)
531
+ return clades.flatMap(clade => {
532
+ const resolved = cladeRows(clade, root, index, rowNamesSet)
533
+ const seeding = clade.mark === 'collapse' || clade.mark === 'focus'
534
+ return resolved && !(seeding && resolved.nodeId === undefined)
535
+ ? [
536
+ {
537
+ ...resolved,
538
+ mark: clade.mark,
539
+ color: withAlpha(
540
+ clade.color ?? cladeHighlightColor,
541
+ cladeHighlightAlpha,
542
+ ),
543
+ markColor: clade.color,
544
+ label: clade.label,
545
+ },
546
+ ]
547
+ : []
548
+ })
549
+ }
550
+
551
+ /**
552
+ * The tree with the display transforms applied: leaf counts summed, children
553
+ * sorted by branch length, `showOnly` taken as the new root, and each
554
+ * `collapsed` clade folded. The overview builds the same tree without the
555
+ * focus, so it shows the focused subtree inside the whole.
556
+ */
557
+ function buildTreeRoot(
558
+ tree: NodeWithIds,
559
+ collapsed: readonly string[],
560
+ showOnly?: string,
561
+ ) {
562
+ let hier = hierarchy(tree, d => d.children)
563
+ hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1))
564
+ sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1))
565
+
566
+ if (showOnly) {
567
+ const res = find(hier, n => n.data.id === showOnly)
568
+ if (res) {
569
+ hier = res
570
+ }
571
+ }
572
+
573
+ for (const collapsedId of collapsed) {
574
+ const node = find(hier, n => n.data.id === collapsedId)
575
+ if (node) {
576
+ if (node.children) {
577
+ collapse(node)
578
+ } else if (node.parent?.children) {
579
+ node.parent.children = node.parent.children.filter(
580
+ c => c.data.id !== collapsedId,
581
+ )
582
+ }
583
+ }
584
+ }
585
+
586
+ return hier
587
+ }
588
+
589
+ // the inclusive tip indices a node covers, from the row-space extent
590
+ // clusterLayout writes to xMin/xMax as tip centers
591
+ function tipRange(node: HierarchyNode): [number, number] {
592
+ return [Math.round(node.xMin! - 0.5), Math.round(node.xMax! - 0.5)]
593
+ }
594
+
595
+ // the channels reading the feature table; every other channel reads rowData
596
+ const featureChannels = new Set<EncodingChannel>([
597
+ 'featureFill',
598
+ 'featureLabel',
599
+ ])
600
+
601
+ // seqPos -> column indexes per row, keyed on the parse so they are garbage
602
+ // collected with it. A computed would rebuild every row's index when read
603
+ // outside a reactive context.
216
604
  const seqPosIndexCache = new WeakMap<object, Map<string, Int32Array>>()
217
605
 
218
- // A segment asserts a 1:1 run, so its two sides have to be the same length.
219
- // One that is not is malformed data, and the arithmetic below would answer
220
- // anyway -- with a residue that is off by however much the sides disagree. Skip
221
- // it, the same refusal an uncovered position gets.
606
+ // A segment is a 1:1 run. A segment whose sides differ in length is malformed,
607
+ // and the lookups treat it like an uncovered position.
222
608
  function sameLength(segment: ResidueSegment) {
223
609
  return (
224
610
  segment.rowEnd - segment.rowStart ===
@@ -226,11 +612,9 @@ function sameLength(segment: ResidueSegment) {
226
612
  )
227
613
  }
228
614
 
229
- // Does the content need a scrollbar? fit() divides the viewport by the row or
230
- // column count and multiplies it back, so an exact fit lands a fraction of a
231
- // pixel over -- enough for a `>` to answer yes and hand the reader a minimap or
232
- // a scrollbar for half a pixel of nothing, which then shrinks the viewport and
233
- // leaves a gap.
615
+ // fit() divides the viewport by the row or column count and multiplies back, so
616
+ // an exact fit can land a fraction of a pixel over. A plain `>` would then show
617
+ // a scrollbar that shrinks the viewport and leaves a gap.
234
618
  function overflows(content: number, viewport: number) {
235
619
  return content - viewport > 0.5
236
620
  }
@@ -239,6 +623,29 @@ function inRanges(ranges: [number, number][] | undefined, position: number) {
239
623
  return !!ranges?.some(([start, end]) => position >= start && position <= end)
240
624
  }
241
625
 
626
+ // the value every one of a node's children reports, or undefined where one of
627
+ // them has no value or they disagree
628
+ function sharedValue(values: (string | undefined)[]) {
629
+ const [first] = values
630
+ return first !== undefined && values.every(v => v === first)
631
+ ? first
632
+ : undefined
633
+ }
634
+
635
+ // preorder, so reversing the list puts every node after its descendants
636
+ function preorder(tree: NodeWithIds) {
637
+ const order: NodeWithIds[] = []
638
+ const stack = [tree]
639
+ while (stack.length > 0) {
640
+ const node = stack.pop()!
641
+ order.push(node)
642
+ for (const child of node.children) {
643
+ stack.push(child)
644
+ }
645
+ }
646
+ return order
647
+ }
648
+
242
649
  /**
243
650
  * #stateModel MsaView
244
651
  *
@@ -279,10 +686,8 @@ function stateModelFactory() {
279
686
  /**
280
687
  * #property
281
688
  * whether the domain legend is expanded. The legend floats over the
282
- * top-right of the alignment, so on a tall panel it covers real
283
- * residues -- persisting the state is what lets a reader collapse it
284
- * and keep it collapsed, and what lets a session or a figure open with
285
- * it already out of the way.
689
+ * top-right of the alignment and covers residues, so a session or
690
+ * figure can open with it collapsed.
286
691
  */
287
692
  showDomainLegend: stripDefault(types.boolean, defaultShowDomainLegend),
288
693
  /**
@@ -315,6 +720,15 @@ function stateModelFactory() {
315
720
  */
316
721
  scrollZoom: stripDefault(types.boolean, defaultScrollZoom),
317
722
 
723
+ /**
724
+ * #property
725
+ * which cell dimensions a wheel zoom scales, while `scrollZoom` is on
726
+ */
727
+ scrollZoomAxis: stripDefault(
728
+ types.enumeration('ScrollZoomAxis', [...scrollZoomAxes]),
729
+ defaultScrollZoomAxis,
730
+ ),
731
+
318
732
  /**
319
733
  * #property
320
734
  * height of the div containing the view, px
@@ -395,11 +809,20 @@ function stateModelFactory() {
395
809
  * hidden-by-default track adds nothing to the shared URL.
396
810
  */
397
811
  turnedOffTracks: stripDefault(types.map(types.boolean), {}),
812
+
813
+ /**
814
+ * #property
815
+ * the height of every track one divider resizes, keyed by `heightKey`:
816
+ * the `kind` for the computed tracks, `own:<id>` for a data track. A
817
+ * key is absent until the user drags that divider, and
818
+ * `defaultTrackHeights` answers for it until then, so an untouched
819
+ * viewer adds nothing to the shared URL.
820
+ */
821
+ trackHeights: stripDefault(types.map(types.number), {}),
398
822
  /**
399
823
  * #property
400
- * tracks supplied as data rather than computed from the alignment:
401
- * per-column values drawn as bars, or a per-column string drawn as a
402
- * text track. See docs/layers.md
824
+ * tracks supplied as data: per-column values drawn as bars, or a
825
+ * per-column string drawn as a text track. See docs/layers.md
403
826
  */
404
827
  columnTracks: stripDefault(
405
828
  types.array(types.frozen<ColumnTrackSpec>()),
@@ -408,11 +831,10 @@ function stateModelFactory() {
408
831
 
409
832
  /**
410
833
  * #property
411
- * which residue of which structure each row's residues are, as data.
412
- * The viewer cannot infer this -- matching a row to a structure by
413
- * sequence equality fails for a tagged construct, a truncation or a
414
- * subsequence row, and fails in the direction that looks like it
415
- * worked -- so it arrives computed. See docs/layers.md
834
+ * row-to-structure residue correspondence, computed outside the viewer
835
+ * (e.g. from SIFTS). Matching by sequence equality places a tagged
836
+ * construct, a truncation or a subsequence row on the wrong residue.
837
+ * See docs/layers.md
416
838
  */
417
839
  residueMappings: stripDefault(
418
840
  types.array(types.frozen<ResidueMapping>()),
@@ -424,19 +846,14 @@ function stateModelFactory() {
424
846
  * data from the loaded tree/msa/treeMetadata, generally loaded by
425
847
  * autorun
426
848
  */
427
- data: types.optional(DataModelF(), {
428
- tree: '',
429
- msa: '',
430
- treeMetadata: '',
431
- }),
849
+ data: types.optional(DataModelF(), {}),
432
850
 
433
851
  /**
434
852
  * #property
435
853
  * the user's explicit hide choices per annotation accession, keyed by
436
- * accession with the value meaning "off", the same shape as
437
- * `turnedOffTracks`. An accession the user has never touched is absent
438
- * and drawn, so a file of two hundred domain types adds nothing to the
439
- * shared URL until someone filters one out
854
+ * accession with the value meaning "off", like `turnedOffTracks`. An
855
+ * untouched accession is absent and drawn, so the shared URL grows
856
+ * only with the user's filters
440
857
  */
441
858
  turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
442
859
  /**
@@ -457,9 +874,34 @@ function stateModelFactory() {
457
874
  * labeled highlights in 1-based inclusive coordinates: a column span
458
875
  * `{start, end}`, a residue span `{row, start, end}` of a named row,
459
876
  * or a row set `{rows}`, each with an optional `label` and `color`.
460
- * Persists in the snapshot, so a computed answer travels in the URL.
877
+ * Persists in the snapshot and the URL.
461
878
  */
462
879
  highlights: stripDefault(types.array(types.frozen<Highlight>()), []),
880
+ /**
881
+ * #property
882
+ * clades of the tree with a mark drawn over them. `mrca` names tips
883
+ * whose common ancestor is the clade, or `range` its first and last
884
+ * tip in display order, and `tips` is the leaf count the producer
885
+ * measured. `mark` is `highlight`, `bracket`, `collapse` or `focus`.
886
+ * See docs/layers.md
887
+ */
888
+ clades: stripDefault(types.array(types.frozen<Clade>()), []),
889
+ /**
890
+ * #property
891
+ * what the viewer's own marks read from `rowData`:
892
+ * `{channel, field, scale?}` per channel, where `channel` is
893
+ * `tipLabel` or `rowTint`. See docs/layers.md
894
+ */
895
+ encodings: stripDefault(types.array(types.frozen<Encoding>()), []),
896
+ /**
897
+ * #property
898
+ * panels drawn between the tree and the alignment:
899
+ * `{kind: "strip", field, scale?, width?, header?}` colors one cell
900
+ * per row from a `rowData` field, and
901
+ * `{kind: "features", x, encoding?, transform?, width?, header?}`
902
+ * draws the spans the GFF carries. See docs/layers.md
903
+ */
904
+ rowPanels: stripDefault(types.array(types.frozen<RowPanelSpec>()), []),
463
905
  }),
464
906
  )
465
907
  .volatile(() => ({
@@ -467,15 +909,19 @@ function stateModelFactory() {
467
909
  * #volatile
468
910
  */
469
911
  headerHeight: 0,
912
+ /**
913
+ * #volatile
914
+ * leaves the toolbar out, for a host drawing its own controls. Kept out
915
+ * of the snapshot so a link opened in the full app shows the toolbar.
916
+ */
917
+ hideHeader: false,
470
918
  /**
471
919
  * #volatile
472
920
  */
473
921
  status: undefined as { msg: string; onCancel?: () => void } | undefined,
474
922
  /**
475
923
  * #volatile
476
- * high resolution scale factor, helps make canvas look better on hi-dpi
477
- * screens. derived from the device pixel ratio so canvases are crisp on
478
- * retina/4k displays and not needlessly oversized on standard ones
924
+ * canvas scale factor, from the device pixel ratio
479
925
  */
480
926
  highResScaleFactor:
481
927
  typeof window === 'undefined' ? 1 : window.devicePixelRatio,
@@ -544,11 +990,8 @@ function stateModelFactory() {
544
990
 
545
991
  /**
546
992
  * #volatile
547
- * transient highlights keyed by who asked for them. One slot cannot hold
548
- * two sources -- a structure viewer's hover and a genome view's hover
549
- * both want to point at a column, and with one slot whoever clears last
550
- * erases the other's. Keyed by owner, each source adds and removes only
551
- * its own. Not persisted: a hover is not part of the document.
993
+ * transient highlights keyed by owner, so a structure viewer's hover and
994
+ * a genome view's hover each clear only their own. Not persisted.
552
995
  */
553
996
  transientHighlights: {} as Record<string, Highlight[]>,
554
997
 
@@ -557,33 +1000,6 @@ function stateModelFactory() {
557
1000
  */
558
1001
  minimapHeight: 56,
559
1002
 
560
- /**
561
- * #volatile
562
- */
563
- conservationTrackHeight: 40,
564
-
565
- /**
566
- * #volatile
567
- * heights of individual `columnTracks`, by track id. A data track is
568
- * resized on its own: the shared per-kind heights below belong to the
569
- * tracks the viewer computes, and dragging a data track's handle used to
570
- * resize those instead.
571
- */
572
- columnTrackHeights: {} as Record<string, number>,
573
-
574
- /**
575
- * #volatile
576
- * taller than the conservation track by default: the logo spends its
577
- * height on stacked glyphs, and a 40px stack of four residues leaves each
578
- * one too short to identify
579
- */
580
- sequenceLogoTrackHeight: 80,
581
-
582
- /**
583
- * #volatile
584
- */
585
- arcTrackHeight: 50,
586
-
587
1003
  /**
588
1004
  * #volatile
589
1005
  */
@@ -597,36 +1013,31 @@ function stateModelFactory() {
597
1013
 
598
1014
  /**
599
1015
  * #volatile
600
- * load problems the view carried on through: an optional layer that did
601
- * not arrive, an overlay that did not parse. `error` is the other kind --
602
- * it replaces the view, which is right for the alignment and wrong for a
603
- * decorative file
1016
+ * non-fatal load problems: an optional layer that failed to load, an
1017
+ * overlay that failed to parse. `error` replaces the view and is for the
1018
+ * alignment itself
604
1019
  */
605
1020
  warnings: [] as string[],
606
1021
 
607
1022
  /**
608
1023
  * #volatile
609
- * bumped by reset(). The React error boundary above the view keeps its
610
- * caught error until it is remounted, so "Return to import form" did
611
- * nothing after a render error until this became its key
1024
+ * bumped by reset(). The error boundary above the view uses it as its key,
1025
+ * since the boundary keeps its caught error until remounted
612
1026
  */
613
1027
  resetCount: 0,
614
1028
 
615
1029
  /**
616
1030
  * #volatile
617
- * set by a host that restores the loaded documents by its own means --
618
- * a jbrowse session that holds them, a page that refetches them on load.
619
- * `unshareableData` then reports nothing, since what it warns about is a
620
- * link that opens empty, and under such a host the link does not
1031
+ * set by a host that restores the loaded documents itself, such as a
1032
+ * jbrowse session or a page that refetches them. `unshareableData` then
1033
+ * reports nothing
621
1034
  */
622
1035
  hostCarriesData: false,
623
1036
 
624
1037
  /**
625
1038
  * #volatile
626
- * overlay annotations drawn on the alignment, whatever their source.
627
- * Every source -- InterProScan, GFF, a user upload -- converts to this
628
- * flat list before it reaches the model, so nothing downstream of here
629
- * knows which one it came from
1039
+ * overlay annotations drawn on the alignment. InterProScan JSON, GFF and
1040
+ * user uploads all convert to this flat list
630
1041
  */
631
1042
  annotations: [] as Annotation[],
632
1043
  }))
@@ -672,8 +1083,8 @@ function stateModelFactory() {
672
1083
  },
673
1084
  /**
674
1085
  * #action
675
- * high-res scale factor, tracks the device pixel ratio so canvases stay
676
- * crisp when the window moves between monitors or the browser zooms
1086
+ * update the canvas scale factor when the device pixel ratio changes
1087
+ * (moving between monitors, browser zoom)
677
1088
  */
678
1089
  setHighResScaleFactor(arg: number) {
679
1090
  self.highResScaleFactor = arg
@@ -696,11 +1107,13 @@ function stateModelFactory() {
696
1107
 
697
1108
  /**
698
1109
  * #action
699
- * report something the view survived: a layer that failed to load, a
700
- * file that failed to parse
1110
+ * record a non-fatal load problem: a layer that failed to load, a file
1111
+ * that failed to parse. A message already on the list is not added again
701
1112
  */
702
1113
  addWarning(warning: string) {
703
- self.warnings = [...self.warnings, warning]
1114
+ if (!self.warnings.includes(warning)) {
1115
+ self.warnings = [...self.warnings, warning]
1116
+ }
704
1117
  },
705
1118
 
706
1119
  /**
@@ -713,7 +1126,7 @@ function stateModelFactory() {
713
1126
  /**
714
1127
  * #action
715
1128
  * declare that this host restores the loaded documents itself, which
716
- * takes down the "Not in the link" warning. See `hostCarriesData`
1129
+ * hides the "Not in the link" warning. See `hostCarriesData`
717
1130
  */
718
1131
  setHostCarriesData(arg: boolean) {
719
1132
  self.hostCarriesData = arg
@@ -723,9 +1136,9 @@ function stateModelFactory() {
723
1136
  * #action
724
1137
  * set mouse position (row, column) in the MSA
725
1138
  *
726
- * PUBLIC API: a host drives this (and reads the `mouseCol` volatile) to
727
- * sync the alignment's hover with a view of its own -- a genome view, a
728
- * 3D structure. Keep the name and signature stable.
1139
+ * Public API: a host calls this (and reads `mouseCol`) to sync hover with
1140
+ * its own view, such as a genome view or 3D structure. Keep the name and
1141
+ * signature stable.
729
1142
  */
730
1143
  setMousePos(col?: number, row?: number) {
731
1144
  self.mouseCol = col
@@ -736,9 +1149,9 @@ function stateModelFactory() {
736
1149
  * #action
737
1150
  * set highlighted columns
738
1151
  *
739
- * PUBLIC API: jbrowse-plugin-msaview calls this from its
740
- * afterCreateAutoruns to highlight alignment columns, and MSAViewer
741
- * passes its `highlightColumns` prop through it. Not dead code.
1152
+ * Public API: jbrowse-plugin-msaview calls this from its
1153
+ * afterCreateAutoruns, and MSAViewer passes its `highlightColumns` prop
1154
+ * through it.
742
1155
  */
743
1156
  setHighlightedColumns(columns?: number[]) {
744
1157
  self.highlightedColumns = columns
@@ -749,12 +1162,18 @@ function stateModelFactory() {
749
1162
  setHighlights(highlights: Highlight[]) {
750
1163
  self.highlights.replace(highlights)
751
1164
  },
1165
+
752
1166
  /**
753
1167
  * #action
754
- * show `highlights` on behalf of `owner`, replacing whatever that owner
755
- * showed before and leaving every other owner's alone. The object is
756
- * replaced rather than mutated so one assignment is the observable
757
- * change.
1168
+ * replace the clades the viewer marks (see docs/layers.md)
1169
+ */
1170
+ setClades(clades: Clade[]) {
1171
+ self.clades.replace(clades)
1172
+ },
1173
+ /**
1174
+ * #action
1175
+ * show `highlights` for `owner`, replacing that owner's previous ones and
1176
+ * leaving other owners' in place
758
1177
  */
759
1178
  applyHighlight(owner: string, highlights: Highlight[]) {
760
1179
  self.transientHighlights = {
@@ -764,7 +1183,7 @@ function stateModelFactory() {
764
1183
  },
765
1184
  /**
766
1185
  * #action
767
- * drop what `owner` was showing, leaving every other owner's in place
1186
+ * remove `owner`'s highlights, leaving other owners' in place
768
1187
  */
769
1188
  clearHighlight(owner: string) {
770
1189
  if (owner in self.transientHighlights) {
@@ -819,10 +1238,9 @@ function stateModelFactory() {
819
1238
 
820
1239
  /**
821
1240
  * #action
822
- * switch to another alignment of a multi-alignment file (Stockholm). The
823
- * new alignment has its own rows and its own tree, so everything naming
824
- * the old one's -- the collapsed node ids, the subtree in focus, the
825
- * reference row, the scroll position -- goes with it
1241
+ * switch to another alignment of a multi-alignment file (Stockholm).
1242
+ * Clears the collapsed node ids, the subtree in focus, the reference row
1243
+ * and the scroll position, which all refer to the previous alignment
826
1244
  */
827
1245
  setCurrentAlignment(n: number) {
828
1246
  if (n === self.currentAlignment) {
@@ -924,11 +1342,10 @@ function stateModelFactory() {
924
1342
 
925
1343
  /**
926
1344
  * #action
927
- * keep the GFF text the way the alignment and the tree are kept, rather
928
- * than only its parsed annotations. The annotations are volatile, so a
929
- * file opened from disk used to leave no trace in the snapshot at all --
930
- * not the text, and not the filehandle, which is cleared once a blob is
931
- * read. An autorun parses this back into annotations.
1345
+ * store the GFF text in the snapshot like the alignment and tree. The
1346
+ * parsed annotations are volatile and a blob filehandle is cleared once
1347
+ * read, so the text is the only persisted copy. An autorun parses it
1348
+ * into annotations.
932
1349
  */
933
1350
  setGFF(result: string) {
934
1351
  self.data.setGFF(result)
@@ -960,16 +1377,14 @@ function stateModelFactory() {
960
1377
  },
961
1378
  /**
962
1379
  * #getter
963
- * whether this host brings the loaded documents back by means the
964
- * snapshot cannot see, which is what decides whether `unshareableData`
965
- * has anything to warn about.
1380
+ * whether the host restores the loaded documents outside the snapshot.
1381
+ * When true, `unshareableData` is empty.
966
1382
  *
967
- * A simple host flips the `hostCarriesData` volatile. A host whose
968
- * answer depends on how the view was opened overrides this getter in a
969
- * `.views` block of its own composed model -- jbrowse-plugin-msaview's
970
- * indexed-location views refetch from a URL the session holds, while its
971
- * data-store views really are absent from a link someone pastes
972
- * elsewhere. `unshareableData` reads it off `self`, so an override wins.
1383
+ * A simple host sets `hostCarriesData`. A host where this depends on how
1384
+ * the view was opened overrides the getter in its own composed model's
1385
+ * `.views` block; jbrowse-plugin-msaview's indexed-location views refetch
1386
+ * from a URL the session holds, while its data-store views do not.
1387
+ * `unshareableData` reads it off `self`, so an override takes effect.
973
1388
  */
974
1389
  get hostRestoresData() {
975
1390
  return self.hostCarriesData
@@ -992,8 +1407,8 @@ function stateModelFactory() {
992
1407
  * #getter
993
1408
  */
994
1409
  get colorScheme() {
995
- // colorSchemeName is a free string (menus, snapshots, URL params); fall
996
- // back to the default rather than returning undefined on a stale name
1410
+ // colorSchemeName is a free string (menus, snapshots, URL params), so a
1411
+ // stale name falls back to the default
997
1412
  return (
998
1413
  colorSchemes[self.colorSchemeName] ??
999
1414
  colorSchemes[defaultColorSchemeName]!
@@ -1025,29 +1440,23 @@ function stateModelFactory() {
1025
1440
 
1026
1441
  /**
1027
1442
  * #getter
1028
- * the loaded documents this view's own snapshot cannot carry, largest
1029
- * first. A file opened from disk or pasted in becomes inline text, and
1030
- * DataModel drops an inline document past `maxInlineSnapshotBytes`
1031
- * rather than put megabytes of sequence into a session or a URL.
1443
+ * loaded documents left out of the snapshot, largest first. A file opened
1444
+ * from disk or pasted in becomes inline text, and DataModel drops an
1445
+ * inline document past `maxInlineSnapshotBytes`.
1032
1446
  *
1033
- * Dropping it is right. Dropping it silently is what makes a copied link
1034
- * open an empty viewer, so the header says so and the standalone app
1035
- * stops rewriting the address bar while this is non-empty. A document
1036
- * fetched from a URL never appears here whatever its size: the snapshot
1037
- * keeps the filehandle and refetches through it.
1447
+ * The header lists these, and the standalone app stops rewriting the
1448
+ * address bar while the list is non-empty, so a copied link does not
1449
+ * open an empty viewer unannounced. A document fetched from a URL never
1450
+ * appears here, since the snapshot keeps its filehandle.
1038
1451
  *
1039
- * Nothing is unshareable when the host restores the data by its own
1040
- * means (see `hostRestoresData`) -- inside a session that reloads these
1041
- * documents from somewhere the snapshot does not show, the warning is
1042
- * simply wrong.
1452
+ * Empty when `hostRestoresData` is true.
1043
1453
  */
1044
1454
  get unshareableData(): UnshareableData[] {
1045
1455
  if (self.hostRestoresData) {
1046
1456
  return []
1047
1457
  }
1048
1458
  const { data } = self
1049
- // a data track past the limit leaves the snapshot the same way an
1050
- // inline document does, and left unreported the same way too
1459
+ // data tracks past the limit also leave the snapshot
1051
1460
  const trackBytes = columnTrackSizes(self.columnTracks)
1052
1461
  .filter(bytes => bytes > maxInlineSnapshotBytes)
1053
1462
  .reduce((a, b) => a + b, 0)
@@ -1074,13 +1483,14 @@ function stateModelFactory() {
1074
1483
  },
1075
1484
  /**
1076
1485
  * #getter
1077
- * extra per-row attributes, keyed by row name. Parsed defensively: the
1078
- * source is a user-supplied document (treeMetadataFilehandle, or a
1079
- * session snapshot), and this computed is read by labelWidthMap on every
1080
- * layout, so a malformed file would otherwise throw out of rendering and
1081
- * take the whole view down over a decorative field.
1082
- */
1083
- get treeMetadata(): Record<string, Record<string, string> | undefined> {
1486
+ * the row table: extra fields per row, keyed by row name, which the
1487
+ * `encodings` channels read. It is stored as the JSON string
1488
+ * `data.treeMetadata`, the name that travels in existing links, so the
1489
+ * inline size limit and `treeMetadataFilehandle` cover it. labelWidthMap
1490
+ * reads it on every layout, so a malformed user-supplied file returns {}
1491
+ * instead of throwing out of rendering.
1492
+ */
1493
+ get rowData(): Record<string, Record<string, string> | undefined> {
1084
1494
  const text = self.data.treeMetadata
1085
1495
  if (!text) {
1086
1496
  return {}
@@ -1091,10 +1501,31 @@ function stateModelFactory() {
1091
1501
  ? (parsed as Record<string, Record<string, string> | undefined>)
1092
1502
  : {}
1093
1503
  } catch (e) {
1094
- console.error('failed to parse treeMetadata', e)
1504
+ console.error('failed to parse rowData', e)
1095
1505
  return {}
1096
1506
  }
1097
1507
  },
1508
+ /**
1509
+ * #method
1510
+ * one row's fields, the single reader of the row table
1511
+ */
1512
+ rowDataOf(name: string) {
1513
+ return this.rowData[name]
1514
+ },
1515
+ /**
1516
+ * #getter
1517
+ * the field names the row table carries, sorted, for a producer or a UI
1518
+ * choosing one to encode
1519
+ */
1520
+ get rowFields(): string[] {
1521
+ const fields = new Set<string>()
1522
+ for (const row of Object.values(this.rowData)) {
1523
+ for (const field of Object.keys(row ?? {})) {
1524
+ fields.add(field)
1525
+ }
1526
+ }
1527
+ return [...fields].sort((a, b) => a.localeCompare(b))
1528
+ },
1098
1529
  /**
1099
1530
  * #getter
1100
1531
  */
@@ -1144,6 +1575,37 @@ function stateModelFactory() {
1144
1575
  this.leaves.map((leaf, index) => [leaf.data.name, index] as const),
1145
1576
  )
1146
1577
  },
1578
+ /**
1579
+ * #getter
1580
+ * `clades` resolved to the rows each one covers. The tip names resolve
1581
+ * against `tree` rather than `root`, so a clade whose ancestor the user
1582
+ * collapsed keeps its rows. One leaf pass over the tree serves every
1583
+ * clade. A `range` record names no node, so `collapse` and `focus`, which
1584
+ * need one, drop it.
1585
+ */
1586
+ get resolvedClades(): ResolvedClade[] {
1587
+ if (self.clades.length === 0) {
1588
+ return noClades
1589
+ }
1590
+ return resolveClades(
1591
+ self.clades,
1592
+ hierarchy(this.tree, d => d.children),
1593
+ this.rowNamesSet,
1594
+ )
1595
+ },
1596
+ /**
1597
+ * #getter
1598
+ * the pixel column reserved at the right of the tree area for the bracket
1599
+ * mark, which the tip labels and the tree itself stay clear of. Zero
1600
+ * where no clade draws a bar or a label.
1601
+ */
1602
+ get cladeGutterWidth() {
1603
+ return cladeGutterWidth({
1604
+ clades: this.resolvedClades,
1605
+ rowHeight: self.rowHeight,
1606
+ fontSize: this.fontSize,
1607
+ })
1608
+ },
1147
1609
  /**
1148
1610
  * #getter
1149
1611
  */
@@ -1177,46 +1639,143 @@ function stateModelFactory() {
1177
1639
  * #getter
1178
1640
  */
1179
1641
  get root() {
1180
- let hier = hierarchy(this.tree, d => d.children)
1181
- hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1))
1182
- sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1))
1183
-
1184
- if (self.showOnly) {
1185
- const res = find(hier, n => n.data.id === self.showOnly)
1186
- if (res) {
1187
- hier = res
1188
- }
1642
+ return buildTreeRoot(this.tree, self.collapsed, self.showOnly)
1643
+ },
1644
+
1645
+ /**
1646
+ * #getter
1647
+ * height of the band the tree overview draws in, zero when it is off
1648
+ */
1649
+ get treeOverviewHeight() {
1650
+ return self.showTreeOverview ? self.overviewHeight : 0
1651
+ },
1652
+
1653
+ /**
1654
+ * #getter
1655
+ * the whole tree laid out for the overview, or undefined when the
1656
+ * overview is off. The focus is left out, so the focused subtree draws
1657
+ * inside the whole tree, and the collapsed clades are folded, since
1658
+ * those are rows the view no longer has. `x` is in tip-index space and
1659
+ * `len` is a fraction of the root-to-tip length, so one layout serves
1660
+ * any band size.
1661
+ */
1662
+ get treeOverviewLayout() {
1663
+ if (!self.showTreeOverview) {
1664
+ return undefined
1665
+ }
1666
+ const root = buildTreeRoot(this.tree, self.collapsed)
1667
+ const numTips = leaves(root).length
1668
+ clusterLayout(root, numTips, 1)
1669
+ const rootLen = Math.max(root.data.length || 0, 0)
1670
+ const extent = maxLength(root) - rootLen
1671
+ setBrLength(root, -rootLen, extent ? 1 / extent : 0)
1672
+ return {
1673
+ root,
1674
+ numTips,
1675
+ maxDepthToLeaf: calcDepthToLeaf(root),
1676
+ showBranchLen: self.showBranchLen && extent > 0,
1189
1677
  }
1678
+ },
1190
1679
 
1191
- for (const collapsedId of self.collapsed) {
1192
- const node = find(hier, n => n.data.id === collapsedId)
1193
- if (node) {
1194
- if (node.children) {
1195
- collapse(node)
1196
- } else if (node.parent?.children) {
1197
- node.parent.children = node.parent.children.filter(
1198
- c => c.data.id !== collapsedId,
1199
- )
1200
- }
1201
- }
1680
+ /**
1681
+ * #getter
1682
+ * the `clades` highlights in the overview's own row space, which the
1683
+ * focus does not narrow
1684
+ */
1685
+ get treeOverviewClades(): ResolvedClade[] {
1686
+ const layout = this.treeOverviewLayout
1687
+ if (!layout || self.clades.length === 0) {
1688
+ return noClades
1202
1689
  }
1690
+ const rowNames = new Map(
1691
+ leaves(layout.root).map((leaf, index) => [leaf.data.name, index]),
1692
+ )
1693
+ return resolveClades(self.clades, layout.root, rowNames)
1694
+ },
1203
1695
 
1204
- return hier
1696
+ /**
1697
+ * #getter
1698
+ * the inclusive tip rows the focused subtree covers in the overview,
1699
+ * which is the box drawn on it. undefined with no focus
1700
+ */
1701
+ get treeOverviewFocusRows(): [number, number] | undefined {
1702
+ const layout = this.treeOverviewLayout
1703
+ if (!layout || !self.showOnly) {
1704
+ return undefined
1705
+ }
1706
+ const node = find(layout.root, n => n.data.id === self.showOnly)
1707
+ return node ? tipRange(node) : undefined
1708
+ },
1709
+
1710
+ /**
1711
+ * #method
1712
+ * the subtree a point `y` pixels down the tree overview picks: the
1713
+ * deepest one whose tip range covers every row under that pixel, with
1714
+ * the rows it covers. A pixel stands for several tips on a large tree,
1715
+ * which is what keeps the pick off the individual tips. undefined when
1716
+ * the overview is off or the point picks the whole tree.
1717
+ */
1718
+ treeOverviewHit(y: number) {
1719
+ const layout = this.treeOverviewLayout
1720
+ if (!layout) {
1721
+ return undefined
1722
+ }
1723
+ const { root, numTips } = layout
1724
+ const perPixel = numTips / self.overviewHeight
1725
+ const first = clamp(Math.floor(y * perPixel), 0, numTips - 1)
1726
+ const last = clamp(
1727
+ Math.ceil((y + 1) * perPixel) - 1,
1728
+ first,
1729
+ numTips - 1,
1730
+ )
1731
+ let node = nodeCoveringRows(root, first + 0.5, last + 0.5)
1732
+ // focusing one tip leaves a single row on screen, so the pick lifts to
1733
+ // the subtree that tip sits in
1734
+ while (!node.children && node.parent) {
1735
+ node = node.parent
1736
+ }
1737
+ return node === root
1738
+ ? undefined
1739
+ : { id: node.data.id, rows: tipRange(node) }
1205
1740
  },
1206
1741
 
1207
1742
  /**
1208
1743
  * #getter
1209
- * widget width minus the tree area gives the space for the MSA
1744
+ * the pixel column the row panels occupy between the tree and the
1745
+ * alignment, the sum of each record's width
1746
+ */
1747
+ get rowPanelsWidth() {
1748
+ return sum(self.rowPanels.map(p => rowPanelWidth(p, self.rowHeight)))
1749
+ },
1750
+ /**
1751
+ * #getter
1752
+ * height of the band the row panel headers draw in, which is zero with
1753
+ * no row panels and leaves the top area as it was
1754
+ */
1755
+ get rowPanelsHeaderHeight() {
1756
+ return self.rowPanels.length > 0 ? rowPanelHeaderHeight : 0
1757
+ },
1758
+ /**
1759
+ * #getter
1760
+ * widget width minus the tree area and the row panels gives the space
1761
+ * for the MSA
1210
1762
  */
1211
1763
  get msaAreaWidth() {
1212
- return self.width - self.treeAreaWidth - self.resizeHandleWidth
1764
+ return (
1765
+ self.width -
1766
+ self.treeAreaWidth -
1767
+ this.rowPanelsWidth -
1768
+ self.resizeHandleWidth
1769
+ )
1213
1770
  },
1214
1771
 
1215
1772
  /**
1216
1773
  * #getter
1774
+ * the right edge the tip labels end at, which is the tree area less the
1775
+ * margin and the bracket gutter
1217
1776
  */
1218
1777
  get treeAreaWidthMinusMargin() {
1219
- return self.treeAreaWidth - self.marginLeft
1778
+ return self.treeAreaWidth - self.marginLeft - this.cladeGutterWidth
1220
1779
  },
1221
1780
  /**
1222
1781
  * #getter
@@ -1263,7 +1822,7 @@ function stateModelFactory() {
1263
1822
  */
1264
1823
  get insertionPositions() {
1265
1824
  const { blanks, rows } = this
1266
- if (blanks.length === 0 || !self.hideGapsEffective) {
1825
+ if (blanks.length === 0) {
1267
1826
  return new Map<string, { pos: number; letters: string }[]>()
1268
1827
  }
1269
1828
  const result = new Map<string, { pos: number; letters: string }[]>()
@@ -1286,10 +1845,8 @@ function stateModelFactory() {
1286
1845
  },
1287
1846
  /**
1288
1847
  * #getter
1289
- * number of rows the alignment occupies on screen. This is the leaf count,
1290
- * not `rows.length`: a tree leaf with no matching MSA row still takes up a
1291
- * row of vertical space (drawn blank), so row hit-testing and fit-to-height
1292
- * must count it.
1848
+ * number of rows on screen: the leaf count, which includes tree leaves
1849
+ * with no matching MSA row (drawn blank), unlike `rows.length`.
1293
1850
  */
1294
1851
  get numRows() {
1295
1852
  return this.leaves.length
@@ -1298,12 +1855,8 @@ function stateModelFactory() {
1298
1855
  /**
1299
1856
  * #method
1300
1857
  * index of the global column holding each ungapped sequence position of a
1301
- * row, so seqPos -> column is a lookup rather than a scan. The domain
1302
- * overlay resolves thousands of these per redraw.
1303
- *
1304
- * Built per row, on the row asked for: the first lookup used to index
1305
- * every row in the alignment. The cache is keyed on the parse the rows
1306
- * came from, so a new alignment brings a new one.
1858
+ * row. The domain overlay resolves thousands of these per redraw. Built
1859
+ * lazily per row and cached on the parse.
1307
1860
  */
1308
1861
  seqPosIndex(rowName: string): Int32Array | undefined {
1309
1862
  const MSA = this.MSA
@@ -1329,10 +1882,9 @@ function stateModelFactory() {
1329
1882
 
1330
1883
  /**
1331
1884
  * #getter
1332
- * every sequence the alignment holds, keyed by row name, whatever the
1333
- * tree currently shows. `rows` is the rows on screen; this is the rows
1334
- * that exist, and every lookup about a named row goes through it --
1335
- * collapsing a clade hides rows, it does not delete their sequence
1885
+ * every sequence in the alignment, keyed by row name, including rows a
1886
+ * collapsed clade hides. `rows` holds only the rows on screen; lookups
1887
+ * by row name use this
1336
1888
  */
1337
1889
  get rowMap() {
1338
1890
  const MSA = this.MSA
@@ -1479,10 +2031,8 @@ function stateModelFactory() {
1479
2031
  clusterLayout(r, this.totalHeight, self.treeWidth)
1480
2032
  const max = this.rootToTipLength
1481
2033
  const k = max ? self.treeWidth / max : 0
1482
- // the displayed root starts at x=0 whatever branch length it carries,
1483
- // so its own length is subtracted here rather than zeroed on the parsed
1484
- // node -- `root` hands out the cached parse, and writing to it made
1485
- // showOnly shorten that branch for good
2034
+ // the displayed root starts at x=0, so subtract its length here; `root`
2035
+ // returns the cached parse, which must not be mutated
1486
2036
  setBrLength(r, -Math.max(r.data.length || 0, 0), k)
1487
2037
  // for each collapsed clade, record the pixel x-position of its farthest
1488
2038
  // tip so the renderer can draw a triangle spanning the branch-length
@@ -1522,10 +2072,8 @@ function stateModelFactory() {
1522
2072
 
1523
2073
  /**
1524
2074
  * #getter
1525
- * x-position of the farthest tip in a phylogram, px. The layout scales
1526
- * the longest root-to-tip path onto treeWidth, so that is where it lands
1527
- * -- and 0 for a tree carrying no lengths at all, which draws as a
1528
- * cladogram instead
2075
+ * x-position of the farthest tip in a phylogram, px: treeWidth, or 0 for
2076
+ * a tree with no branch lengths (drawn as a cladogram)
1529
2077
  */
1530
2078
  get maxBranchLength() {
1531
2079
  return this.rootToTipLength ? self.treeWidth : 0
@@ -1533,12 +2081,11 @@ function stateModelFactory() {
1533
2081
 
1534
2082
  /**
1535
2083
  * #getter
1536
- * pixels per unit of branch length in the current phylogram layout, and 0
1537
- * in cladogram mode, where the x-positions carry no length at all. The
1538
- * scale bar over the tree is drawn from it.
2084
+ * pixels per unit of branch length in the phylogram layout, 0 in
2085
+ * cladogram mode. The tree's scale bar uses it.
1539
2086
  */
1540
2087
  get pxPerBranchLength() {
1541
- const max = maxLength(this.root)
2088
+ const max = this.rootToTipLength
1542
2089
  return this.showBranchLenEffective && max ? self.treeWidth / max : 0
1543
2090
  },
1544
2091
 
@@ -1593,26 +2140,157 @@ function stateModelFactory() {
1593
2140
  return self.drawLabels && self.rowHeight >= minLetterRowHeight
1594
2141
  },
1595
2142
  }))
2143
+ .views(self => {
2144
+ // a spec is a frozen value, so it keys its computed until setColumnTracks
2145
+ // replaces it
2146
+ const columnTrackModels = new WeakMap<
2147
+ ColumnTrackSpec,
2148
+ IComputedValue<BasicTrack>
2149
+ >()
2150
+ return {
2151
+ /**
2152
+ * #getter
2153
+ * a data track's values or string, projected from its row's residues
2154
+ * onto alignment columns when it names a row
2155
+ */
2156
+ get columnTrackContent() {
2157
+ const { MSA, blanks, hideGapsEffective } = self
2158
+ const width = MSA?.getWidth() ?? 0
2159
+ const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
2160
+ if (!track.row) {
2161
+ return items
2162
+ }
2163
+ const out = Array.from({ length: width }, () => fill)
2164
+ const index = self.seqPosIndex(track.row)
2165
+ items.forEach((item, seqPos) => {
2166
+ const col = index?.[seqPos]
2167
+ if (col !== undefined) {
2168
+ out[col] = item
2169
+ }
2170
+ })
2171
+ return out
2172
+ }
2173
+ const skip = <T>(items: T[]) =>
2174
+ hideGapsEffective ? dropBlanks(blanks, items) : items
2175
+ // an arc endpoint maps row residue -> column -> visible column.
2176
+ // visibleColsBefore, not globalColToVisibleCol, so an endpoint in a
2177
+ // hidden column moves to the neighboring visible one and the arc stays
2178
+ const resolve = (track: ColumnTrackSpec, pos: number) => {
2179
+ const col = track.row
2180
+ ? self.seqPosIndex(track.row)?.[pos - 1]
2181
+ : pos - 1
2182
+ if (col === undefined || col < 0 || col >= width) {
2183
+ return undefined
2184
+ }
2185
+ return hideGapsEffective ? visibleColsBefore(blanks, col) : col
2186
+ }
2187
+ return new Map<
2188
+ string,
2189
+ { values?: number[]; data?: string; arcs?: Arc[] }
2190
+ >(
2191
+ self.columnTracks.map(track => {
2192
+ if (track.kind === 'arc') {
2193
+ const arcs = (track.arcs ?? [])
2194
+ .map(arc => {
2195
+ const start = resolve(track, Math.min(arc.start, arc.end))
2196
+ const end = resolve(track, Math.max(arc.start, arc.end))
2197
+ return start !== undefined &&
2198
+ end !== undefined &&
2199
+ start < end
2200
+ ? { start, end, color: arc.color }
2201
+ : undefined
2202
+ })
2203
+ .filter(notEmpty)
2204
+ return [track.id, { arcs }] as const
2205
+ }
2206
+ if (track.kind === 'bar') {
2207
+ const max = track.max ?? 1
2208
+ const values = skip(project(track, track.values ?? [], 0)).map(
2209
+ v => Math.min(1, Math.max(0, v / max)),
2210
+ )
2211
+ return [track.id, { values }] as const
2212
+ }
2213
+ const data = skip(
2214
+ project(track, (track.data ?? '').split(''), ' '),
2215
+ )
2216
+ return [track.id, { data: data.join('') }] as const
2217
+ }),
2218
+ )
2219
+ },
2220
+ /**
2221
+ * #method
2222
+ * the height a track draws at: what the user dragged its divider to,
2223
+ * then the height its snapshot asked for, then its kind's default. Only
2224
+ * a text track falls through to rowHeight
2225
+ */
2226
+ trackHeight(kind: TrackKind, heightKey: string = kind, given?: number) {
2227
+ return (
2228
+ self.trackHeights.get(heightKey) ??
2229
+ given ??
2230
+ defaultTrackHeights[kind] ??
2231
+ self.rowHeight
2232
+ )
2233
+ },
2234
+ /**
2235
+ * #method
2236
+ * the track a column track spec draws as, computed once per spec. A
2237
+ * text track's height falls through to rowHeight, and its own computed
2238
+ * confines the vertical zoom to it, so a sibling keeps its object and
2239
+ * its canvas skips the redraw
2240
+ */
2241
+ columnTrackModel(track: ColumnTrackSpec): BasicTrack {
2242
+ let computedModel = columnTrackModels.get(track)
2243
+ if (!computedModel) {
2244
+ computedModel = computed(() => {
2245
+ const heightKey = resizableKinds.has(track.kind)
2246
+ ? ownHeightKey(track.id)
2247
+ : undefined
2248
+ const content = this.columnTrackContent.get(track.id)
2249
+ return {
2250
+ model: {
2251
+ id: track.id,
2252
+ name: track.name,
2253
+ kind: track.kind,
2254
+ heightKey,
2255
+ height: this.trackHeight(track.kind, heightKey, track.height),
2256
+ barColor: track.color,
2257
+ arcColor: track.color,
2258
+ customColorScheme: track.colors,
2259
+ data: content?.data,
2260
+ arcs: content?.arcs,
2261
+ },
2262
+ ReactComponent: TrackBlocks,
2263
+ }
2264
+ })
2265
+ columnTrackModels.set(track, computedModel)
2266
+ }
2267
+ return computedModel.get()
2268
+ },
2269
+ /**
2270
+ * #getter
2271
+ */
2272
+ get columnTrackModels(): BasicTrack[] {
2273
+ return self.columnTracks.map(track => this.columnTrackModel(track))
2274
+ },
2275
+ }
2276
+ })
1596
2277
  .views(self => ({
1597
2278
  /**
1598
2279
  * #getter
1599
2280
  */
1600
2281
  get labelWidthMap() {
1601
- const { showTreeText, leaves, treeMetadata } = self
1602
- // gated on the same condition the renderer draws labels under, so the
1603
- // gutter labelsWidth reserves and the labels actually drawn cannot
1604
- // disagree -- and so turning labels off hands their space to the tree.
1605
- // Measured at a fixed reference size and scaled by labelWidthScale:
1606
- // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
2282
+ const { showTreeText, leaves } = self
2283
+ // gated on the renderer's label condition, so hidden labels reserve no
2284
+ // gutter. Measured once at a reference size and scaled by
2285
+ // labelWidthScale: re-measuring per vertical-zoom frame cost ~200ms on a
1607
2286
  // 50k-leaf tree
1608
2287
  return showTreeText
1609
2288
  ? new Map(
1610
2289
  leaves.map(node => {
1611
2290
  const { name } = node.data
1612
2291
  // `||`, matching renderTreeLabels: an empty genome falls back
1613
- // to the row name, and measuring '' would size the gutter (and
1614
- // the label's click target) to nothing
1615
- const displayName = treeMetadata[name]?.genome || name
2292
+ // to the row name
2293
+ const displayName = self.rowDataOf(name)?.genome || name
1616
2294
  return [
1617
2295
  name,
1618
2296
  measureTextCanvas(displayName, labelReferenceFontSize),
@@ -1632,11 +2310,9 @@ function stateModelFactory() {
1632
2310
  },
1633
2311
 
1634
2312
  get labelsWidth() {
1635
- // a loop, not Math.max(...widths.values()): spreading a map of every
1636
- // leaf passes one argument per row, and the argument limit is somewhere
1637
- // around 125k -- so the bundled 230k-tip COVID tree threw
1638
- // "RangeError: Maximum call stack size exceeded" out of a getter the
1639
- // treeWidth autorun reads on load
2313
+ // a loop, not Math.max(...): spreading passes one argument per leaf,
2314
+ // and the ~125k argument limit throws RangeError on the 230k-tip COVID
2315
+ // tree
1640
2316
  let max = 0
1641
2317
  for (const width of this.labelWidthMap.values()) {
1642
2318
  if (width > max) {
@@ -1662,9 +2338,8 @@ function stateModelFactory() {
1662
2338
 
1663
2339
  /**
1664
2340
  * #getter
1665
- * the base pairs of the consensus secondary structure, as arcs. The WUSS
1666
- * string is collapsed through the hidden columns before it is parsed, so
1667
- * the pairs land in the same visible column space the text track does
2341
+ * the base pairs of the consensus secondary structure, as arcs, in
2342
+ * visible column space (hidden columns are removed before parsing)
1668
2343
  */
1669
2344
  get secondaryStructureArcs(): Arc[] | undefined {
1670
2345
  const { blanks, hideGapsEffective } = self
@@ -1688,9 +2363,8 @@ function stateModelFactory() {
1688
2363
  const { MSA, hideGapsEffective, blanks } = self
1689
2364
  const tracks = (MSA?.tracks ?? []).filter(t => !!t.data)
1690
2365
  if (tracks.length === 0) {
1691
- // reading rowHeight up front made every zoom frame rebuild the track
1692
- // list, and the canvases redraw on the track object they are handed
1693
- // changing
2366
+ // return before reading rowHeight, so zooming does not rebuild the
2367
+ // list and redraw every track canvas
1694
2368
  return []
1695
2369
  }
1696
2370
  const { rowHeight } = self
@@ -1707,113 +2381,9 @@ function stateModelFactory() {
1707
2381
 
1708
2382
  /**
1709
2383
  * #getter
1710
- * a data track's values or string, projected from its row's residues
1711
- * onto alignment columns when it names a row
1712
- */
1713
- get columnTrackContent() {
1714
- const { MSA, blanks, hideGapsEffective } = self
1715
- const width = MSA?.getWidth() ?? 0
1716
- const project = <T>(track: ColumnTrackSpec, items: T[], fill: T) => {
1717
- if (!track.row) {
1718
- return items
1719
- }
1720
- const out = Array.from({ length: width }, () => fill)
1721
- const index = self.seqPosIndex(track.row)
1722
- items.forEach((item, seqPos) => {
1723
- const col = index?.[seqPos]
1724
- if (col !== undefined) {
1725
- out[col] = item
1726
- }
1727
- })
1728
- return out
1729
- }
1730
- const skip = <T>(items: T[]) =>
1731
- hideGapsEffective ? dropBlanks(blanks, items) : items
1732
- // an arc names two positions rather than one per column, so it takes
1733
- // the same two steps the arrays take -- a row's residues onto columns,
1734
- // then columns onto the visible ones -- as a lookup. visibleColsBefore,
1735
- // not globalColToVisibleCol: an endpoint in a hidden column collapses
1736
- // to where that column went instead of taking the whole arc with it
1737
- const resolve = (track: ColumnTrackSpec, pos: number) => {
1738
- const col = track.row
1739
- ? self.seqPosIndex(track.row)?.[pos - 1]
1740
- : pos - 1
1741
- if (col === undefined || col < 0 || col >= width) {
1742
- return undefined
1743
- }
1744
- return hideGapsEffective ? visibleColsBefore(blanks, col) : col
1745
- }
1746
- return new Map<
1747
- string,
1748
- { values?: number[]; data?: string; arcs?: Arc[] }
1749
- >(
1750
- self.columnTracks.map(track => {
1751
- if (track.kind === 'arc') {
1752
- const arcs = (track.arcs ?? [])
1753
- .map(arc => {
1754
- const start = resolve(track, Math.min(arc.start, arc.end))
1755
- const end = resolve(track, Math.max(arc.start, arc.end))
1756
- return start !== undefined && end !== undefined && start < end
1757
- ? { start, end, color: arc.color }
1758
- : undefined
1759
- })
1760
- .filter(notEmpty)
1761
- return [track.id, { arcs }] as const
1762
- }
1763
- if (track.kind === 'bar') {
1764
- const max = track.max ?? 1
1765
- const values = skip(project(track, track.values ?? [], 0)).map(
1766
- v => Math.min(1, Math.max(0, v / max)),
1767
- )
1768
- return [track.id, { values }] as const
1769
- }
1770
- const data = skip(project(track, (track.data ?? '').split(''), ' '))
1771
- return [track.id, { data: data.join('') }] as const
1772
- }),
1773
- )
1774
- },
1775
- /**
1776
- * #getter
1777
- */
1778
- get columnTrackModels(): BasicTrack[] {
1779
- // read per kind, not up front: a text track is the only kind sized by
1780
- // the row height, and reading it here rebuilt every data track on every
1781
- // vertical zoom step
1782
- const defaultHeight = (kind: ColumnTrackSpec['kind']) =>
1783
- kind === 'bar'
1784
- ? self.conservationTrackHeight
1785
- : kind === 'arc'
1786
- ? self.arcTrackHeight
1787
- : self.rowHeight
1788
- return self.columnTracks.map(track => ({
1789
- model: {
1790
- id: track.id,
1791
- name: track.name,
1792
- kind: track.kind,
1793
- height:
1794
- self.columnTrackHeights[track.id] ??
1795
- track.height ??
1796
- defaultHeight(track.kind),
1797
- // the spec has one `color`; bar and arc are separate track models
1798
- // that read it under their own name
1799
- barColor: track.color,
1800
- arcColor: track.color,
1801
- customColorScheme: track.colors,
1802
- data: this.columnTrackContent.get(track.id)?.data,
1803
- arcs: this.columnTrackContent.get(track.id)?.arcs,
1804
- },
1805
- ReactComponent: TrackBlocks,
1806
- }))
1807
- },
1808
- /**
1809
- * #getter
1810
- */
1811
- /**
1812
- * #getter
1813
- * the consensus secondary structure as a track, when there is one. Its
1814
- * own getter so the object keeps its identity across a zoom: the canvas
1815
- * redraws on the track it is handed changing, and rebuilding these
1816
- * alongside everything else made every zoom frame redraw every track
2384
+ * the consensus secondary structure as a track, when there is one. A
2385
+ * separate getter keeps the object stable across zoom, so its canvas
2386
+ * does not redraw
1817
2387
  */
1818
2388
  get basePairTrackModels(): BasicTrack[] {
1819
2389
  const arcs = this.secondaryStructureArcs
@@ -1824,7 +2394,8 @@ function stateModelFactory() {
1824
2394
  id: 'base-pairs',
1825
2395
  name: 'Base pairs',
1826
2396
  kind: 'arc' as const,
1827
- height: self.arcTrackHeight,
2397
+ heightKey: 'arc',
2398
+ height: self.trackHeight('arc'),
1828
2399
  arcs,
1829
2400
  },
1830
2401
  ReactComponent: TrackBlocks,
@@ -1835,8 +2406,8 @@ function stateModelFactory() {
1835
2406
 
1836
2407
  /**
1837
2408
  * #getter
1838
- * the tracks computed from the alignment itself, which depend on their
1839
- * own heights and on the alphabet -- and on nothing zoom changes
2409
+ * the tracks computed from the alignment; they depend on their heights
2410
+ * and the alphabet, not on zoom
1840
2411
  */
1841
2412
  get computedTrackModels(): BasicTrack[] {
1842
2413
  return [
@@ -1844,7 +2415,6 @@ function stateModelFactory() {
1844
2415
  id: 'conservation',
1845
2416
  name: 'Conservation',
1846
2417
  kind: 'bar' as const,
1847
- height: self.conservationTrackHeight,
1848
2418
  barColor: 'gray',
1849
2419
  },
1850
2420
  ...(self.sequenceType === 'amino'
@@ -1853,7 +2423,6 @@ function stateModelFactory() {
1853
2423
  id: 'property-conservation',
1854
2424
  name: 'Property conservation',
1855
2425
  kind: 'bar' as const,
1856
- height: self.conservationTrackHeight,
1857
2426
  barColor: '#6a51a3',
1858
2427
  },
1859
2428
  ]
@@ -1862,24 +2431,33 @@ function stateModelFactory() {
1862
2431
  id: 'sequence-logo',
1863
2432
  name: 'Sequence logo',
1864
2433
  kind: 'logo' as const,
1865
- height: self.sequenceLogoTrackHeight,
1866
2434
  },
1867
2435
  // last, so it sits against the alignment it numbers
1868
2436
  {
1869
2437
  id: 'position-ruler',
1870
2438
  name: 'Position',
1871
2439
  kind: 'ruler' as const,
1872
- height: 20,
1873
2440
  },
1874
- ].map(model => ({ model, ReactComponent: TrackBlocks }))
2441
+ ].map(model => ({
2442
+ // every computed track of a kind shares that kind's height, so the
2443
+ // kind is its key
2444
+ model: {
2445
+ ...model,
2446
+ heightKey: resizableKinds.has(model.kind) ? model.kind : undefined,
2447
+ height: self.trackHeight(model.kind),
2448
+ },
2449
+ ReactComponent: TrackBlocks,
2450
+ }))
1875
2451
  },
1876
2452
 
1877
2453
  get tracks(): BasicTrack[] {
1878
2454
  return [
1879
2455
  ...this.adapterTrackModels,
1880
2456
  ...this.basePairTrackModels,
1881
- ...this.columnTrackModels,
1882
- ...this.computedTrackModels,
2457
+ ...self.columnTrackModels,
2458
+ // every computed track reads the alignment's columns, and a tree, a
2459
+ // GFF and a features panel draw a figure with none
2460
+ ...(self.numColumns > 0 ? this.computedTrackModels : []),
1883
2461
  ]
1884
2462
  },
1885
2463
 
@@ -1919,19 +2497,18 @@ function stateModelFactory() {
1919
2497
  * Convert a visible column to a row-specific sequence position (0-based).
1920
2498
  * Returns undefined if the position is a gap in the sequence.
1921
2499
  *
1922
- * PUBLIC API: this and the sibling coordinate converters
1923
- * (visibleColToGlobalCol, seqPosToVisibleCol, globalColToVisibleCol,
1924
- * seqPosToGlobalCol) are how a host translates between alignment columns
1925
- * and a row's residue positions across gaps. Keep them stable.
2500
+ * Public API, like the sibling converters (visibleColToGlobalCol,
2501
+ * seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) hosts
2502
+ * use to translate between columns and residue positions. Keep them
2503
+ * stable.
1926
2504
  *
1927
2505
  * @param rowName - The name of the row
1928
2506
  * @param visibleCol - The visible column index
1929
2507
  * @returns The sequence position (0-based), or undefined if it's a gap
1930
2508
  */
1931
2509
  visibleColToSeqPos(rowName: string, visibleCol: number) {
1932
- // a binary search of the row's index, not a scan of the row: this
1933
- // answers on every mouse move, and a 30k-column row scanned per event
1934
- // is the whole frame
2510
+ // binary search: this runs on every mouse move, and scanning a
2511
+ // 30k-column row per event takes the whole frame
1935
2512
  return seqPosOfGlobalCol(
1936
2513
  self.seqPosIndex(rowName),
1937
2514
  this.visibleColToGlobalCol(visibleCol),
@@ -1971,10 +2548,9 @@ function stateModelFactory() {
1971
2548
 
1972
2549
  /**
1973
2550
  * #method
1974
- * Convert a visible column index (what a mouse handler reports) back to a
1975
- * column of the full alignment. Hidden columns shift everything to their
1976
- * right, so a host that holds per-column data of its own has to make this
1977
- * hop before indexing it.
2551
+ * Convert a visible column index (what a mouse handler reports) to a
2552
+ * column of the full alignment. A host indexing its own per-column data
2553
+ * needs this when columns are hidden.
1978
2554
  *
1979
2555
  * @param visibleCol - The visible column index
1980
2556
  * @returns The global column index in the full MSA
@@ -1989,8 +2565,7 @@ function stateModelFactory() {
1989
2565
  /**
1990
2566
  * #method
1991
2567
  * Convert a sequence position (ungapped) to a global column index.
1992
- * Returns undefined for a row the alignment does not have -- answering
1993
- * anyway is how a mistyped or stale row name came to highlight column 0.
2568
+ * Returns undefined for a row name the alignment does not have.
1994
2569
  *
1995
2570
  * @param rowName - The name of the row
1996
2571
  * @param seqPos - The sequence position (0-based, ungapped)
@@ -2023,12 +2598,42 @@ function stateModelFactory() {
2023
2598
  : this.globalColToVisibleCol(globalCol)
2024
2599
  },
2025
2600
 
2601
+ /**
2602
+ * #method
2603
+ * the visible columns a span covers, in highlight coordinates: `start`
2604
+ * and `end` are 1-based residues of `row`, or columns of the file
2605
+ * without it. A fractional position, as a zoom gesture reports one,
2606
+ * widens to the whole residue or column it falls in. A span entirely on
2607
+ * hidden columns, or naming a row the alignment lacks, gives undefined.
2608
+ */
2609
+ visibleSpan({ row, start: rawStart, end: rawEnd }: Region) {
2610
+ const { blanks } = self
2611
+ const start = Math.max(1, Math.floor(rawStart))
2612
+ const end = Math.ceil(rawEnd)
2613
+ let startGlobal = start - 1
2614
+ let endGlobal = end - 1
2615
+ if (row !== undefined) {
2616
+ const rowStart = this.seqPosToGlobalCol(row, start - 1)
2617
+ const rowEnd = this.seqPosToGlobalCol(row, end - 1)
2618
+ if (rowStart === undefined || rowEnd === undefined) {
2619
+ return undefined
2620
+ }
2621
+ startGlobal = rowStart
2622
+ endGlobal = rowEnd
2623
+ }
2624
+ const startCol =
2625
+ this.globalColToVisibleCol(startGlobal) ??
2626
+ visibleColsBefore(blanks, startGlobal)
2627
+ const endCol =
2628
+ this.globalColToVisibleCol(endGlobal) ??
2629
+ visibleColsBefore(blanks, endGlobal) - 1
2630
+ return startCol <= endCol ? { startCol, endCol } : undefined
2631
+ },
2632
+
2026
2633
  /**
2027
2634
  * #getter
2028
- * every reason a mapping is being ignored, so a host can say which. A
2029
- * mapping outlives the alignment it was computed for; when the two no
2030
- * longer agree the lookups have to refuse, and refusing invisibly is how
2031
- * "there is no structure here" gets confused with "this data is stale".
2635
+ * why each ignored residue mapping is ignored, so a host can tell a
2636
+ * missing structure from a mapping made against a different alignment.
2032
2637
  */
2033
2638
  get residueMappingProblems(): ResidueMappingProblem[] {
2034
2639
  const problems: ResidueMappingProblem[] = []
@@ -2080,10 +2685,8 @@ function stateModelFactory() {
2080
2685
 
2081
2686
  /**
2082
2687
  * #getter
2083
- * the mappings that still fit the loaded alignment. A row-level problem
2084
- * takes the whole mapping out; a single malformed segment takes only
2085
- * itself, since the rest of the mapping is still a claim about residues
2086
- * that exist.
2688
+ * the mappings that fit the loaded alignment. A row-level problem drops
2689
+ * the whole mapping; a malformed segment drops only that segment.
2087
2690
  */
2088
2691
  get usableResidueMappings(): ResidueMapping[] {
2089
2692
  const unusable = new Set(
@@ -2101,9 +2704,8 @@ function stateModelFactory() {
2101
2704
 
2102
2705
  /**
2103
2706
  * #getter
2104
- * the structures the loaded alignment has usable mappings onto. A row can
2105
- * have several -- an experimental entry and a predicted model, say -- so
2106
- * a host that means a particular one has to name it.
2707
+ * the structures with usable mappings. A row can map onto several, such
2708
+ * as an experimental entry and a predicted model.
2107
2709
  */
2108
2710
  get mappedStructures() {
2109
2711
  return this.usableResidueMappings.map(m => ({
@@ -2114,18 +2716,12 @@ function stateModelFactory() {
2114
2716
 
2115
2717
  /**
2116
2718
  * #method
2117
- * The structure residue a row residue is, or undefined. Refusing is the
2118
- * point: the guess this replaces answered every query, with a wrong
2119
- * residue when it did not know.
2719
+ * The structure residue for a row residue. Returns undefined when no
2720
+ * segment covers `seqPos`, or when the row maps onto several structures
2721
+ * and `structureId` does not pick one (see `mappedStructures`).
2120
2722
  *
2121
- * It also refuses when the answer is not unique. A row commonly maps onto
2122
- * several structures -- an experimental entry and two predicted models --
2123
- * and returning whichever came first would be the same class of wrong,
2124
- * quieter. Name one with `structureId`, or use `mappedStructures` to see
2125
- * what there is.
2126
- *
2127
- * Positions are 1-based, as `residueMappings` and `highlights` are --
2128
- * note that the column helpers above take 0-based ones.
2723
+ * Positions are 1-based, like `residueMappings` and `highlights`; the
2724
+ * column helpers above are 0-based.
2129
2725
  *
2130
2726
  * @param rowName - The alignment row
2131
2727
  * @param seqPos - Residue of that row, 1-based
@@ -2164,10 +2760,10 @@ function stateModelFactory() {
2164
2760
 
2165
2761
  /**
2166
2762
  * #method
2167
- * The row residue a structure residue is, the same lookup backwards, and
2168
- * refusing on the same terms. `asymId` picks between mappings onto the
2169
- * same entry, which a homodimer -- two rows, two chains, one id -- always
2170
- * needs; without it such a lookup is ambiguous and gets nothing.
2763
+ * The row residue for a structure residue; the inverse of
2764
+ * `structureResidue`, returning undefined in the same cases. `asymId`
2765
+ * picks a chain when several mappings share an entry id, as in a
2766
+ * homodimer.
2171
2767
  *
2172
2768
  * @param structureId - The structure's id, as the mapping names it
2173
2769
  * @param position - Residue of that structure, 1-based label_seq_id
@@ -2203,17 +2799,39 @@ function stateModelFactory() {
2203
2799
  .views(self => ({
2204
2800
  /**
2205
2801
  * #getter
2206
- * the vertical space the alignment rows actually get: the widget height
2207
- * less everything stacked above and below them -- the header, the tracks,
2208
- * and the minimap when the columns overflow. Every consumer wants this
2209
- * same subtraction, so there is one of it: blocksY, maxScrollY, the
2210
- * vertical scrollbar and fitVertically all read it, and a second getter
2211
- * that forgot the tracks is what put the last rows out of reach.
2802
+ * the branch-length scale bar over the tree, undefined in cladogram mode
2803
+ * or when the tree area is too narrow for one
2804
+ */
2805
+ get treeScaleBar() {
2806
+ return scaleBarLength(
2807
+ self.pxPerBranchLength,
2808
+ self.treeAreaWidth - self.marginLeft * 2,
2809
+ )
2810
+ },
2811
+ /**
2812
+ * #getter
2813
+ * the band across the top: the minimap over the alignment, the row panel
2814
+ * headers, and the tree overview stacked on the scale bar, as tall as the
2815
+ * tallest of the three
2816
+ */
2817
+ get topBandHeight() {
2818
+ return Math.max(
2819
+ self.showHorizontalScrollbar ? self.minimapHeight : 0,
2820
+ self.rowPanelsHeaderHeight,
2821
+ self.treeOverviewHeight +
2822
+ (this.treeScaleBar ? treeScaleBarHeight : 0),
2823
+ )
2824
+ },
2825
+ /**
2826
+ * #getter
2827
+ * the vertical space for alignment rows: the widget height less the
2828
+ * header, the top band and the tracks. Shared by blocksY, maxScrollY, the
2829
+ * vertical scrollbar and fitVertically.
2212
2830
  */
2213
2831
  get msaAreaHeight() {
2214
2832
  return (
2215
2833
  self.height -
2216
- (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
2834
+ this.topBandHeight -
2217
2835
  self.headerHeight -
2218
2836
  this.totalTrackAreaHeight
2219
2837
  )
@@ -2226,13 +2844,12 @@ function stateModelFactory() {
2226
2844
  return sum(self.turnedOnTracks.map(r => r.model.height))
2227
2845
  },
2228
2846
  /**
2229
- * one representative annotation per accession, which is what the legend,
2230
- * the filter dialog and the palettes key off
2847
+ * one representative annotation per accession, for the legend, the
2848
+ * filter dialog and the palettes
2231
2849
  */
2232
2850
  get annotationTypes() {
2233
- // first occurrence wins. The representative supplies only the name,
2234
- // description and -- for ordinal segments -- the start that orders
2235
- // them, and those agree across an accession's instances
2851
+ // first occurrence wins; only name, description and segment start are
2852
+ // read from it
2236
2853
  const types = new Map<string, Annotation>()
2237
2854
  for (const annot of self.annotations) {
2238
2855
  if (!types.has(annot.accession)) {
@@ -2263,11 +2880,8 @@ function stateModelFactory() {
2263
2880
  * #getter
2264
2881
  */
2265
2882
  get dataInitialized() {
2266
- // truthiness, not `!== ''`: these are types.maybe, and DataModel's
2267
- // postProcessSnapshot drops a document over 50kb, so a restored session
2268
- // that inlined a large alignment comes back `undefined` here -- which
2269
- // `!== ''` reads as initialized and renders an empty view instead of
2270
- // the import form
2883
+ // truthiness, not `!== ''`: DataModel drops an inline document over
2884
+ // 50kb from the snapshot, so a restored session can hold `undefined`
2271
2885
  return !!(self.data.msa || self.data.tree) && !self.error
2272
2886
  },
2273
2887
  /**
@@ -2317,12 +2931,20 @@ function stateModelFactory() {
2317
2931
  },
2318
2932
  /**
2319
2933
  * #getter
2320
- * most-negative allowed scrollY, keeping the last row in view rather than
2321
- * letting the whole alignment scroll off the top.
2934
+ * most-negative allowed scrollY, which keeps the last row in view
2322
2935
  */
2323
2936
  get maxScrollY() {
2324
2937
  return Math.min(-self.totalHeight + self.msaAreaHeight, 0)
2325
2938
  },
2939
+ /**
2940
+ * #getter
2941
+ * axis a wheel zoom scales, for ctrl+wheel as much as for scroll-zoom.
2942
+ * With scroll-zoom off the toolbar shows no axis, so ctrl+wheel takes
2943
+ * both.
2944
+ */
2945
+ get wheelZoomAxis(): ScrollZoomAxis {
2946
+ return self.scrollZoom ? self.scrollZoomAxis : 'both'
2947
+ },
2326
2948
  }))
2327
2949
  .actions(self => ({
2328
2950
  /**
@@ -2339,15 +2961,20 @@ function stateModelFactory() {
2339
2961
  self.scrollZoom = arg
2340
2962
  },
2341
2963
 
2964
+ /**
2965
+ * #action
2966
+ */
2967
+ setScrollZoomAxis(arg: ScrollZoomAxis) {
2968
+ self.scrollZoomAxis = arg
2969
+ },
2970
+
2342
2971
  /**
2343
2972
  * #action
2344
2973
  * set hovered tree node and its descendants
2345
2974
  */
2346
2975
  setHoveredTreeNode(nodeId?: string) {
2347
- // the tree's mousemove handler calls this on every event, and both the
2348
- // lookup and the write are expensive: `find` walks the whole hierarchy,
2349
- // and a fresh object here invalidates hoveredRowIndices and redraws the
2350
- // tree and MSA overlays. Re-hovering the same node is the common case
2976
+ // called on every tree mousemove; `find` walks the whole hierarchy and a
2977
+ // new object redraws the tree and MSA overlays
2351
2978
  if (nodeId === self.hoveredTreeNode?.nodeId) {
2352
2979
  return
2353
2980
  }
@@ -2367,15 +2994,12 @@ function stateModelFactory() {
2367
2994
  /**
2368
2995
  * #action
2369
2996
  * Calculate a neighbor joining tree from the current MSA using BLOSUM62
2370
- * distances. Refuses above `maxNeighborJoiningRows`: the join loop is
2371
- * cubic and runs on the main thread, so 800 rows is a ten-second freeze
2372
- * with no progress and no cancel, and a tree that size wants a tool built
2373
- * for it anyway.
2997
+ * distances. Throws above `maxNeighborJoiningRows`: the join loop is
2998
+ * cubic and runs on the main thread, and 800 rows freeze the tab for ten
2999
+ * seconds with no cancel.
2374
3000
  */
2375
3001
  calculateNeighborJoiningTreeFromMSA() {
2376
- // every sequence in the alignment, not the rows on screen: a collapsed
2377
- // clade is a display state, and building the tree from what it leaves
2378
- // showing drops the sequences it hides out of the result
3002
+ // every sequence, including rows in collapsed clades
2379
3003
  const rows = [...self.rowMap]
2380
3004
  if (rows.length < 2) {
2381
3005
  throw new Error('Need at least 2 sequences to build a tree')
@@ -2390,10 +3014,9 @@ function stateModelFactory() {
2390
3014
 
2391
3015
  /**
2392
3016
  * #action
2393
- * swap in a different tree over the same alignment. Node ids are derived
2394
- * from the path (node-0-0-1), so a `collapsed` or `showOnly` id held over
2395
- * from the old tree matches a real node in the new one and folds whatever
2396
- * happens to sit there -- the ids go with the tree they name.
3017
+ * swap in a different tree over the same alignment. Clears `collapsed`
3018
+ * and `showOnly`, since path-derived node ids (node-0-0-1) from the old
3019
+ * tree would match unrelated nodes in the new one.
2397
3020
  */
2398
3021
  replaceTree(newick: string) {
2399
3022
  transaction(() => {
@@ -2462,26 +3085,36 @@ function stateModelFactory() {
2462
3085
  * #action
2463
3086
  * Smoothly zoom by a continuous scaleFactor. The column under the cursor
2464
3087
  * (offsetX/offsetY, px relative to the MSA area) stays anchored
2465
- * horizontally. Vertically the anchor is biased toward the top: when the
2466
- * alignment nearly fits the viewport, snap to y=0 rather than pinning a
2467
- * random row under the cursor, with the bias fading out as the alignment
2468
- * grows taller than the viewport (where cursor-anchoring is useful).
2469
- * Drives wheel/trackpad-pinch zoom.
2470
- */
2471
- zoomToPos(scaleFactor: number, offsetX: number, offsetY: number) {
3088
+ * horizontally. Vertically the anchor is biased toward y=0 when the
3089
+ * alignment nearly fits the viewport, fading to cursor-anchoring as the
3090
+ * alignment grows taller than the viewport.
3091
+ * Drives wheel/trackpad-pinch zoom. `axis` holds one cell dimension
3092
+ * fixed; the held axis still re-anchors its scroll offset, since the
3093
+ * other one can change how much of the alignment fits.
3094
+ */
3095
+ zoomToPos(
3096
+ scaleFactor: number,
3097
+ offsetX: number,
3098
+ offsetY: number,
3099
+ axis: ScrollZoomAxis = 'both',
3100
+ ) {
2472
3101
  transaction(() => {
2473
3102
  const colInView = (-self.scrollX + offsetX) / self.colWidth
2474
3103
  const rowInView = (-self.scrollY + offsetY) / self.rowHeight
2475
- self.colWidth = clamp(
2476
- self.colWidth * scaleFactor,
2477
- minColWidth,
2478
- maxCellSize,
2479
- )
2480
- self.rowHeight = clamp(
2481
- self.rowHeight * scaleFactor,
2482
- minRowHeight,
2483
- maxCellSize,
2484
- )
3104
+ if (axis !== 'vertical') {
3105
+ self.colWidth = clamp(
3106
+ self.colWidth * scaleFactor,
3107
+ minColWidth,
3108
+ maxCellSize,
3109
+ )
3110
+ }
3111
+ if (axis !== 'horizontal') {
3112
+ self.rowHeight = clamp(
3113
+ self.rowHeight * scaleFactor,
3114
+ minRowHeight,
3115
+ maxCellSize,
3116
+ )
3117
+ }
2485
3118
  self.scrollX = clamp(
2486
3119
  offsetX - colInView * self.colWidth,
2487
3120
  self.maxScrollX,
@@ -2489,9 +3122,7 @@ function stateModelFactory() {
2489
3122
  )
2490
3123
 
2491
3124
  const anchoredScrollY = offsetY - rowInView * self.rowHeight
2492
- // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
2493
- // overflows, so -maxScrollY is exactly that overflow past the
2494
- // scrollable MSA viewport (0 when it fits)
3125
+ // -maxScrollY is the overflow past the viewport, 0 when it fits
2495
3126
  const overflow = Math.max(0, -self.maxScrollY)
2496
3127
  const visibleHeight = self.totalHeight - overflow
2497
3128
  const topBias =
@@ -2520,13 +3151,11 @@ function stateModelFactory() {
2520
3151
 
2521
3152
  /**
2522
3153
  * #action
2523
- * Set the overlay annotations (an empty list clears them). Every source
2524
- * funnels through here after its own adapter has flattened it:
2525
- * InterProScan, GFF, user uploads, NCBI CDD.
3154
+ * Set the overlay annotations (an empty list clears them). InterProScan,
3155
+ * GFF, user uploads and NCBI CDD all arrive here as Annotation[].
2526
3156
  *
2527
- * It does not touch `showDomains`. Loading used to force the overlay on,
2528
- * and since a restored snapshot loads its GFF again on the way in, a link
2529
- * shared with the overlay hidden reopened with it drawn.
3157
+ * Leaves `showDomains` alone, because a restored snapshot reloads its
3158
+ * GFF and must keep a hidden overlay hidden.
2530
3159
  */
2531
3160
  setAnnotations(annotations: Annotation[]) {
2532
3161
  self.annotations = annotations
@@ -2566,6 +3195,28 @@ function stateModelFactory() {
2566
3195
  setColumnTracks(tracks: ColumnTrackSpec[]) {
2567
3196
  self.columnTracks.replace(tracks)
2568
3197
  },
3198
+ /**
3199
+ * #action
3200
+ * replace the row table, which the model keeps as the JSON string
3201
+ * `data.treeMetadata` (see docs/layers.md)
3202
+ */
3203
+ setRowData(rowData: Record<string, Record<string, string>>) {
3204
+ self.data.setTreeMetadata(JSON.stringify(rowData))
3205
+ },
3206
+ /**
3207
+ * #action
3208
+ * replace what the viewer's marks read from the row table
3209
+ */
3210
+ setEncodings(encodings: Encoding[]) {
3211
+ self.encodings.replace(encodings)
3212
+ },
3213
+ /**
3214
+ * #action
3215
+ * replace the panels drawn between the tree and the alignment
3216
+ */
3217
+ setRowPanels(panels: RowPanelSpec[]) {
3218
+ self.rowPanels.replace(panels)
3219
+ },
2569
3220
  /**
2570
3221
  * #action
2571
3222
  * replace the alignment<->structure correspondence (see docs/layers.md)
@@ -2577,8 +3228,6 @@ function stateModelFactory() {
2577
3228
  * #action
2578
3229
  */
2579
3230
  toggleTrack(id: string) {
2580
- // the stored value is "is off", so the current shown state is exactly
2581
- // what the flipped entry should hold
2582
3231
  const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff
2583
3232
  self.turnedOffTracks.set(
2584
3233
  id,
@@ -2601,18 +3250,78 @@ function stateModelFactory() {
2601
3250
  },
2602
3251
  /**
2603
3252
  * #getter
2604
- * width of the alignment canvas itself: the msa area less the vertical
2605
- * scrollbar sitting in it. Not usable from showHorizontalScrollbar, which
2606
- * feeds msaAreaHeight -> showVerticalScrollbar and would close a cycle
3253
+ * width of the alignment canvas: the msa area less the vertical
3254
+ * scrollbar. showHorizontalScrollbar must not read it, since that feeds
3255
+ * msaAreaHeight -> showVerticalScrollbar and would form a cycle
2607
3256
  */
2608
3257
  get msaCanvasWidth() {
2609
3258
  return self.msaAreaWidth - this.verticalScrollbarWidth
2610
3259
  },
3260
+ /**
3261
+ * #method
3262
+ * the cell at a visible column and row index, in the coordinates a host
3263
+ * writes highlights in
3264
+ */
3265
+ cellAt(visibleCol: number, rowIndex?: number): Cell {
3266
+ const column = self.visibleColToGlobalCol(visibleCol)
3267
+ const row =
3268
+ rowIndex === undefined ? undefined : self.leaves[rowIndex]?.data.name
3269
+ return row === undefined
3270
+ ? { column: column + 1 }
3271
+ : {
3272
+ column: column + 1,
3273
+ row,
3274
+ residue: self.visibleColToSeqPosOneBased(row, visibleCol),
3275
+ letter: self.rowMap.get(row)?.[column],
3276
+ }
3277
+ },
3278
+ /**
3279
+ * #getter
3280
+ * the cell under the pointer. Public API: MSAViewer's onCellHover
3281
+ * reports it.
3282
+ */
3283
+ get hoveredCell() {
3284
+ const { mouseCol, mouseRow } = self
3285
+ return mouseCol === undefined
3286
+ ? undefined
3287
+ : this.cellAt(mouseCol, mouseRow)
3288
+ },
3289
+ /**
3290
+ * #getter
3291
+ * the cell a click pinned. Public API: MSAViewer's onCellClick reports it.
3292
+ */
3293
+ get clickedCell() {
3294
+ const { mouseClickCol, mouseClickRow } = self
3295
+ return mouseClickCol === undefined
3296
+ ? undefined
3297
+ : this.cellAt(mouseClickCol, mouseClickRow)
3298
+ },
3299
+ /**
3300
+ * #getter
3301
+ * the columns on screen. Public API: MSAViewer's onViewportChange reports
3302
+ * it.
3303
+ */
3304
+ get viewport(): Viewport | undefined {
3305
+ const { scrollX, colWidth, numColumns, viewInitialized } = self
3306
+ if (numColumns === 0 || !viewInitialized) {
3307
+ return undefined
3308
+ }
3309
+ const { xStart, xEnd } = visibleColRange({
3310
+ offsetX: -scrollX,
3311
+ blockWidth: this.msaCanvasWidth,
3312
+ colWidth,
3313
+ })
3314
+ const last = Math.max(0, Math.min(xEnd, numColumns) - 1)
3315
+ return {
3316
+ startColumn: self.visibleColToGlobalCol(Math.min(xStart, last)) + 1,
3317
+ endColumn: self.visibleColToGlobalCol(last) + 1,
3318
+ }
3319
+ },
2611
3320
  /**
2612
3321
  * #getter
2613
3322
  * ordinal segment types (exons etc.), ordered by sequence position so
2614
- * exon-1..exon-14 read left-to-right; colored by alternating shade and
2615
- * labeled by number rather than each getting a distinct hue + legend row
3323
+ * exon-1..exon-14 run left-to-right; colored by alternating shade and
3324
+ * labeled by number, with no legend row
2616
3325
  */
2617
3326
  get segmentDomainTypes() {
2618
3327
  return [...self.annotationTypes.values()]
@@ -2641,11 +3350,42 @@ function stateModelFactory() {
2641
3350
  )
2642
3351
  return { ...segments, ...categorical }
2643
3352
  },
2644
- get strokePalette() {
2645
- return transform(this.fillPalette, ([key, val]) => [
2646
- key,
2647
- colord(val).darken(0.1).toHex(),
2648
- ])
3353
+ /**
3354
+ * #getter
3355
+ * the encoding coloring the overlay's spans, undefined when none does,
3356
+ * which leaves each span the color its accession takes in `fillPalette`
3357
+ */
3358
+ get featureFillEncoding(): ResolvedEncoding | undefined {
3359
+ return this.resolvedEncodings.find(e => e.channel === 'featureFill')
3360
+ },
3361
+
3362
+ /**
3363
+ * #getter
3364
+ * the fill and outline of every feature's span: its own GFF `color=`
3365
+ * first, then the `featureFill` scale, then the accession palette.
3366
+ * Computed once per change of the features, the encodings or the palette
3367
+ */
3368
+ get featureColors(): Map<Annotation, { fill: string; stroke: string }> {
3369
+ return featureColorMap(
3370
+ self.filteredAnnotations,
3371
+ this.featureFillEncoding,
3372
+ this.fillPalette,
3373
+ )
3374
+ },
3375
+
3376
+ /**
3377
+ * #getter
3378
+ * the text the `featureLabel` channel draws inside each span, undefined
3379
+ * when no encoding names the channel. A data channel, so it draws
3380
+ * whether or not the residue letters do
3381
+ */
3382
+ get featureLabels(): Map<Annotation, string> | undefined {
3383
+ const encoding = this.resolvedEncodings.find(
3384
+ e => e.channel === 'featureLabel',
3385
+ )
3386
+ return encoding
3387
+ ? featureLabelMap(self.filteredAnnotations, encoding.field)
3388
+ : undefined
2649
3389
  },
2650
3390
 
2651
3391
  /**
@@ -2665,9 +3405,9 @@ function stateModelFactory() {
2665
3405
  /**
2666
3406
  * #getter
2667
3407
  * the domain types currently drawn on the alignment (filtered-on), shared
2668
- * by the on-screen legend and the SVG export legend. Ordinal segments
2669
- * (exons) are excluded — they read as a numbered gene model, not a color
2670
- * key — so this is the categorical types ordered by sequence position
3408
+ * by the on-screen legend and the SVG export legend: the categorical types
3409
+ * ordered by sequence position. Ordinal segments (exons) are numbered on
3410
+ * the band instead
2671
3411
  */
2672
3412
  get visibleDomainTypes() {
2673
3413
  return this.categoricalDomainTypes
@@ -2675,15 +3415,94 @@ function stateModelFactory() {
2675
3415
  .toSorted((a, b) => a.start - b.start)
2676
3416
  },
2677
3417
 
3418
+ /**
3419
+ * #getter
3420
+ * the categorical color keys drawn for this view, shared by the on-screen
3421
+ * legend overlay and the SVG export's reserved column. The domain overlay
3422
+ * produces the first, listing the `featureFill` scale where an encoding
3423
+ * names one. Every field a row-table encoding or a row panel reads
3424
+ * produces one more, so two channels over one field, or two strips over
3425
+ * it, list that field once. A strip's `legend` names the title it lists
3426
+ * under, so strips over eight fields with one set of colors list one
3427
+ * legend between them
3428
+ */
3429
+ get legends(): Legend[] {
3430
+ const { featureFillEncoding, fillPalette, visibleDomainTypes } = this
3431
+ const byKey = new Map<string, Legend>()
3432
+ // the accession palette keys on no field, so it takes a key of its own
3433
+ const add = (
3434
+ field: string | undefined,
3435
+ entries: LegendEntry[],
3436
+ id?: string,
3437
+ ) => {
3438
+ if (entries.length === 0) {
3439
+ return
3440
+ }
3441
+ const key = field === undefined ? 'domains' : `field:${field}`
3442
+ const legend = byKey.get(key)
3443
+ if (legend) {
3444
+ const seen = new Set(legend.entries.map(e => e.id))
3445
+ legend.entries.push(...entries.filter(e => !seen.has(e.id)))
3446
+ } else {
3447
+ byKey.set(key, {
3448
+ id: id ?? (field === undefined ? 'domains' : `rowData-${field}`),
3449
+ title: field ?? 'Domains',
3450
+ entries: [...entries],
3451
+ })
3452
+ }
3453
+ }
3454
+ // the overlay draws the domain key; with no columns it draws nothing,
3455
+ // and a features panel taking the same colors lists them instead
3456
+ if (self.actuallyShowDomains && self.numColumns > 0) {
3457
+ add(
3458
+ featureFillEncoding?.field,
3459
+ featureFillEncoding
3460
+ ? featureFillEncoding.legend
3461
+ : visibleDomainTypes.map(d => ({
3462
+ id: d.accession,
3463
+ label: d.name,
3464
+ color: fillPalette[d.accession]!,
3465
+ })),
3466
+ 'domains',
3467
+ )
3468
+ }
3469
+ for (const { channel, field, legend } of this.resolvedEncodings) {
3470
+ if (!featureChannels.has(channel)) {
3471
+ add(field, legend)
3472
+ }
3473
+ }
3474
+ for (const { legendTitle, legend } of this.resolvedRowPanels) {
3475
+ add(legendTitle, legend)
3476
+ }
3477
+ return [...byKey.values()]
3478
+ },
3479
+
3480
+ /**
3481
+ * #getter
3482
+ * whether the overlay marks each domain with a bar under its row instead
3483
+ * of filling the row behind the letters. Letter-color mode hands the
3484
+ * background to the color scheme, so a filled box would paint over it and
3485
+ * leave the setting with nothing to show. Sub-row layout already stacks
3486
+ * the boxes clear of the letters, and with the letters too small to draw
3487
+ * the filled box is the only thing left to read.
3488
+ */
3489
+ get domainUnderline() {
3490
+ return (
3491
+ self.actuallyShowDomains &&
3492
+ !self.bgColor &&
3493
+ !self.subFeatureRows &&
3494
+ self.showMsaLetters
3495
+ )
3496
+ },
3497
+
2678
3498
  /**
2679
3499
  * #getter
2680
3500
  * every filtered-on annotation resolved to the visible column span it is
2681
3501
  * drawn across, keyed by row name. Each row is ordered longest-first so a
2682
- * short domain nested inside a long one draws on top of it rather than
2683
- * under it. Resolving these once here rather than inside each canvas
2684
- * block removes a per-feature, per-block sequence position conversion
2685
- * from every redraw, and gives the letter renderer the band colors it
2686
- * needs to keep residues readable on top of the boxes.
3502
+ * nested short domain draws on top, and each band carries the lane the
3503
+ * sub-row layout puts it in. Resolved once here instead of per canvas
3504
+ * block per redraw; the letter renderer also reads the band colors to
3505
+ * pick legible letter colors.
2687
3506
  */
2688
3507
  get domainBands() {
2689
3508
  const { blanks } = self
@@ -2694,14 +3513,11 @@ function stateModelFactory() {
2694
3513
  const rowBands = annotations
2695
3514
  .toSorted((a, b) => len(b) - len(a))
2696
3515
  .map(annotation => {
2697
- // annotation positions are 1-based and inclusive. Both ends count
2698
- // the visible columns in front of a global column, so endCol is
2699
- // the exclusive column after the last residue's own column --
2700
- // the band stops there rather than stretching across a following
2701
- // gap run -- and a residue whose column is itself hidden
2702
- // collapses onto the neighbouring boundary instead of dropping
2703
- // the band. A band whose every column is hidden spans nothing
2704
- // and is left out, as is one naming a row the alignment lacks.
3516
+ // annotation positions are 1-based and inclusive. endCol is
3517
+ // exclusive, one past the last residue's column, so the band does
3518
+ // not extend over a following gap run. An endpoint in a hidden
3519
+ // column moves to the neighboring boundary. Bands with no visible
3520
+ // columns, or naming a missing row, are dropped.
2705
3521
  const start = self.seqPosToGlobalCol(name, annotation.start - 1)
2706
3522
  const end = self.seqPosToGlobalCol(name, annotation.end - 1)
2707
3523
  if (start === undefined || end === undefined) {
@@ -2714,11 +3530,10 @@ function stateModelFactory() {
2714
3530
  : undefined
2715
3531
  })
2716
3532
  .filter(notEmpty)
2717
- // numbered after the drop, so a band that resolved to nothing does
2718
- // not leave an empty sub-row behind it
3533
+ // numbered after filtering, so a dropped band leaves no gap
2719
3534
  .map((band, stackIndex) => ({ ...band, stackIndex }))
2720
3535
  if (rowBands.length > 0) {
2721
- bands.set(name, rowBands)
3536
+ bands.set(name, packDomainLanes(rowBands))
2722
3537
  }
2723
3538
  }
2724
3539
  return bands
@@ -2755,9 +3570,6 @@ function stateModelFactory() {
2755
3570
  const hits = (this.domainBands.get(name) ?? [])
2756
3571
  .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
2757
3572
  .map(b => b.annotation)
2758
- // the shared empty array, so moving the mouse across an alignment with
2759
- // no annotations does not hand every canvas block a new value to
2760
- // re-render on
2761
3573
  return hits.length > 0 ? hits : noDomains
2762
3574
  },
2763
3575
 
@@ -2776,8 +3588,7 @@ function stateModelFactory() {
2776
3588
  * #getter
2777
3589
  * row indices highlighted by the current tree hover (a hovered internal
2778
3590
  * node highlights every tip below it). Shared by the tree and MSA overlay
2779
- * canvases so they cannot disagree, and resolved through the memoized
2780
- * name->index map rather than rebuilding a lookup on each mouse move.
3591
+ * canvases, via the memoized name->index map.
2781
3592
  */
2782
3593
  get hoveredRowIndices() {
2783
3594
  const { hoveredTreeNode, rowNamesSet } = self
@@ -2791,8 +3602,8 @@ function stateModelFactory() {
2791
3602
  /**
2792
3603
  * #getter
2793
3604
  * contiguous runs of `highlightedColumns`, so a run of highlighted columns
2794
- * draws as one bordered band. Computed here because the overlay canvas
2795
- * redraws on every mouse move while the highlight itself rarely changes.
3605
+ * draws as one bordered band. Memoized because the overlay canvas redraws
3606
+ * on every mouse move.
2796
3607
  */
2797
3608
  get highlightedColumnRuns() {
2798
3609
  const { highlightedColumns } = self
@@ -2818,13 +3629,8 @@ function stateModelFactory() {
2818
3629
  * is dropped. Row names that match no row are ignored.
2819
3630
  */
2820
3631
  get resolvedHighlights(): ResolvedHighlight[] {
2821
- const { blanks, rowNamesSet, transientHighlights } = self
2822
- const toVisible = (globalCol: number) => {
2823
- const visible = self.globalColToVisibleCol(globalCol)
2824
- return visible ?? visibleColsBefore(blanks, globalCol)
2825
- }
2826
- // the document's own highlights first, then what each owner is showing
2827
- // right now, so a hover draws over a persisted band rather than under it
3632
+ const { rowNamesSet, transientHighlights } = self
3633
+ // persisted highlights first, so transient ones draw on top
2828
3634
  const all = [
2829
3635
  ...self.highlights,
2830
3636
  ...Object.values(transientHighlights).flat(),
@@ -2840,72 +3646,254 @@ function stateModelFactory() {
2840
3646
  if (start === undefined || end === undefined) {
2841
3647
  return []
2842
3648
  }
2843
- let startGlobal = start - 1
2844
- let endGlobal = end - 1
2845
- if (row !== undefined) {
2846
- const rowStart = self.seqPosToGlobalCol(row, start - 1)
2847
- const rowEnd = self.seqPosToGlobalCol(row, end - 1)
2848
- if (rowStart === undefined || rowEnd === undefined) {
2849
- return []
3649
+ const span = self.visibleSpan({ row, start, end })
3650
+ return span ? [{ ...base, ...span, rowIndices: [] }] : []
3651
+ })
3652
+ },
3653
+
3654
+ /**
3655
+ * #method
3656
+ * per-column summary statistics: consensus residue and its identity
3657
+ * fraction, both conservation scores, gap fraction, and the sorted non-gap
3658
+ * residue distribution. undefined past the end of the alignment or for an
3659
+ * all-gap column.
3660
+ */
3661
+ columnStatsAt(col: number): ColumnStats | undefined {
3662
+ const { colStats, conservation, propertyConservation } = self
3663
+ return columnStats({
3664
+ col,
3665
+ colStats,
3666
+ conservation,
3667
+ propertyConservation,
3668
+ })
3669
+ },
3670
+
3671
+ /**
3672
+ * #method
3673
+ */
3674
+ getRowData(name: string) {
3675
+ return {
3676
+ data: self.MSA?.getRowData(name),
3677
+ rowData: self.rowDataOf(name),
3678
+ }
3679
+ },
3680
+
3681
+ /**
3682
+ * #getter
3683
+ * each encoding with its scale resolved against the values its field
3684
+ * takes: a feature channel reads them across the features drawn, every
3685
+ * other channel across the row table. Resolved once per change of that
3686
+ * table or the encodings, never per row per frame.
3687
+ */
3688
+ get resolvedEncodings(): ResolvedEncoding[] {
3689
+ const rows = Object.values(self.rowData)
3690
+ const features = self.filteredAnnotations
3691
+ return self.encodings.map(encoding => ({
3692
+ ...encoding,
3693
+ ...resolveScale(
3694
+ encoding.scale,
3695
+ featureChannels.has(encoding.channel)
3696
+ ? features
3697
+ .map(a => featureField(a, encoding.field))
3698
+ .filter(notEmpty)
3699
+ : rows.map(row => row?.[encoding.field]).filter(notEmpty),
3700
+ ),
3701
+ }))
3702
+ },
3703
+
3704
+ /**
3705
+ * #getter
3706
+ * each row panel with its scale resolved against the values its field
3707
+ * takes across the row table, giving the color per row name, the pixel
3708
+ * column it draws in, and the entries its legend lists. Resolved once
3709
+ * per change of that table or the panels, never per block per frame.
3710
+ */
3711
+ get resolvedRowPanels(): ResolvedRowPanel[] {
3712
+ const rows = Object.entries(self.rowData)
3713
+ const { fillPalette, featureFillEncoding, featureLabels } = this
3714
+ const annotations = self.filteredAnnotations
3715
+ let offsetX = 0
3716
+ return self.rowPanels.map((panel, index) => {
3717
+ const width = rowPanelWidth(panel, self.rowHeight)
3718
+ const base = { id: `rowpanel-${index}`, width, offsetX }
3719
+ offsetX += width
3720
+ if (panel.kind === 'features') {
3721
+ const color = panel.encoding?.color
3722
+ const encoding = color
3723
+ ? resolveFeatureScale(color.field, color.scale, annotations)
3724
+ : featureFillEncoding
3725
+ const label = panel.encoding?.label
3726
+ const align = panel.transform?.find(t => t.type === 'align')
3727
+ return {
3728
+ ...base,
3729
+ kind: panel.kind,
3730
+ x: panel.x,
3731
+ header: panel.header ?? '',
3732
+ field: encoding?.field,
3733
+ legendTitle: encoding?.field,
3734
+ spans: featurePanelSpans({
3735
+ panel,
3736
+ width,
3737
+ colWidth: self.colWidth,
3738
+ domainBands: this.domainBands,
3739
+ annotationsByRow: self.annotationsByRow,
3740
+ shifts: align
3741
+ ? this.featureAlignShifts.get(align.on)
3742
+ : undefined,
3743
+ }),
3744
+ colors: featureColorMap(annotations, encoding, fillPalette),
3745
+ labels: label
3746
+ ? featureLabelMap(annotations, label)
3747
+ : featureLabels,
3748
+ legend:
3749
+ encoding?.legend ??
3750
+ this.visibleDomainTypes.map(d => ({
3751
+ id: d.accession,
3752
+ label: d.name,
3753
+ color: fillPalette[d.accession]!,
3754
+ })),
3755
+ }
3756
+ }
3757
+ const { colorOf, legend } = resolveScale(
3758
+ panel.scale,
3759
+ rows.map(([, row]) => row?.[panel.field]).filter(notEmpty),
3760
+ )
3761
+ const colors = new Map<string, string>()
3762
+ for (const [name, row] of rows) {
3763
+ const value = row?.[panel.field]
3764
+ const color = value === undefined ? undefined : colorOf(value)
3765
+ if (color) {
3766
+ colors.set(name, color)
2850
3767
  }
2851
- startGlobal = rowStart
2852
- endGlobal = rowEnd
2853
3768
  }
2854
- const startCol = toVisible(startGlobal)
2855
- const endVisible = self.globalColToVisibleCol(endGlobal)
2856
- const endCol = endVisible ?? visibleColsBefore(blanks, endGlobal) - 1
2857
- return startCol <= endCol
2858
- ? [{ ...base, startCol, endCol, rowIndices: [] }]
2859
- : []
3769
+ return {
3770
+ ...base,
3771
+ kind: panel.kind,
3772
+ field: panel.field,
3773
+ header: panel.header ?? panel.field,
3774
+ legendTitle: panel.legend ?? panel.field,
3775
+ colors,
3776
+ legend,
3777
+ }
2860
3778
  })
2861
3779
  },
2862
3780
 
2863
3781
  /**
2864
3782
  * #getter
2865
- * per-column summary statistics for the hovered column: consensus residue
2866
- * and its identity fraction, conservation score, gap fraction, and the
2867
- * sorted non-gap residue distribution. undefined when nothing is hovered.
3783
+ * the shift an `align` transform gives each row, keyed by the feature
3784
+ * name it aligns on: the offset putting the first feature of that name
3785
+ * at zero. A row carrying no such feature is absent, and keeps its own
3786
+ * origin.
3787
+ */
3788
+ get featureAlignShifts(): Map<string, Map<string, number>> {
3789
+ const names = new Set(
3790
+ self.rowPanels.flatMap(panel =>
3791
+ panel.kind === 'features' && panel.x === 'position'
3792
+ ? (panel.transform ?? [])
3793
+ .filter(t => t.type === 'align')
3794
+ .map(t => t.on)
3795
+ : [],
3796
+ ),
3797
+ )
3798
+ return new Map(
3799
+ [...names].map(on => [
3800
+ on,
3801
+ new Map(
3802
+ Object.entries(self.annotationsByRow)
3803
+ .map(([name, annotations]) => {
3804
+ const hit = annotations.find(a => featureName(a) === on)
3805
+ return hit ? ([name, 1 - hit.start] as const) : undefined
3806
+ })
3807
+ .filter(notEmpty),
3808
+ ),
3809
+ ]),
3810
+ )
3811
+ },
3812
+
3813
+ /**
3814
+ * #getter
3815
+ * the color the `tipLabel` channel gives each row, by row name. Undefined
3816
+ * when no encoding names the channel, which leaves the labels the theme's
3817
+ * text color.
2868
3818
  */
2869
- get mouseOverColumnStats() {
2870
- const { mouseCol } = self
2871
- if (mouseCol === undefined) {
3819
+ get tipLabelColors(): Map<string, string> | undefined {
3820
+ const encoding = this.resolvedEncodings.find(
3821
+ e => e.channel === 'tipLabel',
3822
+ )
3823
+ if (!encoding) {
2872
3824
  return undefined
2873
3825
  }
2874
- const { colStats } = self
2875
- if (mouseCol >= colStats.numColumns) {
2876
- return undefined
3826
+ const colors = new Map<string, string>()
3827
+ for (const [name, row] of Object.entries(self.rowData)) {
3828
+ const value = row?.[encoding.field]
3829
+ const color =
3830
+ value === undefined ? undefined : encoding.colorOf(value)
3831
+ if (color) {
3832
+ colors.set(name, color)
3833
+ }
2877
3834
  }
2878
- const total = colStats.total(mouseCol)
2879
- if (!total) {
3835
+ return colors
3836
+ },
3837
+
3838
+ /**
3839
+ * #getter
3840
+ * the wash the `rowTint` channel draws over each row, indexed by row, or
3841
+ * undefined when no encoding names the channel. The overlay draws these,
3842
+ * so a tint stays out of the raster tile cache and its keys.
3843
+ */
3844
+ get rowTints(): (string | undefined)[] | undefined {
3845
+ const encoding = this.resolvedEncodings.find(
3846
+ e => e.channel === 'rowTint',
3847
+ )
3848
+ if (!encoding) {
2880
3849
  return undefined
2881
3850
  }
2882
- const gaps = colStats.gapCount(mouseCol)
2883
- const distribution = colStats
2884
- .residueEntries(mouseCol)
2885
- .sort((a, b) => b[1] - a[1])
2886
- const consensus = distribution[0]
2887
- return {
2888
- col: mouseCol,
2889
- total,
2890
- gaps,
2891
- gapFraction: gaps / total,
2892
- conservation: self.conservation[mouseCol] ?? 0,
2893
- propertyConservation: self.propertyConservation[mouseCol],
2894
- consensusLetter: consensus?.[0] ?? '',
2895
- consensusCount: consensus?.[1] ?? 0,
2896
- consensusFraction: consensus ? consensus[1] / total : 0,
2897
- distribution,
2898
- }
3851
+ return self.rowNames.map(name => {
3852
+ const value = self.rowDataOf(name)?.[encoding.field]
3853
+ const color =
3854
+ value === undefined ? undefined : encoding.colorOf(value)
3855
+ if (!color) {
3856
+ return undefined
3857
+ }
3858
+ return withAlpha(color, rowTintAlpha)
3859
+ })
2899
3860
  },
2900
3861
 
2901
3862
  /**
2902
- * #method
3863
+ * #getter
3864
+ * the color the `branch` channel gives each tree edge, by the node id at
3865
+ * the edge's far end, or undefined when no encoding names the channel. A
3866
+ * node takes the field value its tips agree on, so a clade of one value
3867
+ * colors down from where it splits off, and a node whose tips disagree or
3868
+ * whose value has no color is absent and draws in the default color.
3869
+ *
3870
+ * The pass runs over the whole tree, never `root`, so a collapsed or
3871
+ * focused clade keeps the color the full tree gives it.
2903
3872
  */
2904
- getRowData(name: string) {
2905
- return {
2906
- data: self.MSA?.getRowData(name),
2907
- treeMetadata: self.treeMetadata[name],
3873
+ get branchColors(): Map<string, string> | undefined {
3874
+ const encoding = this.resolvedEncodings.find(
3875
+ e => e.channel === 'branch',
3876
+ )
3877
+ if (!encoding) {
3878
+ return undefined
3879
+ }
3880
+ const order = preorder(self.tree)
3881
+ const values = new Map<NodeWithIds, string | undefined>()
3882
+ const colors = new Map<string, string>()
3883
+ for (let i = order.length - 1; i >= 0; i--) {
3884
+ const node = order[i]!
3885
+ const value =
3886
+ node.children.length > 0
3887
+ ? sharedValue(node.children.map(child => values.get(child)))
3888
+ : self.rowDataOf(node.name)?.[encoding.field]
3889
+ values.set(node, value)
3890
+ const color =
3891
+ value === undefined ? undefined : encoding.colorOf(value)
3892
+ if (color) {
3893
+ colors.set(node.id, color)
3894
+ }
2908
3895
  }
3896
+ return colors
2909
3897
  },
2910
3898
  }))
2911
3899
  .actions(self => ({
@@ -2918,33 +3906,34 @@ function stateModelFactory() {
2918
3906
  /**
2919
3907
  * #action
2920
3908
  */
2921
- setConservationTrackHeight(arg: number) {
2922
- self.conservationTrackHeight = arg
3909
+ setHideHeader(arg: boolean) {
3910
+ self.hideHeader = arg
2923
3911
  },
2924
3912
  /**
2925
3913
  * #action
3914
+ * focus the subtree a click `y` pixels down the tree overview lands on.
3915
+ * A click inside the box already drawn there clears the focus, the way
3916
+ * clicking the focused branch again does.
2926
3917
  */
2927
- setColumnTrackHeight(id: string, height: number) {
2928
- self.columnTrackHeights = { ...self.columnTrackHeights, [id]: height }
3918
+ treeOverviewClick(y: number) {
3919
+ const hit = self.treeOverviewHit(y)
3920
+ const focus = self.treeOverviewFocusRows
3921
+ const inside =
3922
+ !!focus && !!hit && hit.rows[0] >= focus[0] && hit.rows[1] <= focus[1]
3923
+ self.setShowOnly(inside || !hit ? undefined : hit.id)
2929
3924
  },
2930
3925
  /**
2931
3926
  * #action
3927
+ * resize every track sharing a `heightKey`; see `trackHeights`
2932
3928
  */
2933
- setSequenceLogoTrackHeight(arg: number) {
2934
- self.sequenceLogoTrackHeight = arg
3929
+ setTrackHeight(heightKey: string, height: number) {
3930
+ self.trackHeights.set(heightKey, height)
2935
3931
  },
2936
3932
  /**
2937
3933
  * #action
2938
- */
2939
- setArcTrackHeight(arg: number) {
2940
- self.arcTrackHeight = arg
2941
- },
2942
- /**
2943
- * #action
2944
- * Return to the import form: every property off `preservedOnReset`
2945
- * (data, filehandles, collapsed/showOnly, zoom, scroll, ...) goes back
2946
- * to its default, then the file-derived volatiles applySnapshot cannot
2947
- * reach are cleared by hand.
3934
+ * Return to the import form: reset every property not in
3935
+ * `preservedOnReset` to its default, then clear the file-derived
3936
+ * volatiles applySnapshot does not touch.
2948
3937
  */
2949
3938
  reset() {
2950
3939
  self.resetCount++
@@ -2977,8 +3966,8 @@ function stateModelFactory() {
2977
3966
  },
2978
3967
  /**
2979
3968
  * #action
2980
- * draw this annotation type, or stop drawing it. Only the "stop" is
2981
- * recorded -- see `turnedOffFeatures`
3969
+ * show or hide an annotation type. Only hidden types are recorded; see
3970
+ * `turnedOffFeatures`
2982
3971
  */
2983
3972
  setFilter(accession: string, shown: boolean) {
2984
3973
  if (shown) {
@@ -2992,12 +3981,8 @@ function stateModelFactory() {
2992
3981
  * #action
2993
3982
  */
2994
3983
  fit() {
2995
- // Each direction's viewport depends on the other's result: fitting the
2996
- // rows while the columns still overflow measures against a height the
2997
- // minimap is taking, and fitting the columns while the rows still
2998
- // overflow measures against a width the vertical scrollbar is taking.
2999
- // A second pass measures against the geometry the first pass produced,
3000
- // which is the one the reader ends up looking at.
3984
+ // two passes: each direction's viewport depends on whether the other
3985
+ // overflows (the minimap takes height, the vertical scrollbar width)
3001
3986
  transaction(() => {
3002
3987
  for (let pass = 0; pass < 2; pass++) {
3003
3988
  this.fitHorizontally()
@@ -3018,14 +4003,36 @@ function stateModelFactory() {
3018
4003
  }
3019
4004
  self.scrollY = 0
3020
4005
  },
4006
+ /**
4007
+ * #action
4008
+ * zoom and scroll so a span fills the alignment's width, in highlight
4009
+ * coordinates (see visibleSpan). Does nothing before the viewer knows
4010
+ * its width, or for a span that resolves to no visible column.
4011
+ */
4012
+ zoomToRegion(region: Region) {
4013
+ const span = self.visibleSpan(region)
4014
+ if (!span || !self.viewInitialized) {
4015
+ return
4016
+ }
4017
+ transaction(() => {
4018
+ self.colWidth = clamp(
4019
+ self.msaCanvasWidth / (span.endCol - span.startCol + 1),
4020
+ minColWidth,
4021
+ maxCellSize,
4022
+ )
4023
+ self.scrollX = clamp(
4024
+ -span.startCol * self.colWidth,
4025
+ self.maxScrollX,
4026
+ 0,
4027
+ )
4028
+ })
4029
+ },
3021
4030
  /**
3022
4031
  * #action
3023
4032
  */
3024
4033
  fitHorizontally() {
3025
4034
  if (self.numColumns > 0) {
3026
- // fitting to msaAreaWidth instead left the last ~20px of columns off
3027
- // the right edge -- and short of the width that shows a minimap, so
3028
- // nothing on screen said they were there
4035
+ // msaCanvasWidth excludes the vertical scrollbar's 20px
3029
4036
  self.colWidth = clamp(
3030
4037
  self.msaCanvasWidth / self.numColumns,
3031
4038
  minColWidth,
@@ -3036,17 +4043,46 @@ function stateModelFactory() {
3036
4043
  },
3037
4044
 
3038
4045
  afterCreate() {
3039
- // seed the highlighted-columns overlay from the declarative property so
3040
- // a shared snapshot/URL opens with those columns highlighted (the
3041
- // volatile highlightedColumns can later be driven by genome-hover sync)
3042
4046
  if (self.highlightColumns?.length) {
3043
4047
  self.setHighlightedColumns(self.highlightColumns)
3044
4048
  }
3045
4049
 
3046
- // track the live device pixel ratio so canvas backing stores re-scale
3047
- // when the window moves between monitors or the browser zooms. The
3048
- // matchMedia query is pinned to the current ratio, so each change
3049
- // re-registers against the new one to keep tracking further moves.
4050
+ // The `collapse` and `focus` clade marks seed the collapsed list and
4051
+ // the subtree in focus, which the tree, `hideGapsEffective` and the
4052
+ // alignment all read. The tree arrives with the model for inline data
4053
+ // and later for a filehandle, so the seeding waits for it and then runs
4054
+ // once: expanding a seeded clade sticks, and the record collapses it
4055
+ // again only on reload. `dataInitialized` is true once the MSA alone
4056
+ // has loaded, when the tree is still the flat stub, so a tree
4057
+ // filehandle holds the seeding until its text lands.
4058
+ let cladesSeeded = false
4059
+ addDisposer(
4060
+ self,
4061
+ autorun(() => {
4062
+ if (
4063
+ cladesSeeded ||
4064
+ !self.dataInitialized ||
4065
+ (self.treeFilehandle && !self.data.tree) ||
4066
+ self.clades.length === 0
4067
+ ) {
4068
+ return
4069
+ }
4070
+ cladesSeeded = true
4071
+ for (const { mark, nodeId } of self.resolvedClades) {
4072
+ if (nodeId === undefined) {
4073
+ continue
4074
+ }
4075
+ if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
4076
+ self.toggleCollapsed(nodeId)
4077
+ } else if (mark === 'focus') {
4078
+ self.setShowOnly(nodeId)
4079
+ }
4080
+ }
4081
+ }),
4082
+ )
4083
+
4084
+ // the matchMedia query is pinned to the current device pixel ratio, so
4085
+ // each change re-registers against the new one
3050
4086
  if (
3051
4087
  typeof window !== 'undefined' &&
3052
4088
  typeof window.matchMedia === 'function'
@@ -3070,25 +4106,17 @@ function stateModelFactory() {
3070
4106
  * Fetch a filehandle whenever it changes, and hand the text to
3071
4107
  * `onLoad`.
3072
4108
  *
3073
- * Every loader carries a generation guard: the filehandle can change
3074
- * mid-fetch (a second file picked while the first is still in flight),
3075
- * and the slower earlier request must not clobber the data, status, or
3076
- * loading flag belonging to the newer one. A superseded or cleared
3077
- * request is also aborted, rather than left downloading a file nothing
3078
- * is waiting for, and it gives back the status line it was writing --
3079
- * a reset() mid-download used to leave "Downloading file" and a Cancel
3080
- * button behind on the import form.
4109
+ * A generation guard keeps a slower superseded request from overwriting
4110
+ * the newer one's data, status or loading flag. A superseded or cleared
4111
+ * request is aborted and clears its status line.
3081
4112
  *
3082
- * `clearFilehandle` serves two purposes for the loaders that pass it.
3083
- * A local file has no URL to refetch from, so the handle is dropped
3084
- * once its bytes are in the model; and a fetch the user cancels drops
3085
- * it too, returning the view to the import form rather than leaving a
3086
- * stuck spinner.
4113
+ * `clearFilehandle` runs after a local file loads, since a blob has no
4114
+ * URL to refetch, and when the user cancels, which returns the view to
4115
+ * the import form.
3087
4116
  *
3088
- * `what` names the layer in a failure message. An optional layer
3089
- * (annotations, row metadata) that fails to load is a warning: it is
3090
- * not worth replacing an alignment the reader is looking at with an
3091
- * error screen over a decorative file.
4117
+ * `what` names the layer in a failure message. An `optional` layer
4118
+ * (annotations, row metadata) that fails adds a warning instead of an
4119
+ * error.
3092
4120
  */
3093
4121
  const loadOnFilehandleChange = ({
3094
4122
  what,
@@ -3186,8 +4214,7 @@ function stateModelFactory() {
3186
4214
  },
3187
4215
  })
3188
4216
 
3189
- // treeMetadata is decorative and has no import-form step of its own, so
3190
- // it keeps no loading flag and nothing to return to on cancel
4217
+ // treeMetadata has no import-form step, so no loading flag or cancel
3191
4218
  loadOnFilehandleChange({
3192
4219
  what: 'row metadata',
3193
4220
  optional: true,
@@ -3197,11 +4224,8 @@ function stateModelFactory() {
3197
4224
  },
3198
4225
  })
3199
4226
 
3200
- // autorun parses inline gff text from data.gff. Text that goes away
3201
- // takes its annotations with it -- setData with a new alignment and no
3202
- // gff used to leave the previous file's annotations drawn over it --
3203
- // while annotations a host set directly are left alone, which is why
3204
- // this tracks what it applied instead of reading the current list
4227
+ // parses data.gff into annotations. Clearing the text clears only the
4228
+ // annotations this autorun applied, not ones a host set directly
3205
4229
  let appliedGFF = false
3206
4230
  addDisposer(
3207
4231
  self,
@@ -3212,8 +4236,6 @@ function stateModelFactory() {
3212
4236
  self.applyGFFText(gffText)
3213
4237
  appliedGFF = true
3214
4238
  } catch (e) {
3215
- // a malformed overlay is not worth replacing the alignment
3216
- // with an error screen
3217
4239
  console.error(e)
3218
4240
  self.addWarning(`The annotations did not parse: ${e}`)
3219
4241
  }
@@ -3224,9 +4246,25 @@ function stateModelFactory() {
3224
4246
  }),
3225
4247
  )
3226
4248
 
3227
- // gffFilehandle carries overlay annotations. It loads into data.gff and
3228
- // the autorun above parses it, so there is one parse path whether the
3229
- // text arrived from a file or from a snapshot
4249
+ // a GFF whose first column names rows of some other alignment parses
4250
+ // without error and draws nothing
4251
+ addDisposer(
4252
+ self,
4253
+ autorun(() => {
4254
+ const { annotations, rowNamesSet } = self
4255
+ if (
4256
+ annotations.length > 0 &&
4257
+ rowNamesSet.size > 0 &&
4258
+ !annotations.some(a => rowNamesSet.has(a.id))
4259
+ ) {
4260
+ self.addWarning(
4261
+ `0 of ${annotations.length} annotations name a row in this alignment`,
4262
+ )
4263
+ }
4264
+ }),
4265
+ )
4266
+
4267
+ // gffFilehandle loads into data.gff, which the autorun above parses
3230
4268
  loadOnFilehandleChange({
3231
4269
  what: 'annotations',
3232
4270
  optional: true,
@@ -3253,19 +4291,12 @@ function stateModelFactory() {
3253
4291
  },
3254
4292
  })
3255
4293
 
3256
- // Keep the parse chain warm: reading self.columns transitively holds
3257
- // self.MSA (parseMSA) computed alive, so it is parsed once per data
3258
- // change rather than re-parsed on every non-reactive access. Do not
3259
- // remove.
4294
+ // Keep computeds alive that are read outside reactions, so they are not
4295
+ // recomputed on every access: self.columns (and through it the
4296
+ // parseMSA result), and the column statistics that dynamic color
4297
+ // schemes and the mousemove-driven hover tooltip read.
3260
4298
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
3261
4299
  // xref problem https://github.com/GMOD/react-msaview/issues/75
3262
- //
3263
- // The column statistics are held for the same reason whenever something
3264
- // reads them off the reactive path: dynamic color schemes, and the hover
3265
- // tooltip, which is read from a mousemove handler. Without this the
3266
- // tooltip's cost depends on whether some visible track happens to be
3267
- // observing them, so closing the conservation track would silently turn
3268
- // every mouse move into a full-alignment recount.
3269
4300
  addDisposer(
3270
4301
  self,
3271
4302
  autorun(() => {
@@ -3287,10 +4318,9 @@ function stateModelFactory() {
3287
4318
  }),
3288
4319
  )
3289
4320
 
3290
- // autorun: when autoTreeAreaWidth is set and no tree is drawn, shrink the
3291
- // tree area to fit the row labels rather than padding it to the fixed
3292
- // default. Gated on noTree/!drawTree so it never fights the treeWidth sync
3293
- // below (treeAreaWidth here depends only on labelsWidth, not treeWidth).
4321
+ // with autoTreeAreaWidth and no tree drawn, size the tree area to the
4322
+ // labels. Gated on noTree/!drawTree so it does not loop with the
4323
+ // treeWidth autorun below
3294
4324
  addDisposer(
3295
4325
  self,
3296
4326
  autorun(() => {
@@ -3299,19 +4329,17 @@ function stateModelFactory() {
3299
4329
  (self.noTree || !self.drawTree) &&
3300
4330
  self.labelsWidth
3301
4331
  ) {
3302
- self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12)
4332
+ self.setTreeAreaWidth(
4333
+ self.labelsWidth + self.marginLeft + 12 + self.cladeGutterWidth,
4334
+ )
3303
4335
  }
3304
4336
  }),
3305
4337
  )
3306
4338
 
3307
- // treeWidth trails the tree area, less whatever the labels take out of
3308
- // it. A width that arrived with the snapshot is left alone until the
3309
- // tree area itself moves: a host that opens a view with a narrow tree
3310
- // beside wide labels -- jbrowse-plugin-msaview does, at treeWidth 100
3311
- // in a 200px area -- had its value overwritten on the first frame, and
3312
- // a restored session came back re-derived rather than as it was left.
3313
- // A getter cannot do this: labelsWidth is measured off the leaves,
3314
- // which are laid out against treeWidth.
4339
+ // treeWidth follows the tree area less the labels. A non-default
4340
+ // snapshot treeWidth (jbrowse-plugin-msaview opens at 100 in a 200px
4341
+ // area) is kept until the tree area changes. Not a getter, because
4342
+ // labelsWidth is measured off leaves laid out against treeWidth.
3315
4343
  let pinnedAreaWidth =
3316
4344
  self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth
3317
4345
  addDisposer(
@@ -3324,16 +4352,22 @@ function stateModelFactory() {
3324
4352
  }
3325
4353
  pinnedAreaWidth = undefined
3326
4354
  self.setTreeWidth(
3327
- Math.max(50, areaWidth - labelsWidth - 10 - self.marginLeft),
4355
+ Math.max(
4356
+ 50,
4357
+ areaWidth -
4358
+ labelsWidth -
4359
+ 10 -
4360
+ self.marginLeft -
4361
+ self.cladeGutterWidth,
4362
+ ),
3328
4363
  )
3329
4364
  }),
3330
4365
  )
3331
4366
  },
3332
4367
  }))
3333
4368
  .postProcessSnapshot(({ data, columnTracks, ...rest }) => ({
3334
- // per-property defaults are stripped by the stripDefault helper; the only thing
3335
- // it can't express is this cross-field rule: drop inline tree/msa/metadata
3336
- // when a sibling filehandle can refetch them, keeping sessions/URLs small
4369
+ // stripDefault handles per-property defaults; this drops inline documents
4370
+ // whose filehandle can refetch them
3337
4371
  ...rest,
3338
4372
  ...smallColumnTracks(columnTracks),
3339
4373
  data: {