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/dist/model.js CHANGED
@@ -2,20 +2,25 @@ import { clamp, groupBy, notEmpty, sum } from '@jbrowse/core/util';
2
2
  import { openLocation } from '@jbrowse/core/util/io';
3
3
  import { ElementId, FileLocation } from '@jbrowse/core/util/types/mst';
4
4
  import { addDisposer, applySnapshot, cast, getSnapshot, isAlive, types, } from '@jbrowse/mobx-state-tree';
5
- import { colord } from 'colord';
6
- import { autorun, transaction } from 'mobx';
5
+ import { autorun, computed, transaction } from 'mobx';
7
6
  import { generateNodeIds, gffToAnnotations, interProScanToAnnotations, parseEmfTree, parseGFF, parseMSA, parseNewick, } from 'msa-parsers';
8
7
  import { calculateBlocks } from './calculateBlocks.js';
9
8
  import { clustalXColumnColors } from './clustalX.js';
10
9
  import colorSchemes from './colorSchemes.js';
11
10
  import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.js';
11
+ import { columnStats } from './columnStats.js';
12
+ import { packDomainLanes } from './components/msa/packDomainLanes.js';
13
+ import { visibleColRange } from './components/msa/visibleColRange.js';
12
14
  import TrackBlocks from './components/tracks/TrackBlocks.js';
13
- import { defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultShowDomainLegend, defaultShowDomains, defaultSubFeatureRows, defaultTreeWidth, labelReferenceFontSize, maxCellSize, maxInlineSnapshotBytes, maxNeighborJoiningRows, minColWidth, minLetterColWidth, minLetterRowHeight, minRowHeight, segmentFeatureTypes, segmentShades, } from './constants.js';
15
+ import { cladeGutterWidth } from './components/tree/cladeBrackets.js';
16
+ import { scaleBarLength } from './components/tree/scaleBar.js';
17
+ import { cladeHighlightAlpha, cladeHighlightColor, defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultFeaturePanelWidth, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultScrollZoomAxis, defaultShowDomainLegend, defaultShowDomains, defaultSubFeatureRows, defaultTreeWidth, labelReferenceFontSize, maxCellSize, maxInlineSnapshotBytes, maxNeighborJoiningRows, minColWidth, minLetterColWidth, minLetterRowHeight, minRowHeight, rowPanelHeaderHeight, rowTintAlpha, scrollZoomAxes, segmentFeatureTypes, segmentShades, treeScaleBarHeight, } from './constants.js';
14
18
  import { createPaletteMap } from './createPaletteMap.js';
15
19
  import { exportFileName } from './exportFileName.js';
20
+ import { featureField, featureName } from './featureFields.js';
16
21
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.js';
17
22
  import { flatToTree } from './flatToTree.js';
18
- import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find, forEachDescendant, hierarchy, leaves, maxLength, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
23
+ import { calcDepthToLeaf, clusterLayout, collapse, collapsedSubtreeMaxLength, find, forEachDescendant, hierarchy, leafIndex, leaves, maxLength, mrca, nodeCoveringRows, setBrLength, sort, sum as hierarchySum, } from './hierarchy.js';
19
24
  import { measureTextCanvas } from './measureTextCanvas.js';
20
25
  import { DataModelF } from './model/DataModel.js';
21
26
  import { DialogQueueSessionMixin } from './model/DialogQueue.js';
@@ -25,10 +30,11 @@ import { calculateNeighborJoiningTree } from './neighborJoining.js';
25
30
  import { parseAsn1 } from './parseAsn1.js';
26
31
  import { calculatePropertyConservation } from './propertyConservation.js';
27
32
  import { globalColToVisibleCol, seqPosOfGlobalCol, visibleColToGlobalCol, visibleColsBefore, } from './rowCoordinateCalculations.js';
33
+ import { resolveScale } from './scales.js';
28
34
  import { buildSeqPosIndex } from './seqPosToGlobalCol.js';
29
35
  import { maxBitsFor } from './sequenceLogo.js';
30
36
  import { stripDefault } from './stripDefault.js';
31
- import { computeRowInsertions, dropBlanks, len, skipBlanks, transform, } from './util.js';
37
+ import { computeRowInsertions, dropBlanks, len, outlineColor, skipBlanks, withAlpha, } from './util.js';
32
38
  import { saveAs } from './vendor/fileSaver.js';
33
39
  import { parseWuss } from './wuss.js';
34
40
  function parseTreeText(text) {
@@ -37,14 +43,172 @@ function parseTreeText(text) {
37
43
  }
38
44
  return parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text);
39
45
  }
40
- // Tracks that start hidden. The sequence logo answers a narrower question than
41
- // conservation does and costs three times the vertical space, so it waits to be
42
- // asked for.
46
+ // The height each kind draws at before the user drags a divider. A text track
47
+ // is absent: it is one alignment row tall and follows rowHeight, so the zoom
48
+ // controls already size it.
49
+ export const defaultTrackHeights = {
50
+ bar: 40,
51
+ // taller than a bar track: in a 40px stack of four residues each glyph is
52
+ // too short to identify. Twice the height of the tracks above it, so it
53
+ // starts hidden
54
+ logo: 80,
55
+ arc: 50,
56
+ ruler: 20,
57
+ };
58
+ // the kinds a divider resizes. The ruler is a fixed scale, and a text track
59
+ // follows the vertical zoom.
60
+ const resizableKinds = new Set(['bar', 'logo', 'arc']);
43
61
  const defaultOffTracks = new Set(['sequence-logo', 'position-ruler']);
44
- // base-pair arcs: one color for the nested helices, one for a pseudoknot, whose
45
- // whole point is that it crosses them
62
+ // a data track carries its own height rather than its kind's, so its divider
63
+ // resizes it alone
64
+ const ownHeightKey = (id) => `own:${id}`;
65
+ // base-pair arcs: one color for nested helices, one for pseudoknots, which cross
66
+ // them
46
67
  const HELIX_ARC = '#4e79a7';
47
68
  const PSEUDOKNOT_ARC = '#e15759';
69
+ // the fill of a span under a scale that gives its value no color, such as a
70
+ // gene a `map` over gene names leaves out
71
+ const UNSCALED_FEATURE = '#d9d9d9';
72
+ // the fraction of a span a following one may cover before it takes a lane of
73
+ // its own in a `features` panel
74
+ const laneOverlap = 0.1;
75
+ // a row panel's own width, or a default: the row height, which makes a strip
76
+ // cell square, and a gene neighborhood's width for a features panel
77
+ function rowPanelWidth(panel, rowHeight) {
78
+ return (panel.width ??
79
+ (panel.kind === 'features' ? defaultFeaturePanelWidth : rowHeight));
80
+ }
81
+ // the fill and outline of every feature in a list: its own GFF `color=` first,
82
+ // then the scale an encoding resolves over one of its fields, then the
83
+ // accession palette
84
+ function featureColorMap(annotations, encoding, fillPalette) {
85
+ const strokes = new Map();
86
+ const strokeOf = (fill) => {
87
+ const hit = strokes.get(fill);
88
+ if (hit !== undefined) {
89
+ return hit;
90
+ }
91
+ const stroke = outlineColor(fill);
92
+ strokes.set(fill, stroke);
93
+ return stroke;
94
+ };
95
+ return new Map(annotations.map(annotation => {
96
+ const value = encoding
97
+ ? featureField(annotation, encoding.field)
98
+ : undefined;
99
+ const fill = annotation.color ??
100
+ (value === undefined ? undefined : encoding.colorOf(value)) ??
101
+ (encoding ? UNSCALED_FEATURE : fillPalette[annotation.accession]);
102
+ return [annotation, { fill, stroke: strokeOf(fill) }];
103
+ }));
104
+ }
105
+ // the text a label channel draws inside each span, for the features carrying
106
+ // the field it names
107
+ function featureLabelMap(annotations, field) {
108
+ const labels = new Map();
109
+ for (const annotation of annotations) {
110
+ const value = featureField(annotation, field);
111
+ if (value !== undefined) {
112
+ labels.set(annotation, value);
113
+ }
114
+ }
115
+ return labels;
116
+ }
117
+ /**
118
+ * Lanes for `position: "strandpile"`, gggenomes' `position_strandpile`: the
119
+ * forward features stack above the line and the reverse below it, each strand
120
+ * packing on its own. The deepest row on each side sets the grid every row lays
121
+ * out on, so the line between the strands sits at one height down the panel and
122
+ * a reader can scan it. A feature with no strand piles with the forward ones.
123
+ */
124
+ function strandpileLanes(rows) {
125
+ const piled = rows.map(([name, bands]) => {
126
+ const reverse = packDomainLanes(bands.filter(b => b.annotation.strand === -1));
127
+ const forward = packDomainLanes(bands.filter(b => b.annotation.strand !== -1));
128
+ return { name, forward, reverse };
129
+ });
130
+ const up = Math.max(0, ...piled.map(p => p.forward[0]?.laneCount ?? 0));
131
+ const down = Math.max(0, ...piled.map(p => p.reverse[0]?.laneCount ?? 0));
132
+ const laneCount = up + down;
133
+ return new Map(piled.map(({ name, forward, reverse }) => [
134
+ name,
135
+ [
136
+ // forward level 0 is the lane just above the line, stacking upward
137
+ ...forward.map(b => ({ ...b, lane: up - 1 - b.lane, laneCount })),
138
+ ...reverse.map(b => ({ ...b, lane: up + b.lane, laneCount })),
139
+ ],
140
+ ]));
141
+ }
142
+ /** one row's spans laid out by the panel's `position` */
143
+ function panelLanes(panel, rows) {
144
+ return panel.position === 'strandpile'
145
+ ? strandpileLanes(rows)
146
+ : new Map(rows.map(([name, bands]) => [name, packDomainLanes(bands)]));
147
+ }
148
+ /**
149
+ * The spans a `features` panel draws, keyed by row name and measured in the
150
+ * panel's own pixels. `column` takes the bands the overlay draws, at the
151
+ * alignment's column width. `position` packs each row's features in its own
152
+ * residue positions, shifted by an `align` transform, and maps the extent they
153
+ * cover across every row onto the panel width, so a row with no alignment
154
+ * still has an x.
155
+ */
156
+ function featurePanelSpans({ panel, width, colWidth, domainBands, annotationsByRow, shifts, }) {
157
+ if (panel.x === 'column') {
158
+ return panelLanes(panel, [...domainBands].map(([name, bands]) => [
159
+ name,
160
+ bands.map(band => ({
161
+ ...band,
162
+ xStart: band.startCol * colWidth,
163
+ xEnd: band.endCol * colWidth,
164
+ })),
165
+ ]));
166
+ }
167
+ const shifted = Object.entries(annotationsByRow).map(([name, annotations]) => {
168
+ const shift = shifts?.get(name) ?? 0;
169
+ return [
170
+ name,
171
+ annotations.map(annotation => ({
172
+ annotation,
173
+ start: annotation.start - 1 + shift,
174
+ end: annotation.end + shift,
175
+ })),
176
+ ];
177
+ });
178
+ let min = Infinity;
179
+ let max = -Infinity;
180
+ for (const [, features] of shifted) {
181
+ for (const { start, end } of features) {
182
+ min = Math.min(min, start);
183
+ max = Math.max(max, end);
184
+ }
185
+ }
186
+ // a gene arrow ends where its feature does, so the extent maps onto the whole
187
+ // panel less the pixel the rightmost stroke needs
188
+ const drawable = Math.max(1, width - 1);
189
+ const scale = max > min ? drawable / (max - min) : 0;
190
+ // a lane opens where a span covers more than a tenth of the one before it,
191
+ // which keeps the genes of an operon on one lane: adjacent genes commonly
192
+ // share a few bases, and a stop codon overlapping the next start reads as a
193
+ // second lane over the whole row
194
+ return panelLanes(panel, shifted.map(([name, features]) => [
195
+ name,
196
+ features.map(({ annotation, start, end }) => ({
197
+ annotation,
198
+ xStart: (start - min) * scale,
199
+ xEnd: (end - min) * scale,
200
+ startCol: start,
201
+ endCol: end - (end - start) * laneOverlap,
202
+ })),
203
+ ]));
204
+ }
205
+ // a scale over the values a field takes across the features drawn
206
+ function resolveFeatureScale(field, scale, annotations) {
207
+ return {
208
+ field,
209
+ ...resolveScale(scale, annotations.map(a => featureField(a, field)).filter(notEmpty)),
210
+ };
211
+ }
48
212
  // a data track over this size stays in the live model but leaves the snapshot,
49
213
  // the same rule DataModel applies to an inline document
50
214
  function columnTrackSizes(tracks) {
@@ -59,17 +223,13 @@ function smallColumnTracks(tracks) {
59
223
  * The snapshot properties reset() carries across a return to the import form:
60
224
  * display preferences and layout, nothing derived from the loaded file.
61
225
  *
62
- * reset() applies a default snapshot filtered to this list, so the list is the
63
- * whole decision: a property left off it resets to its default, a visible and
64
- * benign failure. The previous shape — a hand-maintained list of things to
65
- * CLEAR — failed in the dangerous direction: a forgotten property silently
66
- * carried the previous file's state into the next one, and because node ids
67
- * are path-derived (node-0-0-1), a stale `collapsed` or `showOnly` id matched
68
- * a real node in the new tree and folded it. Downstream composed properties
69
- * (e.g. the jbrowse plugin's) are not on the list, so they reset too.
226
+ * reset() applies a default snapshot filtered to this list, so any property
227
+ * left off it resets, including downstream composed ones (e.g. the jbrowse
228
+ * plugin's). Node ids are path-derived (node-0-0-1), so a carried-over
229
+ * `collapsed` or `showOnly` id would match a real node in the next tree and
230
+ * fold it.
70
231
  *
71
- * Exported for modelReset.test.ts, which checks that everything off this list
72
- * matches a freshly created model after reset().
232
+ * Exported for modelReset.test.ts.
73
233
  */
74
234
  export const preservedOnReset = new Set([
75
235
  'id',
@@ -77,6 +237,7 @@ export const preservedOnReset = new Set([
77
237
  'height',
78
238
  'drawMsaLetters',
79
239
  'scrollZoom',
240
+ 'scrollZoomAxis',
80
241
  'bgColor',
81
242
  'colorSchemeName',
82
243
  'showColumnStats',
@@ -87,50 +248,167 @@ export const preservedOnReset = new Set([
87
248
  'showBranchLen',
88
249
  'drawTree',
89
250
  'drawNodeBubbles',
251
+ 'drawNodeLabels',
252
+ 'showTreeOverview',
253
+ 'overviewHeight',
90
254
  'autoTreeAreaWidth',
91
255
  'turnedOffTracks',
256
+ 'trackHeights',
92
257
  'hideGaps',
93
258
  'allowedGappyness',
94
259
  'subFeatureRows',
95
260
  'showDomainLegend',
96
261
  ]);
97
- // `turnedOffTracks` records the user's explicit choices only: an id is absent
98
- // until they touch that track, and then its value is whether the track is OFF.
99
- // Reading the default through this is what lets a track ship hidden without
100
- // writing an entry into every snapshot and shared URL.
101
- // A track the file itself supplies says whether it starts hidden, since only
102
- // the file knows how many of them there are: a Pfam seed carries a couple of
103
- // #=GR lines, an Rfam family one per row.
262
+ // `turnedOffTracks` holds only the user's explicit choices, so a hidden-by-default
263
+ // track writes nothing into the snapshot. A file-supplied track passes its own
264
+ // `defaultOff`, since the count varies by file: a Pfam seed has a couple of #=GR
265
+ // lines, an Rfam family one per row.
104
266
  function trackIsOff(turnedOffTracks, id, defaultOff) {
105
267
  return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id));
106
268
  }
107
- // one array for every "nothing under the pointer", since a fresh [] is a fresh
108
- // value to every observer of it
269
+ // shared empty results, so observers don't see a fresh [] as a change
109
270
  const noDomains = [];
110
- // seqPos -> column indexes, per row, hung off the parse the rows came from so
111
- // they are collected with it. A computed would rebuild every row's index on
112
- // each miss, and rebuild all of them again whenever it was read outside a
113
- // reactive context.
271
+ const noClades = [];
272
+ /**
273
+ * The rows a clade covers and the node an `mrca` names, or undefined when the
274
+ * clade does not resolve: a tip name the tree does not have or has twice, or a
275
+ * leaf count `tips` disagrees with. `index` and `rowNamesSet` are the memoized
276
+ * passes over the tree.
277
+ */
278
+ function cladeRows(clade, root, index, rowNamesSet) {
279
+ if (clade.range) {
280
+ const [a, b] = clade.range.map(name => index.get(name) ? rowNamesSet.get(name) : undefined);
281
+ if (a === undefined || b === undefined) {
282
+ return undefined;
283
+ }
284
+ const rows = a <= b ? [a, b] : [b, a];
285
+ return rows[1] - rows[0] + 1 === clade.tips ? { rows } : undefined;
286
+ }
287
+ const node = clade.mrca ? mrca(root, clade.mrca, index) : undefined;
288
+ if (!node) {
289
+ return undefined;
290
+ }
291
+ const tips = leaves(node);
292
+ if (tips.length !== clade.tips) {
293
+ return undefined;
294
+ }
295
+ let first = Infinity;
296
+ let last = -Infinity;
297
+ for (const tip of tips) {
298
+ const row = rowNamesSet.get(tip.data.name);
299
+ if (row !== undefined) {
300
+ first = Math.min(first, row);
301
+ last = Math.max(last, row);
302
+ }
303
+ }
304
+ return first <= last
305
+ ? { rows: [first, last], nodeId: node.data.id }
306
+ : undefined;
307
+ }
308
+ /**
309
+ * Every clade that resolves, with its fill color settled, against the leaf
310
+ * order `rowNamesSet` gives. The tree panel resolves against the displayed
311
+ * rows and the overview against its own, so both take the tree they draw. A
312
+ * `range` record names no node, so `collapse` and `focus`, which need one,
313
+ * drop it.
314
+ */
315
+ function resolveClades(clades, root, rowNamesSet) {
316
+ const index = leafIndex(root);
317
+ return clades.flatMap(clade => {
318
+ const resolved = cladeRows(clade, root, index, rowNamesSet);
319
+ const seeding = clade.mark === 'collapse' || clade.mark === 'focus';
320
+ return resolved && !(seeding && resolved.nodeId === undefined)
321
+ ? [
322
+ {
323
+ ...resolved,
324
+ mark: clade.mark,
325
+ color: withAlpha(clade.color ?? cladeHighlightColor, cladeHighlightAlpha),
326
+ markColor: clade.color,
327
+ label: clade.label,
328
+ },
329
+ ]
330
+ : [];
331
+ });
332
+ }
333
+ /**
334
+ * The tree with the display transforms applied: leaf counts summed, children
335
+ * sorted by branch length, `showOnly` taken as the new root, and each
336
+ * `collapsed` clade folded. The overview builds the same tree without the
337
+ * focus, so it shows the focused subtree inside the whole.
338
+ */
339
+ function buildTreeRoot(tree, collapsed, showOnly) {
340
+ let hier = hierarchy(tree, d => d.children);
341
+ hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1));
342
+ sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1));
343
+ if (showOnly) {
344
+ const res = find(hier, n => n.data.id === showOnly);
345
+ if (res) {
346
+ hier = res;
347
+ }
348
+ }
349
+ for (const collapsedId of collapsed) {
350
+ const node = find(hier, n => n.data.id === collapsedId);
351
+ if (node) {
352
+ if (node.children) {
353
+ collapse(node);
354
+ }
355
+ else if (node.parent?.children) {
356
+ node.parent.children = node.parent.children.filter(c => c.data.id !== collapsedId);
357
+ }
358
+ }
359
+ }
360
+ return hier;
361
+ }
362
+ // the inclusive tip indices a node covers, from the row-space extent
363
+ // clusterLayout writes to xMin/xMax as tip centers
364
+ function tipRange(node) {
365
+ return [Math.round(node.xMin - 0.5), Math.round(node.xMax - 0.5)];
366
+ }
367
+ // the channels reading the feature table; every other channel reads rowData
368
+ const featureChannels = new Set([
369
+ 'featureFill',
370
+ 'featureLabel',
371
+ ]);
372
+ // seqPos -> column indexes per row, keyed on the parse so they are garbage
373
+ // collected with it. A computed would rebuild every row's index when read
374
+ // outside a reactive context.
114
375
  const seqPosIndexCache = new WeakMap();
115
- // A segment asserts a 1:1 run, so its two sides have to be the same length.
116
- // One that is not is malformed data, and the arithmetic below would answer
117
- // anyway -- with a residue that is off by however much the sides disagree. Skip
118
- // it, the same refusal an uncovered position gets.
376
+ // A segment is a 1:1 run. A segment whose sides differ in length is malformed,
377
+ // and the lookups treat it like an uncovered position.
119
378
  function sameLength(segment) {
120
379
  return (segment.rowEnd - segment.rowStart ===
121
380
  segment.structEnd - segment.structStart);
122
381
  }
123
- // Does the content need a scrollbar? fit() divides the viewport by the row or
124
- // column count and multiplies it back, so an exact fit lands a fraction of a
125
- // pixel over -- enough for a `>` to answer yes and hand the reader a minimap or
126
- // a scrollbar for half a pixel of nothing, which then shrinks the viewport and
127
- // leaves a gap.
382
+ // fit() divides the viewport by the row or column count and multiplies back, so
383
+ // an exact fit can land a fraction of a pixel over. A plain `>` would then show
384
+ // a scrollbar that shrinks the viewport and leaves a gap.
128
385
  function overflows(content, viewport) {
129
386
  return content - viewport > 0.5;
130
387
  }
131
388
  function inRanges(ranges, position) {
132
389
  return !!ranges?.some(([start, end]) => position >= start && position <= end);
133
390
  }
391
+ // the value every one of a node's children reports, or undefined where one of
392
+ // them has no value or they disagree
393
+ function sharedValue(values) {
394
+ const [first] = values;
395
+ return first !== undefined && values.every(v => v === first)
396
+ ? first
397
+ : undefined;
398
+ }
399
+ // preorder, so reversing the list puts every node after its descendants
400
+ function preorder(tree) {
401
+ const order = [];
402
+ const stack = [tree];
403
+ while (stack.length > 0) {
404
+ const node = stack.pop();
405
+ order.push(node);
406
+ for (const child of node.children) {
407
+ stack.push(child);
408
+ }
409
+ }
410
+ return order;
411
+ }
134
412
  /**
135
413
  * #stateModel MsaView
136
414
  *
@@ -166,10 +444,8 @@ function stateModelFactory() {
166
444
  /**
167
445
  * #property
168
446
  * whether the domain legend is expanded. The legend floats over the
169
- * top-right of the alignment, so on a tall panel it covers real
170
- * residues -- persisting the state is what lets a reader collapse it
171
- * and keep it collapsed, and what lets a session or a figure open with
172
- * it already out of the way.
447
+ * top-right of the alignment and covers residues, so a session or
448
+ * figure can open with it collapsed.
173
449
  */
174
450
  showDomainLegend: stripDefault(types.boolean, defaultShowDomainLegend),
175
451
  /**
@@ -198,6 +474,11 @@ function stateModelFactory() {
198
474
  * zoom in/out on plain mouse-wheel without holding ctrl
199
475
  */
200
476
  scrollZoom: stripDefault(types.boolean, defaultScrollZoom),
477
+ /**
478
+ * #property
479
+ * which cell dimensions a wheel zoom scales, while `scrollZoom` is on
480
+ */
481
+ scrollZoomAxis: stripDefault(types.enumeration('ScrollZoomAxis', [...scrollZoomAxes]), defaultScrollZoomAxis),
201
482
  /**
202
483
  * #property
203
484
  * height of the div containing the view, px
@@ -269,18 +550,25 @@ function stateModelFactory() {
269
550
  turnedOffTracks: stripDefault(types.map(types.boolean), {}),
270
551
  /**
271
552
  * #property
272
- * tracks supplied as data rather than computed from the alignment:
273
- * per-column values drawn as bars, or a per-column string drawn as a
274
- * text track. See docs/layers.md
553
+ * the height of every track one divider resizes, keyed by `heightKey`:
554
+ * the `kind` for the computed tracks, `own:<id>` for a data track. A
555
+ * key is absent until the user drags that divider, and
556
+ * `defaultTrackHeights` answers for it until then, so an untouched
557
+ * viewer adds nothing to the shared URL.
558
+ */
559
+ trackHeights: stripDefault(types.map(types.number), {}),
560
+ /**
561
+ * #property
562
+ * tracks supplied as data: per-column values drawn as bars, or a
563
+ * per-column string drawn as a text track. See docs/layers.md
275
564
  */
276
565
  columnTracks: stripDefault(types.array(types.frozen()), []),
277
566
  /**
278
567
  * #property
279
- * which residue of which structure each row's residues are, as data.
280
- * The viewer cannot infer this -- matching a row to a structure by
281
- * sequence equality fails for a tagged construct, a truncation or a
282
- * subsequence row, and fails in the direction that looks like it
283
- * worked -- so it arrives computed. See docs/layers.md
568
+ * row-to-structure residue correspondence, computed outside the viewer
569
+ * (e.g. from SIFTS). Matching by sequence equality places a tagged
570
+ * construct, a truncation or a subsequence row on the wrong residue.
571
+ * See docs/layers.md
284
572
  */
285
573
  residueMappings: stripDefault(types.array(types.frozen()), []),
286
574
  /**
@@ -288,18 +576,13 @@ function stateModelFactory() {
288
576
  * data from the loaded tree/msa/treeMetadata, generally loaded by
289
577
  * autorun
290
578
  */
291
- data: types.optional(DataModelF(), {
292
- tree: '',
293
- msa: '',
294
- treeMetadata: '',
295
- }),
579
+ data: types.optional(DataModelF(), {}),
296
580
  /**
297
581
  * #property
298
582
  * the user's explicit hide choices per annotation accession, keyed by
299
- * accession with the value meaning "off", the same shape as
300
- * `turnedOffTracks`. An accession the user has never touched is absent
301
- * and drawn, so a file of two hundred domain types adds nothing to the
302
- * shared URL until someone filters one out
583
+ * accession with the value meaning "off", like `turnedOffTracks`. An
584
+ * untouched accession is absent and drawn, so the shared URL grows
585
+ * only with the user's filters
303
586
  */
304
587
  turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
305
588
  /**
@@ -320,24 +603,53 @@ function stateModelFactory() {
320
603
  * labeled highlights in 1-based inclusive coordinates: a column span
321
604
  * `{start, end}`, a residue span `{row, start, end}` of a named row,
322
605
  * or a row set `{rows}`, each with an optional `label` and `color`.
323
- * Persists in the snapshot, so a computed answer travels in the URL.
606
+ * Persists in the snapshot and the URL.
324
607
  */
325
608
  highlights: stripDefault(types.array(types.frozen()), []),
609
+ /**
610
+ * #property
611
+ * clades of the tree with a mark drawn over them. `mrca` names tips
612
+ * whose common ancestor is the clade, or `range` its first and last
613
+ * tip in display order, and `tips` is the leaf count the producer
614
+ * measured. `mark` is `highlight`, `bracket`, `collapse` or `focus`.
615
+ * See docs/layers.md
616
+ */
617
+ clades: stripDefault(types.array(types.frozen()), []),
618
+ /**
619
+ * #property
620
+ * what the viewer's own marks read from `rowData`:
621
+ * `{channel, field, scale?}` per channel, where `channel` is
622
+ * `tipLabel` or `rowTint`. See docs/layers.md
623
+ */
624
+ encodings: stripDefault(types.array(types.frozen()), []),
625
+ /**
626
+ * #property
627
+ * panels drawn between the tree and the alignment:
628
+ * `{kind: "strip", field, scale?, width?, header?}` colors one cell
629
+ * per row from a `rowData` field, and
630
+ * `{kind: "features", x, encoding?, transform?, width?, header?}`
631
+ * draws the spans the GFF carries. See docs/layers.md
632
+ */
633
+ rowPanels: stripDefault(types.array(types.frozen()), []),
326
634
  }))
327
635
  .volatile(() => ({
328
636
  /**
329
637
  * #volatile
330
638
  */
331
639
  headerHeight: 0,
640
+ /**
641
+ * #volatile
642
+ * leaves the toolbar out, for a host drawing its own controls. Kept out
643
+ * of the snapshot so a link opened in the full app shows the toolbar.
644
+ */
645
+ hideHeader: false,
332
646
  /**
333
647
  * #volatile
334
648
  */
335
649
  status: undefined,
336
650
  /**
337
651
  * #volatile
338
- * high resolution scale factor, helps make canvas look better on hi-dpi
339
- * screens. derived from the device pixel ratio so canvases are crisp on
340
- * retina/4k displays and not needlessly oversized on standard ones
652
+ * canvas scale factor, from the device pixel ratio
341
653
  */
342
654
  highResScaleFactor: typeof window === 'undefined' ? 1 : window.devicePixelRatio,
343
655
  /**
@@ -394,40 +706,14 @@ function stateModelFactory() {
394
706
  highlightedColumns: undefined,
395
707
  /**
396
708
  * #volatile
397
- * transient highlights keyed by who asked for them. One slot cannot hold
398
- * two sources -- a structure viewer's hover and a genome view's hover
399
- * both want to point at a column, and with one slot whoever clears last
400
- * erases the other's. Keyed by owner, each source adds and removes only
401
- * its own. Not persisted: a hover is not part of the document.
709
+ * transient highlights keyed by owner, so a structure viewer's hover and
710
+ * a genome view's hover each clear only their own. Not persisted.
402
711
  */
403
712
  transientHighlights: {},
404
713
  /**
405
714
  * #volatile
406
715
  */
407
716
  minimapHeight: 56,
408
- /**
409
- * #volatile
410
- */
411
- conservationTrackHeight: 40,
412
- /**
413
- * #volatile
414
- * heights of individual `columnTracks`, by track id. A data track is
415
- * resized on its own: the shared per-kind heights below belong to the
416
- * tracks the viewer computes, and dragging a data track's handle used to
417
- * resize those instead.
418
- */
419
- columnTrackHeights: {},
420
- /**
421
- * #volatile
422
- * taller than the conservation track by default: the logo spends its
423
- * height on stacked glyphs, and a 40px stack of four residues leaves each
424
- * one too short to identify
425
- */
426
- sequenceLogoTrackHeight: 80,
427
- /**
428
- * #volatile
429
- */
430
- arcTrackHeight: 50,
431
717
  /**
432
718
  * #volatile
433
719
  */
@@ -439,33 +725,28 @@ function stateModelFactory() {
439
725
  error: undefined,
440
726
  /**
441
727
  * #volatile
442
- * load problems the view carried on through: an optional layer that did
443
- * not arrive, an overlay that did not parse. `error` is the other kind --
444
- * it replaces the view, which is right for the alignment and wrong for a
445
- * decorative file
728
+ * non-fatal load problems: an optional layer that failed to load, an
729
+ * overlay that failed to parse. `error` replaces the view and is for the
730
+ * alignment itself
446
731
  */
447
732
  warnings: [],
448
733
  /**
449
734
  * #volatile
450
- * bumped by reset(). The React error boundary above the view keeps its
451
- * caught error until it is remounted, so "Return to import form" did
452
- * nothing after a render error until this became its key
735
+ * bumped by reset(). The error boundary above the view uses it as its key,
736
+ * since the boundary keeps its caught error until remounted
453
737
  */
454
738
  resetCount: 0,
455
739
  /**
456
740
  * #volatile
457
- * set by a host that restores the loaded documents by its own means --
458
- * a jbrowse session that holds them, a page that refetches them on load.
459
- * `unshareableData` then reports nothing, since what it warns about is a
460
- * link that opens empty, and under such a host the link does not
741
+ * set by a host that restores the loaded documents itself, such as a
742
+ * jbrowse session or a page that refetches them. `unshareableData` then
743
+ * reports nothing
461
744
  */
462
745
  hostCarriesData: false,
463
746
  /**
464
747
  * #volatile
465
- * overlay annotations drawn on the alignment, whatever their source.
466
- * Every source -- InterProScan, GFF, a user upload -- converts to this
467
- * flat list before it reaches the model, so nothing downstream of here
468
- * knows which one it came from
748
+ * overlay annotations drawn on the alignment. InterProScan JSON, GFF and
749
+ * user uploads all convert to this flat list
469
750
  */
470
751
  annotations: [],
471
752
  }))
@@ -511,8 +792,8 @@ function stateModelFactory() {
511
792
  },
512
793
  /**
513
794
  * #action
514
- * high-res scale factor, tracks the device pixel ratio so canvases stay
515
- * crisp when the window moves between monitors or the browser zooms
795
+ * update the canvas scale factor when the device pixel ratio changes
796
+ * (moving between monitors, browser zoom)
516
797
  */
517
798
  setHighResScaleFactor(arg) {
518
799
  self.highResScaleFactor = arg;
@@ -533,11 +814,13 @@ function stateModelFactory() {
533
814
  },
534
815
  /**
535
816
  * #action
536
- * report something the view survived: a layer that failed to load, a
537
- * file that failed to parse
817
+ * record a non-fatal load problem: a layer that failed to load, a file
818
+ * that failed to parse. A message already on the list is not added again
538
819
  */
539
820
  addWarning(warning) {
540
- self.warnings = [...self.warnings, warning];
821
+ if (!self.warnings.includes(warning)) {
822
+ self.warnings = [...self.warnings, warning];
823
+ }
541
824
  },
542
825
  /**
543
826
  * #action
@@ -548,7 +831,7 @@ function stateModelFactory() {
548
831
  /**
549
832
  * #action
550
833
  * declare that this host restores the loaded documents itself, which
551
- * takes down the "Not in the link" warning. See `hostCarriesData`
834
+ * hides the "Not in the link" warning. See `hostCarriesData`
552
835
  */
553
836
  setHostCarriesData(arg) {
554
837
  self.hostCarriesData = arg;
@@ -557,9 +840,9 @@ function stateModelFactory() {
557
840
  * #action
558
841
  * set mouse position (row, column) in the MSA
559
842
  *
560
- * PUBLIC API: a host drives this (and reads the `mouseCol` volatile) to
561
- * sync the alignment's hover with a view of its own -- a genome view, a
562
- * 3D structure. Keep the name and signature stable.
843
+ * Public API: a host calls this (and reads `mouseCol`) to sync hover with
844
+ * its own view, such as a genome view or 3D structure. Keep the name and
845
+ * signature stable.
563
846
  */
564
847
  setMousePos(col, row) {
565
848
  self.mouseCol = col;
@@ -569,9 +852,9 @@ function stateModelFactory() {
569
852
  * #action
570
853
  * set highlighted columns
571
854
  *
572
- * PUBLIC API: jbrowse-plugin-msaview calls this from its
573
- * afterCreateAutoruns to highlight alignment columns, and MSAViewer
574
- * passes its `highlightColumns` prop through it. Not dead code.
855
+ * Public API: jbrowse-plugin-msaview calls this from its
856
+ * afterCreateAutoruns, and MSAViewer passes its `highlightColumns` prop
857
+ * through it.
575
858
  */
576
859
  setHighlightedColumns(columns) {
577
860
  self.highlightedColumns = columns;
@@ -584,10 +867,15 @@ function stateModelFactory() {
584
867
  },
585
868
  /**
586
869
  * #action
587
- * show `highlights` on behalf of `owner`, replacing whatever that owner
588
- * showed before and leaving every other owner's alone. The object is
589
- * replaced rather than mutated so one assignment is the observable
590
- * change.
870
+ * replace the clades the viewer marks (see docs/layers.md)
871
+ */
872
+ setClades(clades) {
873
+ self.clades.replace(clades);
874
+ },
875
+ /**
876
+ * #action
877
+ * show `highlights` for `owner`, replacing that owner's previous ones and
878
+ * leaving other owners' in place
591
879
  */
592
880
  applyHighlight(owner, highlights) {
593
881
  self.transientHighlights = {
@@ -597,7 +885,7 @@ function stateModelFactory() {
597
885
  },
598
886
  /**
599
887
  * #action
600
- * drop what `owner` was showing, leaving every other owner's in place
888
+ * remove `owner`'s highlights, leaving other owners' in place
601
889
  */
602
890
  clearHighlight(owner) {
603
891
  if (owner in self.transientHighlights) {
@@ -649,10 +937,9 @@ function stateModelFactory() {
649
937
  },
650
938
  /**
651
939
  * #action
652
- * switch to another alignment of a multi-alignment file (Stockholm). The
653
- * new alignment has its own rows and its own tree, so everything naming
654
- * the old one's -- the collapsed node ids, the subtree in focus, the
655
- * reference row, the scroll position -- goes with it
940
+ * switch to another alignment of a multi-alignment file (Stockholm).
941
+ * Clears the collapsed node ids, the subtree in focus, the reference row
942
+ * and the scroll position, which all refer to the previous alignment
656
943
  */
657
944
  setCurrentAlignment(n) {
658
945
  if (n === self.currentAlignment) {
@@ -739,11 +1026,10 @@ function stateModelFactory() {
739
1026
  },
740
1027
  /**
741
1028
  * #action
742
- * keep the GFF text the way the alignment and the tree are kept, rather
743
- * than only its parsed annotations. The annotations are volatile, so a
744
- * file opened from disk used to leave no trace in the snapshot at all --
745
- * not the text, and not the filehandle, which is cleared once a blob is
746
- * read. An autorun parses this back into annotations.
1029
+ * store the GFF text in the snapshot like the alignment and tree. The
1030
+ * parsed annotations are volatile and a blob filehandle is cleared once
1031
+ * read, so the text is the only persisted copy. An autorun parses it
1032
+ * into annotations.
747
1033
  */
748
1034
  setGFF(result) {
749
1035
  self.data.setGFF(result);
@@ -772,16 +1058,14 @@ function stateModelFactory() {
772
1058
  },
773
1059
  /**
774
1060
  * #getter
775
- * whether this host brings the loaded documents back by means the
776
- * snapshot cannot see, which is what decides whether `unshareableData`
777
- * has anything to warn about.
1061
+ * whether the host restores the loaded documents outside the snapshot.
1062
+ * When true, `unshareableData` is empty.
778
1063
  *
779
- * A simple host flips the `hostCarriesData` volatile. A host whose
780
- * answer depends on how the view was opened overrides this getter in a
781
- * `.views` block of its own composed model -- jbrowse-plugin-msaview's
782
- * indexed-location views refetch from a URL the session holds, while its
783
- * data-store views really are absent from a link someone pastes
784
- * elsewhere. `unshareableData` reads it off `self`, so an override wins.
1064
+ * A simple host sets `hostCarriesData`. A host where this depends on how
1065
+ * the view was opened overrides the getter in its own composed model's
1066
+ * `.views` block; jbrowse-plugin-msaview's indexed-location views refetch
1067
+ * from a URL the session holds, while its data-store views do not.
1068
+ * `unshareableData` reads it off `self`, so an override takes effect.
785
1069
  */
786
1070
  get hostRestoresData() {
787
1071
  return self.hostCarriesData;
@@ -804,8 +1088,8 @@ function stateModelFactory() {
804
1088
  * #getter
805
1089
  */
806
1090
  get colorScheme() {
807
- // colorSchemeName is a free string (menus, snapshots, URL params); fall
808
- // back to the default rather than returning undefined on a stale name
1091
+ // colorSchemeName is a free string (menus, snapshots, URL params), so a
1092
+ // stale name falls back to the default
809
1093
  return (colorSchemes[self.colorSchemeName] ??
810
1094
  colorSchemes[defaultColorSchemeName]);
811
1095
  },
@@ -832,29 +1116,23 @@ function stateModelFactory() {
832
1116
  },
833
1117
  /**
834
1118
  * #getter
835
- * the loaded documents this view's own snapshot cannot carry, largest
836
- * first. A file opened from disk or pasted in becomes inline text, and
837
- * DataModel drops an inline document past `maxInlineSnapshotBytes`
838
- * rather than put megabytes of sequence into a session or a URL.
1119
+ * loaded documents left out of the snapshot, largest first. A file opened
1120
+ * from disk or pasted in becomes inline text, and DataModel drops an
1121
+ * inline document past `maxInlineSnapshotBytes`.
839
1122
  *
840
- * Dropping it is right. Dropping it silently is what makes a copied link
841
- * open an empty viewer, so the header says so and the standalone app
842
- * stops rewriting the address bar while this is non-empty. A document
843
- * fetched from a URL never appears here whatever its size: the snapshot
844
- * keeps the filehandle and refetches through it.
1123
+ * The header lists these, and the standalone app stops rewriting the
1124
+ * address bar while the list is non-empty, so a copied link does not
1125
+ * open an empty viewer unannounced. A document fetched from a URL never
1126
+ * appears here, since the snapshot keeps its filehandle.
845
1127
  *
846
- * Nothing is unshareable when the host restores the data by its own
847
- * means (see `hostRestoresData`) -- inside a session that reloads these
848
- * documents from somewhere the snapshot does not show, the warning is
849
- * simply wrong.
1128
+ * Empty when `hostRestoresData` is true.
850
1129
  */
851
1130
  get unshareableData() {
852
1131
  if (self.hostRestoresData) {
853
1132
  return [];
854
1133
  }
855
1134
  const { data } = self;
856
- // a data track past the limit leaves the snapshot the same way an
857
- // inline document does, and left unreported the same way too
1135
+ // data tracks past the limit also leave the snapshot
858
1136
  const trackBytes = columnTrackSizes(self.columnTracks)
859
1137
  .filter(bytes => bytes > maxInlineSnapshotBytes)
860
1138
  .reduce((a, b) => a + b, 0);
@@ -875,13 +1153,14 @@ function stateModelFactory() {
875
1153
  },
876
1154
  /**
877
1155
  * #getter
878
- * extra per-row attributes, keyed by row name. Parsed defensively: the
879
- * source is a user-supplied document (treeMetadataFilehandle, or a
880
- * session snapshot), and this computed is read by labelWidthMap on every
881
- * layout, so a malformed file would otherwise throw out of rendering and
882
- * take the whole view down over a decorative field.
1156
+ * the row table: extra fields per row, keyed by row name, which the
1157
+ * `encodings` channels read. It is stored as the JSON string
1158
+ * `data.treeMetadata`, the name that travels in existing links, so the
1159
+ * inline size limit and `treeMetadataFilehandle` cover it. labelWidthMap
1160
+ * reads it on every layout, so a malformed user-supplied file returns {}
1161
+ * instead of throwing out of rendering.
883
1162
  */
884
- get treeMetadata() {
1163
+ get rowData() {
885
1164
  const text = self.data.treeMetadata;
886
1165
  if (!text) {
887
1166
  return {};
@@ -893,10 +1172,31 @@ function stateModelFactory() {
893
1172
  : {};
894
1173
  }
895
1174
  catch (e) {
896
- console.error('failed to parse treeMetadata', e);
1175
+ console.error('failed to parse rowData', e);
897
1176
  return {};
898
1177
  }
899
1178
  },
1179
+ /**
1180
+ * #method
1181
+ * one row's fields, the single reader of the row table
1182
+ */
1183
+ rowDataOf(name) {
1184
+ return this.rowData[name];
1185
+ },
1186
+ /**
1187
+ * #getter
1188
+ * the field names the row table carries, sorted, for a producer or a UI
1189
+ * choosing one to encode
1190
+ */
1191
+ get rowFields() {
1192
+ const fields = new Set();
1193
+ for (const row of Object.values(this.rowData)) {
1194
+ for (const field of Object.keys(row ?? {})) {
1195
+ fields.add(field);
1196
+ }
1197
+ }
1198
+ return [...fields].sort((a, b) => a.localeCompare(b));
1199
+ },
900
1200
  /**
901
1201
  * #getter
902
1202
  */
@@ -942,6 +1242,33 @@ function stateModelFactory() {
942
1242
  get rowNamesSet() {
943
1243
  return new Map(this.leaves.map((leaf, index) => [leaf.data.name, index]));
944
1244
  },
1245
+ /**
1246
+ * #getter
1247
+ * `clades` resolved to the rows each one covers. The tip names resolve
1248
+ * against `tree` rather than `root`, so a clade whose ancestor the user
1249
+ * collapsed keeps its rows. One leaf pass over the tree serves every
1250
+ * clade. A `range` record names no node, so `collapse` and `focus`, which
1251
+ * need one, drop it.
1252
+ */
1253
+ get resolvedClades() {
1254
+ if (self.clades.length === 0) {
1255
+ return noClades;
1256
+ }
1257
+ return resolveClades(self.clades, hierarchy(this.tree, d => d.children), this.rowNamesSet);
1258
+ },
1259
+ /**
1260
+ * #getter
1261
+ * the pixel column reserved at the right of the tree area for the bracket
1262
+ * mark, which the tip labels and the tree itself stay clear of. Zero
1263
+ * where no clade draws a bar or a label.
1264
+ */
1265
+ get cladeGutterWidth() {
1266
+ return cladeGutterWidth({
1267
+ clades: this.resolvedClades,
1268
+ rowHeight: self.rowHeight,
1269
+ fontSize: this.fontSize,
1270
+ });
1271
+ },
945
1272
  /**
946
1273
  * #getter
947
1274
  */
@@ -974,40 +1301,128 @@ function stateModelFactory() {
974
1301
  * #getter
975
1302
  */
976
1303
  get root() {
977
- let hier = hierarchy(this.tree, d => d.children);
978
- hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1));
979
- sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1));
980
- if (self.showOnly) {
981
- const res = find(hier, n => n.data.id === self.showOnly);
982
- if (res) {
983
- hier = res;
984
- }
1304
+ return buildTreeRoot(this.tree, self.collapsed, self.showOnly);
1305
+ },
1306
+ /**
1307
+ * #getter
1308
+ * height of the band the tree overview draws in, zero when it is off
1309
+ */
1310
+ get treeOverviewHeight() {
1311
+ return self.showTreeOverview ? self.overviewHeight : 0;
1312
+ },
1313
+ /**
1314
+ * #getter
1315
+ * the whole tree laid out for the overview, or undefined when the
1316
+ * overview is off. The focus is left out, so the focused subtree draws
1317
+ * inside the whole tree, and the collapsed clades are folded, since
1318
+ * those are rows the view no longer has. `x` is in tip-index space and
1319
+ * `len` is a fraction of the root-to-tip length, so one layout serves
1320
+ * any band size.
1321
+ */
1322
+ get treeOverviewLayout() {
1323
+ if (!self.showTreeOverview) {
1324
+ return undefined;
985
1325
  }
986
- for (const collapsedId of self.collapsed) {
987
- const node = find(hier, n => n.data.id === collapsedId);
988
- if (node) {
989
- if (node.children) {
990
- collapse(node);
991
- }
992
- else if (node.parent?.children) {
993
- node.parent.children = node.parent.children.filter(c => c.data.id !== collapsedId);
994
- }
995
- }
1326
+ const root = buildTreeRoot(this.tree, self.collapsed);
1327
+ const numTips = leaves(root).length;
1328
+ clusterLayout(root, numTips, 1);
1329
+ const rootLen = Math.max(root.data.length || 0, 0);
1330
+ const extent = maxLength(root) - rootLen;
1331
+ setBrLength(root, -rootLen, extent ? 1 / extent : 0);
1332
+ return {
1333
+ root,
1334
+ numTips,
1335
+ maxDepthToLeaf: calcDepthToLeaf(root),
1336
+ showBranchLen: self.showBranchLen && extent > 0,
1337
+ };
1338
+ },
1339
+ /**
1340
+ * #getter
1341
+ * the `clades` highlights in the overview's own row space, which the
1342
+ * focus does not narrow
1343
+ */
1344
+ get treeOverviewClades() {
1345
+ const layout = this.treeOverviewLayout;
1346
+ if (!layout || self.clades.length === 0) {
1347
+ return noClades;
1348
+ }
1349
+ const rowNames = new Map(leaves(layout.root).map((leaf, index) => [leaf.data.name, index]));
1350
+ return resolveClades(self.clades, layout.root, rowNames);
1351
+ },
1352
+ /**
1353
+ * #getter
1354
+ * the inclusive tip rows the focused subtree covers in the overview,
1355
+ * which is the box drawn on it. undefined with no focus
1356
+ */
1357
+ get treeOverviewFocusRows() {
1358
+ const layout = this.treeOverviewLayout;
1359
+ if (!layout || !self.showOnly) {
1360
+ return undefined;
1361
+ }
1362
+ const node = find(layout.root, n => n.data.id === self.showOnly);
1363
+ return node ? tipRange(node) : undefined;
1364
+ },
1365
+ /**
1366
+ * #method
1367
+ * the subtree a point `y` pixels down the tree overview picks: the
1368
+ * deepest one whose tip range covers every row under that pixel, with
1369
+ * the rows it covers. A pixel stands for several tips on a large tree,
1370
+ * which is what keeps the pick off the individual tips. undefined when
1371
+ * the overview is off or the point picks the whole tree.
1372
+ */
1373
+ treeOverviewHit(y) {
1374
+ const layout = this.treeOverviewLayout;
1375
+ if (!layout) {
1376
+ return undefined;
1377
+ }
1378
+ const { root, numTips } = layout;
1379
+ const perPixel = numTips / self.overviewHeight;
1380
+ const first = clamp(Math.floor(y * perPixel), 0, numTips - 1);
1381
+ const last = clamp(Math.ceil((y + 1) * perPixel) - 1, first, numTips - 1);
1382
+ let node = nodeCoveringRows(root, first + 0.5, last + 0.5);
1383
+ // focusing one tip leaves a single row on screen, so the pick lifts to
1384
+ // the subtree that tip sits in
1385
+ while (!node.children && node.parent) {
1386
+ node = node.parent;
996
1387
  }
997
- return hier;
1388
+ return node === root
1389
+ ? undefined
1390
+ : { id: node.data.id, rows: tipRange(node) };
1391
+ },
1392
+ /**
1393
+ * #getter
1394
+ * the pixel column the row panels occupy between the tree and the
1395
+ * alignment, the sum of each record's width
1396
+ */
1397
+ get rowPanelsWidth() {
1398
+ return sum(self.rowPanels.map(p => rowPanelWidth(p, self.rowHeight)));
1399
+ },
1400
+ /**
1401
+ * #getter
1402
+ * height of the band the row panel headers draw in, which is zero with
1403
+ * no row panels and leaves the top area as it was
1404
+ */
1405
+ get rowPanelsHeaderHeight() {
1406
+ return self.rowPanels.length > 0 ? rowPanelHeaderHeight : 0;
998
1407
  },
999
1408
  /**
1000
1409
  * #getter
1001
- * widget width minus the tree area gives the space for the MSA
1410
+ * widget width minus the tree area and the row panels gives the space
1411
+ * for the MSA
1002
1412
  */
1003
1413
  get msaAreaWidth() {
1004
- return self.width - self.treeAreaWidth - self.resizeHandleWidth;
1414
+ return (self.width -
1415
+ self.treeAreaWidth -
1416
+ this.rowPanelsWidth -
1417
+ self.resizeHandleWidth);
1005
1418
  },
1006
1419
  /**
1007
1420
  * #getter
1421
+ * the right edge the tip labels end at, which is the tree area less the
1422
+ * margin and the bracket gutter
1008
1423
  */
1009
1424
  get treeAreaWidthMinusMargin() {
1010
- return self.treeAreaWidth - self.marginLeft;
1425
+ return self.treeAreaWidth - self.marginLeft - this.cladeGutterWidth;
1011
1426
  },
1012
1427
  /**
1013
1428
  * #getter
@@ -1054,7 +1469,7 @@ function stateModelFactory() {
1054
1469
  */
1055
1470
  get insertionPositions() {
1056
1471
  const { blanks, rows } = this;
1057
- if (blanks.length === 0 || !self.hideGapsEffective) {
1472
+ if (blanks.length === 0) {
1058
1473
  return new Map();
1059
1474
  }
1060
1475
  const result = new Map();
@@ -1077,10 +1492,8 @@ function stateModelFactory() {
1077
1492
  },
1078
1493
  /**
1079
1494
  * #getter
1080
- * number of rows the alignment occupies on screen. This is the leaf count,
1081
- * not `rows.length`: a tree leaf with no matching MSA row still takes up a
1082
- * row of vertical space (drawn blank), so row hit-testing and fit-to-height
1083
- * must count it.
1495
+ * number of rows on screen: the leaf count, which includes tree leaves
1496
+ * with no matching MSA row (drawn blank), unlike `rows.length`.
1084
1497
  */
1085
1498
  get numRows() {
1086
1499
  return this.leaves.length;
@@ -1088,12 +1501,8 @@ function stateModelFactory() {
1088
1501
  /**
1089
1502
  * #method
1090
1503
  * index of the global column holding each ungapped sequence position of a
1091
- * row, so seqPos -> column is a lookup rather than a scan. The domain
1092
- * overlay resolves thousands of these per redraw.
1093
- *
1094
- * Built per row, on the row asked for: the first lookup used to index
1095
- * every row in the alignment. The cache is keyed on the parse the rows
1096
- * came from, so a new alignment brings a new one.
1504
+ * row. The domain overlay resolves thousands of these per redraw. Built
1505
+ * lazily per row and cached on the parse.
1097
1506
  */
1098
1507
  seqPosIndex(rowName) {
1099
1508
  const MSA = this.MSA;
@@ -1118,10 +1527,9 @@ function stateModelFactory() {
1118
1527
  },
1119
1528
  /**
1120
1529
  * #getter
1121
- * every sequence the alignment holds, keyed by row name, whatever the
1122
- * tree currently shows. `rows` is the rows on screen; this is the rows
1123
- * that exist, and every lookup about a named row goes through it --
1124
- * collapsing a clade hides rows, it does not delete their sequence
1530
+ * every sequence in the alignment, keyed by row name, including rows a
1531
+ * collapsed clade hides. `rows` holds only the rows on screen; lookups
1532
+ * by row name use this
1125
1533
  */
1126
1534
  get rowMap() {
1127
1535
  const MSA = this.MSA;
@@ -1254,10 +1662,8 @@ function stateModelFactory() {
1254
1662
  clusterLayout(r, this.totalHeight, self.treeWidth);
1255
1663
  const max = this.rootToTipLength;
1256
1664
  const k = max ? self.treeWidth / max : 0;
1257
- // the displayed root starts at x=0 whatever branch length it carries,
1258
- // so its own length is subtracted here rather than zeroed on the parsed
1259
- // node -- `root` hands out the cached parse, and writing to it made
1260
- // showOnly shorten that branch for good
1665
+ // the displayed root starts at x=0, so subtract its length here; `root`
1666
+ // returns the cached parse, which must not be mutated
1261
1667
  setBrLength(r, -Math.max(r.data.length || 0, 0), k);
1262
1668
  // for each collapsed clade, record the pixel x-position of its farthest
1263
1669
  // tip so the renderer can draw a triangle spanning the branch-length
@@ -1293,22 +1699,19 @@ function stateModelFactory() {
1293
1699
  },
1294
1700
  /**
1295
1701
  * #getter
1296
- * x-position of the farthest tip in a phylogram, px. The layout scales
1297
- * the longest root-to-tip path onto treeWidth, so that is where it lands
1298
- * -- and 0 for a tree carrying no lengths at all, which draws as a
1299
- * cladogram instead
1702
+ * x-position of the farthest tip in a phylogram, px: treeWidth, or 0 for
1703
+ * a tree with no branch lengths (drawn as a cladogram)
1300
1704
  */
1301
1705
  get maxBranchLength() {
1302
1706
  return this.rootToTipLength ? self.treeWidth : 0;
1303
1707
  },
1304
1708
  /**
1305
1709
  * #getter
1306
- * pixels per unit of branch length in the current phylogram layout, and 0
1307
- * in cladogram mode, where the x-positions carry no length at all. The
1308
- * scale bar over the tree is drawn from it.
1710
+ * pixels per unit of branch length in the phylogram layout, 0 in
1711
+ * cladogram mode. The tree's scale bar uses it.
1309
1712
  */
1310
1713
  get pxPerBranchLength() {
1311
- const max = maxLength(this.root);
1714
+ const max = this.rootToTipLength;
1312
1715
  return this.showBranchLenEffective && max ? self.treeWidth / max : 0;
1313
1716
  },
1314
1717
  /**
@@ -1357,25 +1760,141 @@ function stateModelFactory() {
1357
1760
  return self.drawLabels && self.rowHeight >= minLetterRowHeight;
1358
1761
  },
1359
1762
  }))
1763
+ .views(self => {
1764
+ // a spec is a frozen value, so it keys its computed until setColumnTracks
1765
+ // replaces it
1766
+ const columnTrackModels = new WeakMap();
1767
+ return {
1768
+ /**
1769
+ * #getter
1770
+ * a data track's values or string, projected from its row's residues
1771
+ * onto alignment columns when it names a row
1772
+ */
1773
+ get columnTrackContent() {
1774
+ const { MSA, blanks, hideGapsEffective } = self;
1775
+ const width = MSA?.getWidth() ?? 0;
1776
+ const project = (track, items, fill) => {
1777
+ if (!track.row) {
1778
+ return items;
1779
+ }
1780
+ const out = Array.from({ length: width }, () => fill);
1781
+ const index = self.seqPosIndex(track.row);
1782
+ items.forEach((item, seqPos) => {
1783
+ const col = index?.[seqPos];
1784
+ if (col !== undefined) {
1785
+ out[col] = item;
1786
+ }
1787
+ });
1788
+ return out;
1789
+ };
1790
+ const skip = (items) => hideGapsEffective ? dropBlanks(blanks, items) : items;
1791
+ // an arc endpoint maps row residue -> column -> visible column.
1792
+ // visibleColsBefore, not globalColToVisibleCol, so an endpoint in a
1793
+ // hidden column moves to the neighboring visible one and the arc stays
1794
+ const resolve = (track, pos) => {
1795
+ const col = track.row
1796
+ ? self.seqPosIndex(track.row)?.[pos - 1]
1797
+ : pos - 1;
1798
+ if (col === undefined || col < 0 || col >= width) {
1799
+ return undefined;
1800
+ }
1801
+ return hideGapsEffective ? visibleColsBefore(blanks, col) : col;
1802
+ };
1803
+ return new Map(self.columnTracks.map(track => {
1804
+ if (track.kind === 'arc') {
1805
+ const arcs = (track.arcs ?? [])
1806
+ .map(arc => {
1807
+ const start = resolve(track, Math.min(arc.start, arc.end));
1808
+ const end = resolve(track, Math.max(arc.start, arc.end));
1809
+ return start !== undefined &&
1810
+ end !== undefined &&
1811
+ start < end
1812
+ ? { start, end, color: arc.color }
1813
+ : undefined;
1814
+ })
1815
+ .filter(notEmpty);
1816
+ return [track.id, { arcs }];
1817
+ }
1818
+ if (track.kind === 'bar') {
1819
+ const max = track.max ?? 1;
1820
+ const values = skip(project(track, track.values ?? [], 0)).map(v => Math.min(1, Math.max(0, v / max)));
1821
+ return [track.id, { values }];
1822
+ }
1823
+ const data = skip(project(track, (track.data ?? '').split(''), ' '));
1824
+ return [track.id, { data: data.join('') }];
1825
+ }));
1826
+ },
1827
+ /**
1828
+ * #method
1829
+ * the height a track draws at: what the user dragged its divider to,
1830
+ * then the height its snapshot asked for, then its kind's default. Only
1831
+ * a text track falls through to rowHeight
1832
+ */
1833
+ trackHeight(kind, heightKey = kind, given) {
1834
+ return (self.trackHeights.get(heightKey) ??
1835
+ given ??
1836
+ defaultTrackHeights[kind] ??
1837
+ self.rowHeight);
1838
+ },
1839
+ /**
1840
+ * #method
1841
+ * the track a column track spec draws as, computed once per spec. A
1842
+ * text track's height falls through to rowHeight, and its own computed
1843
+ * confines the vertical zoom to it, so a sibling keeps its object and
1844
+ * its canvas skips the redraw
1845
+ */
1846
+ columnTrackModel(track) {
1847
+ let computedModel = columnTrackModels.get(track);
1848
+ if (!computedModel) {
1849
+ computedModel = computed(() => {
1850
+ const heightKey = resizableKinds.has(track.kind)
1851
+ ? ownHeightKey(track.id)
1852
+ : undefined;
1853
+ const content = this.columnTrackContent.get(track.id);
1854
+ return {
1855
+ model: {
1856
+ id: track.id,
1857
+ name: track.name,
1858
+ kind: track.kind,
1859
+ heightKey,
1860
+ height: this.trackHeight(track.kind, heightKey, track.height),
1861
+ barColor: track.color,
1862
+ arcColor: track.color,
1863
+ customColorScheme: track.colors,
1864
+ data: content?.data,
1865
+ arcs: content?.arcs,
1866
+ },
1867
+ ReactComponent: TrackBlocks,
1868
+ };
1869
+ });
1870
+ columnTrackModels.set(track, computedModel);
1871
+ }
1872
+ return computedModel.get();
1873
+ },
1874
+ /**
1875
+ * #getter
1876
+ */
1877
+ get columnTrackModels() {
1878
+ return self.columnTracks.map(track => this.columnTrackModel(track));
1879
+ },
1880
+ };
1881
+ })
1360
1882
  .views(self => ({
1361
1883
  /**
1362
1884
  * #getter
1363
1885
  */
1364
1886
  get labelWidthMap() {
1365
- const { showTreeText, leaves, treeMetadata } = self;
1366
- // gated on the same condition the renderer draws labels under, so the
1367
- // gutter labelsWidth reserves and the labels actually drawn cannot
1368
- // disagree -- and so turning labels off hands their space to the tree.
1369
- // Measured at a fixed reference size and scaled by labelWidthScale:
1370
- // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
1887
+ const { showTreeText, leaves } = self;
1888
+ // gated on the renderer's label condition, so hidden labels reserve no
1889
+ // gutter. Measured once at a reference size and scaled by
1890
+ // labelWidthScale: re-measuring per vertical-zoom frame cost ~200ms on a
1371
1891
  // 50k-leaf tree
1372
1892
  return showTreeText
1373
1893
  ? new Map(leaves.map(node => {
1374
1894
  const { name } = node.data;
1375
1895
  // `||`, matching renderTreeLabels: an empty genome falls back
1376
- // to the row name, and measuring '' would size the gutter (and
1377
- // the label's click target) to nothing
1378
- const displayName = treeMetadata[name]?.genome || name;
1896
+ // to the row name
1897
+ const displayName = self.rowDataOf(name)?.genome || name;
1379
1898
  return [
1380
1899
  name,
1381
1900
  measureTextCanvas(displayName, labelReferenceFontSize),
@@ -1392,11 +1911,9 @@ function stateModelFactory() {
1392
1911
  return self.fontSize / labelReferenceFontSize;
1393
1912
  },
1394
1913
  get labelsWidth() {
1395
- // a loop, not Math.max(...widths.values()): spreading a map of every
1396
- // leaf passes one argument per row, and the argument limit is somewhere
1397
- // around 125k -- so the bundled 230k-tip COVID tree threw
1398
- // "RangeError: Maximum call stack size exceeded" out of a getter the
1399
- // treeWidth autorun reads on load
1914
+ // a loop, not Math.max(...): spreading passes one argument per leaf,
1915
+ // and the ~125k argument limit throws RangeError on the 230k-tip COVID
1916
+ // tree
1400
1917
  let max = 0;
1401
1918
  for (const width of this.labelWidthMap.values()) {
1402
1919
  if (width > max) {
@@ -1419,9 +1936,8 @@ function stateModelFactory() {
1419
1936
  },
1420
1937
  /**
1421
1938
  * #getter
1422
- * the base pairs of the consensus secondary structure, as arcs. The WUSS
1423
- * string is collapsed through the hidden columns before it is parsed, so
1424
- * the pairs land in the same visible column space the text track does
1939
+ * the base pairs of the consensus secondary structure, as arcs, in
1940
+ * visible column space (hidden columns are removed before parsing)
1425
1941
  */
1426
1942
  get secondaryStructureArcs() {
1427
1943
  const { blanks, hideGapsEffective } = self;
@@ -1442,9 +1958,8 @@ function stateModelFactory() {
1442
1958
  const { MSA, hideGapsEffective, blanks } = self;
1443
1959
  const tracks = (MSA?.tracks ?? []).filter(t => !!t.data);
1444
1960
  if (tracks.length === 0) {
1445
- // reading rowHeight up front made every zoom frame rebuild the track
1446
- // list, and the canvases redraw on the track object they are handed
1447
- // changing
1961
+ // return before reading rowHeight, so zooming does not rebuild the
1962
+ // list and redraw every track canvas
1448
1963
  return [];
1449
1964
  }
1450
1965
  const { rowHeight } = self;
@@ -1460,103 +1975,9 @@ function stateModelFactory() {
1460
1975
  },
1461
1976
  /**
1462
1977
  * #getter
1463
- * a data track's values or string, projected from its row's residues
1464
- * onto alignment columns when it names a row
1465
- */
1466
- get columnTrackContent() {
1467
- const { MSA, blanks, hideGapsEffective } = self;
1468
- const width = MSA?.getWidth() ?? 0;
1469
- const project = (track, items, fill) => {
1470
- if (!track.row) {
1471
- return items;
1472
- }
1473
- const out = Array.from({ length: width }, () => fill);
1474
- const index = self.seqPosIndex(track.row);
1475
- items.forEach((item, seqPos) => {
1476
- const col = index?.[seqPos];
1477
- if (col !== undefined) {
1478
- out[col] = item;
1479
- }
1480
- });
1481
- return out;
1482
- };
1483
- const skip = (items) => hideGapsEffective ? dropBlanks(blanks, items) : items;
1484
- // an arc names two positions rather than one per column, so it takes
1485
- // the same two steps the arrays take -- a row's residues onto columns,
1486
- // then columns onto the visible ones -- as a lookup. visibleColsBefore,
1487
- // not globalColToVisibleCol: an endpoint in a hidden column collapses
1488
- // to where that column went instead of taking the whole arc with it
1489
- const resolve = (track, pos) => {
1490
- const col = track.row
1491
- ? self.seqPosIndex(track.row)?.[pos - 1]
1492
- : pos - 1;
1493
- if (col === undefined || col < 0 || col >= width) {
1494
- return undefined;
1495
- }
1496
- return hideGapsEffective ? visibleColsBefore(blanks, col) : col;
1497
- };
1498
- return new Map(self.columnTracks.map(track => {
1499
- if (track.kind === 'arc') {
1500
- const arcs = (track.arcs ?? [])
1501
- .map(arc => {
1502
- const start = resolve(track, Math.min(arc.start, arc.end));
1503
- const end = resolve(track, Math.max(arc.start, arc.end));
1504
- return start !== undefined && end !== undefined && start < end
1505
- ? { start, end, color: arc.color }
1506
- : undefined;
1507
- })
1508
- .filter(notEmpty);
1509
- return [track.id, { arcs }];
1510
- }
1511
- if (track.kind === 'bar') {
1512
- const max = track.max ?? 1;
1513
- const values = skip(project(track, track.values ?? [], 0)).map(v => Math.min(1, Math.max(0, v / max)));
1514
- return [track.id, { values }];
1515
- }
1516
- const data = skip(project(track, (track.data ?? '').split(''), ' '));
1517
- return [track.id, { data: data.join('') }];
1518
- }));
1519
- },
1520
- /**
1521
- * #getter
1522
- */
1523
- get columnTrackModels() {
1524
- // read per kind, not up front: a text track is the only kind sized by
1525
- // the row height, and reading it here rebuilt every data track on every
1526
- // vertical zoom step
1527
- const defaultHeight = (kind) => kind === 'bar'
1528
- ? self.conservationTrackHeight
1529
- : kind === 'arc'
1530
- ? self.arcTrackHeight
1531
- : self.rowHeight;
1532
- return self.columnTracks.map(track => ({
1533
- model: {
1534
- id: track.id,
1535
- name: track.name,
1536
- kind: track.kind,
1537
- height: self.columnTrackHeights[track.id] ??
1538
- track.height ??
1539
- defaultHeight(track.kind),
1540
- // the spec has one `color`; bar and arc are separate track models
1541
- // that read it under their own name
1542
- barColor: track.color,
1543
- arcColor: track.color,
1544
- customColorScheme: track.colors,
1545
- data: this.columnTrackContent.get(track.id)?.data,
1546
- arcs: this.columnTrackContent.get(track.id)?.arcs,
1547
- },
1548
- ReactComponent: TrackBlocks,
1549
- }));
1550
- },
1551
- /**
1552
- * #getter
1553
- */
1554
- /**
1555
- * #getter
1556
- * the consensus secondary structure as a track, when there is one. Its
1557
- * own getter so the object keeps its identity across a zoom: the canvas
1558
- * redraws on the track it is handed changing, and rebuilding these
1559
- * alongside everything else made every zoom frame redraw every track
1978
+ * the consensus secondary structure as a track, when there is one. A
1979
+ * separate getter keeps the object stable across zoom, so its canvas
1980
+ * does not redraw
1560
1981
  */
1561
1982
  get basePairTrackModels() {
1562
1983
  const arcs = this.secondaryStructureArcs;
@@ -1567,7 +1988,8 @@ function stateModelFactory() {
1567
1988
  id: 'base-pairs',
1568
1989
  name: 'Base pairs',
1569
1990
  kind: 'arc',
1570
- height: self.arcTrackHeight,
1991
+ heightKey: 'arc',
1992
+ height: self.trackHeight('arc'),
1571
1993
  arcs,
1572
1994
  },
1573
1995
  ReactComponent: TrackBlocks,
@@ -1577,8 +1999,8 @@ function stateModelFactory() {
1577
1999
  },
1578
2000
  /**
1579
2001
  * #getter
1580
- * the tracks computed from the alignment itself, which depend on their
1581
- * own heights and on the alphabet -- and on nothing zoom changes
2002
+ * the tracks computed from the alignment; they depend on their heights
2003
+ * and the alphabet, not on zoom
1582
2004
  */
1583
2005
  get computedTrackModels() {
1584
2006
  return [
@@ -1586,7 +2008,6 @@ function stateModelFactory() {
1586
2008
  id: 'conservation',
1587
2009
  name: 'Conservation',
1588
2010
  kind: 'bar',
1589
- height: self.conservationTrackHeight,
1590
2011
  barColor: 'gray',
1591
2012
  },
1592
2013
  ...(self.sequenceType === 'amino'
@@ -1595,7 +2016,6 @@ function stateModelFactory() {
1595
2016
  id: 'property-conservation',
1596
2017
  name: 'Property conservation',
1597
2018
  kind: 'bar',
1598
- height: self.conservationTrackHeight,
1599
2019
  barColor: '#6a51a3',
1600
2020
  },
1601
2021
  ]
@@ -1604,23 +2024,32 @@ function stateModelFactory() {
1604
2024
  id: 'sequence-logo',
1605
2025
  name: 'Sequence logo',
1606
2026
  kind: 'logo',
1607
- height: self.sequenceLogoTrackHeight,
1608
2027
  },
1609
2028
  // last, so it sits against the alignment it numbers
1610
2029
  {
1611
2030
  id: 'position-ruler',
1612
2031
  name: 'Position',
1613
2032
  kind: 'ruler',
1614
- height: 20,
1615
2033
  },
1616
- ].map(model => ({ model, ReactComponent: TrackBlocks }));
2034
+ ].map(model => ({
2035
+ // every computed track of a kind shares that kind's height, so the
2036
+ // kind is its key
2037
+ model: {
2038
+ ...model,
2039
+ heightKey: resizableKinds.has(model.kind) ? model.kind : undefined,
2040
+ height: self.trackHeight(model.kind),
2041
+ },
2042
+ ReactComponent: TrackBlocks,
2043
+ }));
1617
2044
  },
1618
2045
  get tracks() {
1619
2046
  return [
1620
2047
  ...this.adapterTrackModels,
1621
2048
  ...this.basePairTrackModels,
1622
- ...this.columnTrackModels,
1623
- ...this.computedTrackModels,
2049
+ ...self.columnTrackModels,
2050
+ // every computed track reads the alignment's columns, and a tree, a
2051
+ // GFF and a features panel draw a figure with none
2052
+ ...(self.numColumns > 0 ? this.computedTrackModels : []),
1624
2053
  ];
1625
2054
  },
1626
2055
  /**
@@ -1651,19 +2080,18 @@ function stateModelFactory() {
1651
2080
  * Convert a visible column to a row-specific sequence position (0-based).
1652
2081
  * Returns undefined if the position is a gap in the sequence.
1653
2082
  *
1654
- * PUBLIC API: this and the sibling coordinate converters
1655
- * (visibleColToGlobalCol, seqPosToVisibleCol, globalColToVisibleCol,
1656
- * seqPosToGlobalCol) are how a host translates between alignment columns
1657
- * and a row's residue positions across gaps. Keep them stable.
2083
+ * Public API, like the sibling converters (visibleColToGlobalCol,
2084
+ * seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) hosts
2085
+ * use to translate between columns and residue positions. Keep them
2086
+ * stable.
1658
2087
  *
1659
2088
  * @param rowName - The name of the row
1660
2089
  * @param visibleCol - The visible column index
1661
2090
  * @returns The sequence position (0-based), or undefined if it's a gap
1662
2091
  */
1663
2092
  visibleColToSeqPos(rowName, visibleCol) {
1664
- // a binary search of the row's index, not a scan of the row: this
1665
- // answers on every mouse move, and a 30k-column row scanned per event
1666
- // is the whole frame
2093
+ // binary search: this runs on every mouse move, and scanning a
2094
+ // 30k-column row per event takes the whole frame
1667
2095
  return seqPosOfGlobalCol(self.seqPosIndex(rowName), this.visibleColToGlobalCol(visibleCol));
1668
2096
  },
1669
2097
  /**
@@ -1697,10 +2125,9 @@ function stateModelFactory() {
1697
2125
  },
1698
2126
  /**
1699
2127
  * #method
1700
- * Convert a visible column index (what a mouse handler reports) back to a
1701
- * column of the full alignment. Hidden columns shift everything to their
1702
- * right, so a host that holds per-column data of its own has to make this
1703
- * hop before indexing it.
2128
+ * Convert a visible column index (what a mouse handler reports) to a
2129
+ * column of the full alignment. A host indexing its own per-column data
2130
+ * needs this when columns are hidden.
1704
2131
  *
1705
2132
  * @param visibleCol - The visible column index
1706
2133
  * @returns The global column index in the full MSA
@@ -1714,8 +2141,7 @@ function stateModelFactory() {
1714
2141
  /**
1715
2142
  * #method
1716
2143
  * Convert a sequence position (ungapped) to a global column index.
1717
- * Returns undefined for a row the alignment does not have -- answering
1718
- * anyway is how a mistyped or stale row name came to highlight column 0.
2144
+ * Returns undefined for a row name the alignment does not have.
1719
2145
  *
1720
2146
  * @param rowName - The name of the row
1721
2147
  * @param seqPos - The sequence position (0-based, ungapped)
@@ -1746,12 +2172,39 @@ function stateModelFactory() {
1746
2172
  ? undefined
1747
2173
  : this.globalColToVisibleCol(globalCol);
1748
2174
  },
2175
+ /**
2176
+ * #method
2177
+ * the visible columns a span covers, in highlight coordinates: `start`
2178
+ * and `end` are 1-based residues of `row`, or columns of the file
2179
+ * without it. A fractional position, as a zoom gesture reports one,
2180
+ * widens to the whole residue or column it falls in. A span entirely on
2181
+ * hidden columns, or naming a row the alignment lacks, gives undefined.
2182
+ */
2183
+ visibleSpan({ row, start: rawStart, end: rawEnd }) {
2184
+ const { blanks } = self;
2185
+ const start = Math.max(1, Math.floor(rawStart));
2186
+ const end = Math.ceil(rawEnd);
2187
+ let startGlobal = start - 1;
2188
+ let endGlobal = end - 1;
2189
+ if (row !== undefined) {
2190
+ const rowStart = this.seqPosToGlobalCol(row, start - 1);
2191
+ const rowEnd = this.seqPosToGlobalCol(row, end - 1);
2192
+ if (rowStart === undefined || rowEnd === undefined) {
2193
+ return undefined;
2194
+ }
2195
+ startGlobal = rowStart;
2196
+ endGlobal = rowEnd;
2197
+ }
2198
+ const startCol = this.globalColToVisibleCol(startGlobal) ??
2199
+ visibleColsBefore(blanks, startGlobal);
2200
+ const endCol = this.globalColToVisibleCol(endGlobal) ??
2201
+ visibleColsBefore(blanks, endGlobal) - 1;
2202
+ return startCol <= endCol ? { startCol, endCol } : undefined;
2203
+ },
1749
2204
  /**
1750
2205
  * #getter
1751
- * every reason a mapping is being ignored, so a host can say which. A
1752
- * mapping outlives the alignment it was computed for; when the two no
1753
- * longer agree the lookups have to refuse, and refusing invisibly is how
1754
- * "there is no structure here" gets confused with "this data is stale".
2206
+ * why each ignored residue mapping is ignored, so a host can tell a
2207
+ * missing structure from a mapping made against a different alignment.
1755
2208
  */
1756
2209
  get residueMappingProblems() {
1757
2210
  const problems = [];
@@ -1798,10 +2251,8 @@ function stateModelFactory() {
1798
2251
  },
1799
2252
  /**
1800
2253
  * #getter
1801
- * the mappings that still fit the loaded alignment. A row-level problem
1802
- * takes the whole mapping out; a single malformed segment takes only
1803
- * itself, since the rest of the mapping is still a claim about residues
1804
- * that exist.
2254
+ * the mappings that fit the loaded alignment. A row-level problem drops
2255
+ * the whole mapping; a malformed segment drops only that segment.
1805
2256
  */
1806
2257
  get usableResidueMappings() {
1807
2258
  const unusable = new Set(this.residueMappingProblems
@@ -1816,9 +2267,8 @@ function stateModelFactory() {
1816
2267
  },
1817
2268
  /**
1818
2269
  * #getter
1819
- * the structures the loaded alignment has usable mappings onto. A row can
1820
- * have several -- an experimental entry and a predicted model, say -- so
1821
- * a host that means a particular one has to name it.
2270
+ * the structures with usable mappings. A row can map onto several, such
2271
+ * as an experimental entry and a predicted model.
1822
2272
  */
1823
2273
  get mappedStructures() {
1824
2274
  return this.usableResidueMappings.map(m => ({
@@ -1828,18 +2278,12 @@ function stateModelFactory() {
1828
2278
  },
1829
2279
  /**
1830
2280
  * #method
1831
- * The structure residue a row residue is, or undefined. Refusing is the
1832
- * point: the guess this replaces answered every query, with a wrong
1833
- * residue when it did not know.
1834
- *
1835
- * It also refuses when the answer is not unique. A row commonly maps onto
1836
- * several structures -- an experimental entry and two predicted models --
1837
- * and returning whichever came first would be the same class of wrong,
1838
- * quieter. Name one with `structureId`, or use `mappedStructures` to see
1839
- * what there is.
2281
+ * The structure residue for a row residue. Returns undefined when no
2282
+ * segment covers `seqPos`, or when the row maps onto several structures
2283
+ * and `structureId` does not pick one (see `mappedStructures`).
1840
2284
  *
1841
- * Positions are 1-based, as `residueMappings` and `highlights` are --
1842
- * note that the column helpers above take 0-based ones.
2285
+ * Positions are 1-based, like `residueMappings` and `highlights`; the
2286
+ * column helpers above are 0-based.
1843
2287
  *
1844
2288
  * @param rowName - The alignment row
1845
2289
  * @param seqPos - Residue of that row, 1-based
@@ -1869,10 +2313,10 @@ function stateModelFactory() {
1869
2313
  },
1870
2314
  /**
1871
2315
  * #method
1872
- * The row residue a structure residue is, the same lookup backwards, and
1873
- * refusing on the same terms. `asymId` picks between mappings onto the
1874
- * same entry, which a homodimer -- two rows, two chains, one id -- always
1875
- * needs; without it such a lookup is ambiguous and gets nothing.
2316
+ * The row residue for a structure residue; the inverse of
2317
+ * `structureResidue`, returning undefined in the same cases. `asymId`
2318
+ * picks a chain when several mappings share an entry id, as in a
2319
+ * homodimer.
1876
2320
  *
1877
2321
  * @param structureId - The structure's id, as the mapping names it
1878
2322
  * @param position - Residue of that structure, 1-based label_seq_id
@@ -1901,16 +2345,31 @@ function stateModelFactory() {
1901
2345
  .views(self => ({
1902
2346
  /**
1903
2347
  * #getter
1904
- * the vertical space the alignment rows actually get: the widget height
1905
- * less everything stacked above and below them -- the header, the tracks,
1906
- * and the minimap when the columns overflow. Every consumer wants this
1907
- * same subtraction, so there is one of it: blocksY, maxScrollY, the
1908
- * vertical scrollbar and fitVertically all read it, and a second getter
1909
- * that forgot the tracks is what put the last rows out of reach.
2348
+ * the branch-length scale bar over the tree, undefined in cladogram mode
2349
+ * or when the tree area is too narrow for one
2350
+ */
2351
+ get treeScaleBar() {
2352
+ return scaleBarLength(self.pxPerBranchLength, self.treeAreaWidth - self.marginLeft * 2);
2353
+ },
2354
+ /**
2355
+ * #getter
2356
+ * the band across the top: the minimap over the alignment, the row panel
2357
+ * headers, and the tree overview stacked on the scale bar, as tall as the
2358
+ * tallest of the three
2359
+ */
2360
+ get topBandHeight() {
2361
+ return Math.max(self.showHorizontalScrollbar ? self.minimapHeight : 0, self.rowPanelsHeaderHeight, self.treeOverviewHeight +
2362
+ (this.treeScaleBar ? treeScaleBarHeight : 0));
2363
+ },
2364
+ /**
2365
+ * #getter
2366
+ * the vertical space for alignment rows: the widget height less the
2367
+ * header, the top band and the tracks. Shared by blocksY, maxScrollY, the
2368
+ * vertical scrollbar and fitVertically.
1910
2369
  */
1911
2370
  get msaAreaHeight() {
1912
2371
  return (self.height -
1913
- (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
2372
+ this.topBandHeight -
1914
2373
  self.headerHeight -
1915
2374
  this.totalTrackAreaHeight);
1916
2375
  },
@@ -1922,13 +2381,12 @@ function stateModelFactory() {
1922
2381
  return sum(self.turnedOnTracks.map(r => r.model.height));
1923
2382
  },
1924
2383
  /**
1925
- * one representative annotation per accession, which is what the legend,
1926
- * the filter dialog and the palettes key off
2384
+ * one representative annotation per accession, for the legend, the
2385
+ * filter dialog and the palettes
1927
2386
  */
1928
2387
  get annotationTypes() {
1929
- // first occurrence wins. The representative supplies only the name,
1930
- // description and -- for ordinal segments -- the start that orders
1931
- // them, and those agree across an accession's instances
2388
+ // first occurrence wins; only name, description and segment start are
2389
+ // read from it
1932
2390
  const types = new Map();
1933
2391
  for (const annot of self.annotations) {
1934
2392
  if (!types.has(annot.accession)) {
@@ -1957,11 +2415,8 @@ function stateModelFactory() {
1957
2415
  * #getter
1958
2416
  */
1959
2417
  get dataInitialized() {
1960
- // truthiness, not `!== ''`: these are types.maybe, and DataModel's
1961
- // postProcessSnapshot drops a document over 50kb, so a restored session
1962
- // that inlined a large alignment comes back `undefined` here -- which
1963
- // `!== ''` reads as initialized and renders an empty view instead of
1964
- // the import form
2418
+ // truthiness, not `!== ''`: DataModel drops an inline document over
2419
+ // 50kb from the snapshot, so a restored session can hold `undefined`
1965
2420
  return !!(self.data.msa || self.data.tree) && !self.error;
1966
2421
  },
1967
2422
  /**
@@ -2008,12 +2463,20 @@ function stateModelFactory() {
2008
2463
  },
2009
2464
  /**
2010
2465
  * #getter
2011
- * most-negative allowed scrollY, keeping the last row in view rather than
2012
- * letting the whole alignment scroll off the top.
2466
+ * most-negative allowed scrollY, which keeps the last row in view
2013
2467
  */
2014
2468
  get maxScrollY() {
2015
2469
  return Math.min(-self.totalHeight + self.msaAreaHeight, 0);
2016
2470
  },
2471
+ /**
2472
+ * #getter
2473
+ * axis a wheel zoom scales, for ctrl+wheel as much as for scroll-zoom.
2474
+ * With scroll-zoom off the toolbar shows no axis, so ctrl+wheel takes
2475
+ * both.
2476
+ */
2477
+ get wheelZoomAxis() {
2478
+ return self.scrollZoom ? self.scrollZoomAxis : 'both';
2479
+ },
2017
2480
  }))
2018
2481
  .actions(self => ({
2019
2482
  /**
@@ -2028,15 +2491,19 @@ function stateModelFactory() {
2028
2491
  setScrollZoom(arg) {
2029
2492
  self.scrollZoom = arg;
2030
2493
  },
2494
+ /**
2495
+ * #action
2496
+ */
2497
+ setScrollZoomAxis(arg) {
2498
+ self.scrollZoomAxis = arg;
2499
+ },
2031
2500
  /**
2032
2501
  * #action
2033
2502
  * set hovered tree node and its descendants
2034
2503
  */
2035
2504
  setHoveredTreeNode(nodeId) {
2036
- // the tree's mousemove handler calls this on every event, and both the
2037
- // lookup and the write are expensive: `find` walks the whole hierarchy,
2038
- // and a fresh object here invalidates hoveredRowIndices and redraws the
2039
- // tree and MSA overlays. Re-hovering the same node is the common case
2505
+ // called on every tree mousemove; `find` walks the whole hierarchy and a
2506
+ // new object redraws the tree and MSA overlays
2040
2507
  if (nodeId === self.hoveredTreeNode?.nodeId) {
2041
2508
  return;
2042
2509
  }
@@ -2055,15 +2522,12 @@ function stateModelFactory() {
2055
2522
  /**
2056
2523
  * #action
2057
2524
  * Calculate a neighbor joining tree from the current MSA using BLOSUM62
2058
- * distances. Refuses above `maxNeighborJoiningRows`: the join loop is
2059
- * cubic and runs on the main thread, so 800 rows is a ten-second freeze
2060
- * with no progress and no cancel, and a tree that size wants a tool built
2061
- * for it anyway.
2525
+ * distances. Throws above `maxNeighborJoiningRows`: the join loop is
2526
+ * cubic and runs on the main thread, and 800 rows freeze the tab for ten
2527
+ * seconds with no cancel.
2062
2528
  */
2063
2529
  calculateNeighborJoiningTreeFromMSA() {
2064
- // every sequence in the alignment, not the rows on screen: a collapsed
2065
- // clade is a display state, and building the tree from what it leaves
2066
- // showing drops the sequences it hides out of the result
2530
+ // every sequence, including rows in collapsed clades
2067
2531
  const rows = [...self.rowMap];
2068
2532
  if (rows.length < 2) {
2069
2533
  throw new Error('Need at least 2 sequences to build a tree');
@@ -2075,10 +2539,9 @@ function stateModelFactory() {
2075
2539
  },
2076
2540
  /**
2077
2541
  * #action
2078
- * swap in a different tree over the same alignment. Node ids are derived
2079
- * from the path (node-0-0-1), so a `collapsed` or `showOnly` id held over
2080
- * from the old tree matches a real node in the new one and folds whatever
2081
- * happens to sit there -- the ids go with the tree they name.
2542
+ * swap in a different tree over the same alignment. Clears `collapsed`
2543
+ * and `showOnly`, since path-derived node ids (node-0-0-1) from the old
2544
+ * tree would match unrelated nodes in the new one.
2082
2545
  */
2083
2546
  replaceTree(newick) {
2084
2547
  transaction(() => {
@@ -2143,23 +2606,26 @@ function stateModelFactory() {
2143
2606
  * #action
2144
2607
  * Smoothly zoom by a continuous scaleFactor. The column under the cursor
2145
2608
  * (offsetX/offsetY, px relative to the MSA area) stays anchored
2146
- * horizontally. Vertically the anchor is biased toward the top: when the
2147
- * alignment nearly fits the viewport, snap to y=0 rather than pinning a
2148
- * random row under the cursor, with the bias fading out as the alignment
2149
- * grows taller than the viewport (where cursor-anchoring is useful).
2150
- * Drives wheel/trackpad-pinch zoom.
2151
- */
2152
- zoomToPos(scaleFactor, offsetX, offsetY) {
2609
+ * horizontally. Vertically the anchor is biased toward y=0 when the
2610
+ * alignment nearly fits the viewport, fading to cursor-anchoring as the
2611
+ * alignment grows taller than the viewport.
2612
+ * Drives wheel/trackpad-pinch zoom. `axis` holds one cell dimension
2613
+ * fixed; the held axis still re-anchors its scroll offset, since the
2614
+ * other one can change how much of the alignment fits.
2615
+ */
2616
+ zoomToPos(scaleFactor, offsetX, offsetY, axis = 'both') {
2153
2617
  transaction(() => {
2154
2618
  const colInView = (-self.scrollX + offsetX) / self.colWidth;
2155
2619
  const rowInView = (-self.scrollY + offsetY) / self.rowHeight;
2156
- self.colWidth = clamp(self.colWidth * scaleFactor, minColWidth, maxCellSize);
2157
- self.rowHeight = clamp(self.rowHeight * scaleFactor, minRowHeight, maxCellSize);
2620
+ if (axis !== 'vertical') {
2621
+ self.colWidth = clamp(self.colWidth * scaleFactor, minColWidth, maxCellSize);
2622
+ }
2623
+ if (axis !== 'horizontal') {
2624
+ self.rowHeight = clamp(self.rowHeight * scaleFactor, minRowHeight, maxCellSize);
2625
+ }
2158
2626
  self.scrollX = clamp(offsetX - colInView * self.colWidth, self.maxScrollX, 0);
2159
2627
  const anchoredScrollY = offsetY - rowInView * self.rowHeight;
2160
- // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
2161
- // overflows, so -maxScrollY is exactly that overflow past the
2162
- // scrollable MSA viewport (0 when it fits)
2628
+ // -maxScrollY is the overflow past the viewport, 0 when it fits
2163
2629
  const overflow = Math.max(0, -self.maxScrollY);
2164
2630
  const visibleHeight = self.totalHeight - overflow;
2165
2631
  const topBias = visibleHeight > 0 ? clamp(1 - overflow / visibleHeight, 0, 1) : 1;
@@ -2181,13 +2647,11 @@ function stateModelFactory() {
2181
2647
  },
2182
2648
  /**
2183
2649
  * #action
2184
- * Set the overlay annotations (an empty list clears them). Every source
2185
- * funnels through here after its own adapter has flattened it:
2186
- * InterProScan, GFF, user uploads, NCBI CDD.
2650
+ * Set the overlay annotations (an empty list clears them). InterProScan,
2651
+ * GFF, user uploads and NCBI CDD all arrive here as Annotation[].
2187
2652
  *
2188
- * It does not touch `showDomains`. Loading used to force the overlay on,
2189
- * and since a restored snapshot loads its GFF again on the way in, a link
2190
- * shared with the overlay hidden reopened with it drawn.
2653
+ * Leaves `showDomains` alone, because a restored snapshot reloads its
2654
+ * GFF and must keep a hidden overlay hidden.
2191
2655
  */
2192
2656
  setAnnotations(annotations) {
2193
2657
  self.annotations = annotations;
@@ -2222,6 +2686,28 @@ function stateModelFactory() {
2222
2686
  setColumnTracks(tracks) {
2223
2687
  self.columnTracks.replace(tracks);
2224
2688
  },
2689
+ /**
2690
+ * #action
2691
+ * replace the row table, which the model keeps as the JSON string
2692
+ * `data.treeMetadata` (see docs/layers.md)
2693
+ */
2694
+ setRowData(rowData) {
2695
+ self.data.setTreeMetadata(JSON.stringify(rowData));
2696
+ },
2697
+ /**
2698
+ * #action
2699
+ * replace what the viewer's marks read from the row table
2700
+ */
2701
+ setEncodings(encodings) {
2702
+ self.encodings.replace(encodings);
2703
+ },
2704
+ /**
2705
+ * #action
2706
+ * replace the panels drawn between the tree and the alignment
2707
+ */
2708
+ setRowPanels(panels) {
2709
+ self.rowPanels.replace(panels);
2710
+ },
2225
2711
  /**
2226
2712
  * #action
2227
2713
  * replace the alignment<->structure correspondence (see docs/layers.md)
@@ -2233,8 +2719,6 @@ function stateModelFactory() {
2233
2719
  * #action
2234
2720
  */
2235
2721
  toggleTrack(id) {
2236
- // the stored value is "is off", so the current shown state is exactly
2237
- // what the flipped entry should hold
2238
2722
  const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff;
2239
2723
  self.turnedOffTracks.set(id, !trackIsOff(self.turnedOffTracks, id, defaultOff));
2240
2724
  },
@@ -2254,18 +2738,77 @@ function stateModelFactory() {
2254
2738
  },
2255
2739
  /**
2256
2740
  * #getter
2257
- * width of the alignment canvas itself: the msa area less the vertical
2258
- * scrollbar sitting in it. Not usable from showHorizontalScrollbar, which
2259
- * feeds msaAreaHeight -> showVerticalScrollbar and would close a cycle
2741
+ * width of the alignment canvas: the msa area less the vertical
2742
+ * scrollbar. showHorizontalScrollbar must not read it, since that feeds
2743
+ * msaAreaHeight -> showVerticalScrollbar and would form a cycle
2260
2744
  */
2261
2745
  get msaCanvasWidth() {
2262
2746
  return self.msaAreaWidth - this.verticalScrollbarWidth;
2263
2747
  },
2748
+ /**
2749
+ * #method
2750
+ * the cell at a visible column and row index, in the coordinates a host
2751
+ * writes highlights in
2752
+ */
2753
+ cellAt(visibleCol, rowIndex) {
2754
+ const column = self.visibleColToGlobalCol(visibleCol);
2755
+ const row = rowIndex === undefined ? undefined : self.leaves[rowIndex]?.data.name;
2756
+ return row === undefined
2757
+ ? { column: column + 1 }
2758
+ : {
2759
+ column: column + 1,
2760
+ row,
2761
+ residue: self.visibleColToSeqPosOneBased(row, visibleCol),
2762
+ letter: self.rowMap.get(row)?.[column],
2763
+ };
2764
+ },
2765
+ /**
2766
+ * #getter
2767
+ * the cell under the pointer. Public API: MSAViewer's onCellHover
2768
+ * reports it.
2769
+ */
2770
+ get hoveredCell() {
2771
+ const { mouseCol, mouseRow } = self;
2772
+ return mouseCol === undefined
2773
+ ? undefined
2774
+ : this.cellAt(mouseCol, mouseRow);
2775
+ },
2776
+ /**
2777
+ * #getter
2778
+ * the cell a click pinned. Public API: MSAViewer's onCellClick reports it.
2779
+ */
2780
+ get clickedCell() {
2781
+ const { mouseClickCol, mouseClickRow } = self;
2782
+ return mouseClickCol === undefined
2783
+ ? undefined
2784
+ : this.cellAt(mouseClickCol, mouseClickRow);
2785
+ },
2786
+ /**
2787
+ * #getter
2788
+ * the columns on screen. Public API: MSAViewer's onViewportChange reports
2789
+ * it.
2790
+ */
2791
+ get viewport() {
2792
+ const { scrollX, colWidth, numColumns, viewInitialized } = self;
2793
+ if (numColumns === 0 || !viewInitialized) {
2794
+ return undefined;
2795
+ }
2796
+ const { xStart, xEnd } = visibleColRange({
2797
+ offsetX: -scrollX,
2798
+ blockWidth: this.msaCanvasWidth,
2799
+ colWidth,
2800
+ });
2801
+ const last = Math.max(0, Math.min(xEnd, numColumns) - 1);
2802
+ return {
2803
+ startColumn: self.visibleColToGlobalCol(Math.min(xStart, last)) + 1,
2804
+ endColumn: self.visibleColToGlobalCol(last) + 1,
2805
+ };
2806
+ },
2264
2807
  /**
2265
2808
  * #getter
2266
2809
  * ordinal segment types (exons etc.), ordered by sequence position so
2267
- * exon-1..exon-14 read left-to-right; colored by alternating shade and
2268
- * labeled by number rather than each getting a distinct hue + legend row
2810
+ * exon-1..exon-14 run left-to-right; colored by alternating shade and
2811
+ * labeled by number, with no legend row
2269
2812
  */
2270
2813
  get segmentDomainTypes() {
2271
2814
  return [...self.annotationTypes.values()]
@@ -2288,11 +2831,34 @@ function stateModelFactory() {
2288
2831
  const categorical = createPaletteMap(this.categoricalDomainTypes.map(d => d.accession));
2289
2832
  return { ...segments, ...categorical };
2290
2833
  },
2291
- get strokePalette() {
2292
- return transform(this.fillPalette, ([key, val]) => [
2293
- key,
2294
- colord(val).darken(0.1).toHex(),
2295
- ]);
2834
+ /**
2835
+ * #getter
2836
+ * the encoding coloring the overlay's spans, undefined when none does,
2837
+ * which leaves each span the color its accession takes in `fillPalette`
2838
+ */
2839
+ get featureFillEncoding() {
2840
+ return this.resolvedEncodings.find(e => e.channel === 'featureFill');
2841
+ },
2842
+ /**
2843
+ * #getter
2844
+ * the fill and outline of every feature's span: its own GFF `color=`
2845
+ * first, then the `featureFill` scale, then the accession palette.
2846
+ * Computed once per change of the features, the encodings or the palette
2847
+ */
2848
+ get featureColors() {
2849
+ return featureColorMap(self.filteredAnnotations, this.featureFillEncoding, this.fillPalette);
2850
+ },
2851
+ /**
2852
+ * #getter
2853
+ * the text the `featureLabel` channel draws inside each span, undefined
2854
+ * when no encoding names the channel. A data channel, so it draws
2855
+ * whether or not the residue letters do
2856
+ */
2857
+ get featureLabels() {
2858
+ const encoding = this.resolvedEncodings.find(e => e.channel === 'featureLabel');
2859
+ return encoding
2860
+ ? featureLabelMap(self.filteredAnnotations, encoding.field)
2861
+ : undefined;
2296
2862
  },
2297
2863
  /**
2298
2864
  * #getter
@@ -2308,24 +2874,92 @@ function stateModelFactory() {
2308
2874
  /**
2309
2875
  * #getter
2310
2876
  * the domain types currently drawn on the alignment (filtered-on), shared
2311
- * by the on-screen legend and the SVG export legend. Ordinal segments
2312
- * (exons) are excluded — they read as a numbered gene model, not a color
2313
- * key — so this is the categorical types ordered by sequence position
2877
+ * by the on-screen legend and the SVG export legend: the categorical types
2878
+ * ordered by sequence position. Ordinal segments (exons) are numbered on
2879
+ * the band instead
2314
2880
  */
2315
2881
  get visibleDomainTypes() {
2316
2882
  return this.categoricalDomainTypes
2317
2883
  .filter(d => !self.turnedOffFeatures.get(d.accession))
2318
2884
  .toSorted((a, b) => a.start - b.start);
2319
2885
  },
2886
+ /**
2887
+ * #getter
2888
+ * the categorical color keys drawn for this view, shared by the on-screen
2889
+ * legend overlay and the SVG export's reserved column. The domain overlay
2890
+ * produces the first, listing the `featureFill` scale where an encoding
2891
+ * names one. Every field a row-table encoding or a row panel reads
2892
+ * produces one more, so two channels over one field, or two strips over
2893
+ * it, list that field once. A strip's `legend` names the title it lists
2894
+ * under, so strips over eight fields with one set of colors list one
2895
+ * legend between them
2896
+ */
2897
+ get legends() {
2898
+ const { featureFillEncoding, fillPalette, visibleDomainTypes } = this;
2899
+ const byKey = new Map();
2900
+ // the accession palette keys on no field, so it takes a key of its own
2901
+ const add = (field, entries, id) => {
2902
+ if (entries.length === 0) {
2903
+ return;
2904
+ }
2905
+ const key = field === undefined ? 'domains' : `field:${field}`;
2906
+ const legend = byKey.get(key);
2907
+ if (legend) {
2908
+ const seen = new Set(legend.entries.map(e => e.id));
2909
+ legend.entries.push(...entries.filter(e => !seen.has(e.id)));
2910
+ }
2911
+ else {
2912
+ byKey.set(key, {
2913
+ id: id ?? (field === undefined ? 'domains' : `rowData-${field}`),
2914
+ title: field ?? 'Domains',
2915
+ entries: [...entries],
2916
+ });
2917
+ }
2918
+ };
2919
+ // the overlay draws the domain key; with no columns it draws nothing,
2920
+ // and a features panel taking the same colors lists them instead
2921
+ if (self.actuallyShowDomains && self.numColumns > 0) {
2922
+ add(featureFillEncoding?.field, featureFillEncoding
2923
+ ? featureFillEncoding.legend
2924
+ : visibleDomainTypes.map(d => ({
2925
+ id: d.accession,
2926
+ label: d.name,
2927
+ color: fillPalette[d.accession],
2928
+ })), 'domains');
2929
+ }
2930
+ for (const { channel, field, legend } of this.resolvedEncodings) {
2931
+ if (!featureChannels.has(channel)) {
2932
+ add(field, legend);
2933
+ }
2934
+ }
2935
+ for (const { legendTitle, legend } of this.resolvedRowPanels) {
2936
+ add(legendTitle, legend);
2937
+ }
2938
+ return [...byKey.values()];
2939
+ },
2940
+ /**
2941
+ * #getter
2942
+ * whether the overlay marks each domain with a bar under its row instead
2943
+ * of filling the row behind the letters. Letter-color mode hands the
2944
+ * background to the color scheme, so a filled box would paint over it and
2945
+ * leave the setting with nothing to show. Sub-row layout already stacks
2946
+ * the boxes clear of the letters, and with the letters too small to draw
2947
+ * the filled box is the only thing left to read.
2948
+ */
2949
+ get domainUnderline() {
2950
+ return (self.actuallyShowDomains &&
2951
+ !self.bgColor &&
2952
+ !self.subFeatureRows &&
2953
+ self.showMsaLetters);
2954
+ },
2320
2955
  /**
2321
2956
  * #getter
2322
2957
  * every filtered-on annotation resolved to the visible column span it is
2323
2958
  * drawn across, keyed by row name. Each row is ordered longest-first so a
2324
- * short domain nested inside a long one draws on top of it rather than
2325
- * under it. Resolving these once here rather than inside each canvas
2326
- * block removes a per-feature, per-block sequence position conversion
2327
- * from every redraw, and gives the letter renderer the band colors it
2328
- * needs to keep residues readable on top of the boxes.
2959
+ * nested short domain draws on top, and each band carries the lane the
2960
+ * sub-row layout puts it in. Resolved once here instead of per canvas
2961
+ * block per redraw; the letter renderer also reads the band colors to
2962
+ * pick legible letter colors.
2329
2963
  */
2330
2964
  get domainBands() {
2331
2965
  const { blanks } = self;
@@ -2334,14 +2968,11 @@ function stateModelFactory() {
2334
2968
  const rowBands = annotations
2335
2969
  .toSorted((a, b) => len(b) - len(a))
2336
2970
  .map(annotation => {
2337
- // annotation positions are 1-based and inclusive. Both ends count
2338
- // the visible columns in front of a global column, so endCol is
2339
- // the exclusive column after the last residue's own column --
2340
- // the band stops there rather than stretching across a following
2341
- // gap run -- and a residue whose column is itself hidden
2342
- // collapses onto the neighbouring boundary instead of dropping
2343
- // the band. A band whose every column is hidden spans nothing
2344
- // and is left out, as is one naming a row the alignment lacks.
2971
+ // annotation positions are 1-based and inclusive. endCol is
2972
+ // exclusive, one past the last residue's column, so the band does
2973
+ // not extend over a following gap run. An endpoint in a hidden
2974
+ // column moves to the neighboring boundary. Bands with no visible
2975
+ // columns, or naming a missing row, are dropped.
2345
2976
  const start = self.seqPosToGlobalCol(name, annotation.start - 1);
2346
2977
  const end = self.seqPosToGlobalCol(name, annotation.end - 1);
2347
2978
  if (start === undefined || end === undefined) {
@@ -2354,11 +2985,10 @@ function stateModelFactory() {
2354
2985
  : undefined;
2355
2986
  })
2356
2987
  .filter(notEmpty)
2357
- // numbered after the drop, so a band that resolved to nothing does
2358
- // not leave an empty sub-row behind it
2988
+ // numbered after filtering, so a dropped band leaves no gap
2359
2989
  .map((band, stackIndex) => ({ ...band, stackIndex }));
2360
2990
  if (rowBands.length > 0) {
2361
- bands.set(name, rowBands);
2991
+ bands.set(name, packDomainLanes(rowBands));
2362
2992
  }
2363
2993
  }
2364
2994
  return bands;
@@ -2388,9 +3018,6 @@ function stateModelFactory() {
2388
3018
  const hits = (this.domainBands.get(name) ?? [])
2389
3019
  .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
2390
3020
  .map(b => b.annotation);
2391
- // the shared empty array, so moving the mouse across an alignment with
2392
- // no annotations does not hand every canvas block a new value to
2393
- // re-render on
2394
3021
  return hits.length > 0 ? hits : noDomains;
2395
3022
  },
2396
3023
  /**
@@ -2407,8 +3034,7 @@ function stateModelFactory() {
2407
3034
  * #getter
2408
3035
  * row indices highlighted by the current tree hover (a hovered internal
2409
3036
  * node highlights every tip below it). Shared by the tree and MSA overlay
2410
- * canvases so they cannot disagree, and resolved through the memoized
2411
- * name->index map rather than rebuilding a lookup on each mouse move.
3037
+ * canvases, via the memoized name->index map.
2412
3038
  */
2413
3039
  get hoveredRowIndices() {
2414
3040
  const { hoveredTreeNode, rowNamesSet } = self;
@@ -2421,8 +3047,8 @@ function stateModelFactory() {
2421
3047
  /**
2422
3048
  * #getter
2423
3049
  * contiguous runs of `highlightedColumns`, so a run of highlighted columns
2424
- * draws as one bordered band. Computed here because the overlay canvas
2425
- * redraws on every mouse move while the highlight itself rarely changes.
3050
+ * draws as one bordered band. Memoized because the overlay canvas redraws
3051
+ * on every mouse move.
2426
3052
  */
2427
3053
  get highlightedColumnRuns() {
2428
3054
  const { highlightedColumns } = self;
@@ -2446,13 +3072,8 @@ function stateModelFactory() {
2446
3072
  * is dropped. Row names that match no row are ignored.
2447
3073
  */
2448
3074
  get resolvedHighlights() {
2449
- const { blanks, rowNamesSet, transientHighlights } = self;
2450
- const toVisible = (globalCol) => {
2451
- const visible = self.globalColToVisibleCol(globalCol);
2452
- return visible ?? visibleColsBefore(blanks, globalCol);
2453
- };
2454
- // the document's own highlights first, then what each owner is showing
2455
- // right now, so a hover draws over a persisted band rather than under it
3075
+ const { rowNamesSet, transientHighlights } = self;
3076
+ // persisted highlights first, so transient ones draw on top
2456
3077
  const all = [
2457
3078
  ...self.highlights,
2458
3079
  ...Object.values(transientHighlights).flat(),
@@ -2468,70 +3089,221 @@ function stateModelFactory() {
2468
3089
  if (start === undefined || end === undefined) {
2469
3090
  return [];
2470
3091
  }
2471
- let startGlobal = start - 1;
2472
- let endGlobal = end - 1;
2473
- if (row !== undefined) {
2474
- const rowStart = self.seqPosToGlobalCol(row, start - 1);
2475
- const rowEnd = self.seqPosToGlobalCol(row, end - 1);
2476
- if (rowStart === undefined || rowEnd === undefined) {
2477
- return [];
3092
+ const span = self.visibleSpan({ row, start, end });
3093
+ return span ? [{ ...base, ...span, rowIndices: [] }] : [];
3094
+ });
3095
+ },
3096
+ /**
3097
+ * #method
3098
+ * per-column summary statistics: consensus residue and its identity
3099
+ * fraction, both conservation scores, gap fraction, and the sorted non-gap
3100
+ * residue distribution. undefined past the end of the alignment or for an
3101
+ * all-gap column.
3102
+ */
3103
+ columnStatsAt(col) {
3104
+ const { colStats, conservation, propertyConservation } = self;
3105
+ return columnStats({
3106
+ col,
3107
+ colStats,
3108
+ conservation,
3109
+ propertyConservation,
3110
+ });
3111
+ },
3112
+ /**
3113
+ * #method
3114
+ */
3115
+ getRowData(name) {
3116
+ return {
3117
+ data: self.MSA?.getRowData(name),
3118
+ rowData: self.rowDataOf(name),
3119
+ };
3120
+ },
3121
+ /**
3122
+ * #getter
3123
+ * each encoding with its scale resolved against the values its field
3124
+ * takes: a feature channel reads them across the features drawn, every
3125
+ * other channel across the row table. Resolved once per change of that
3126
+ * table or the encodings, never per row per frame.
3127
+ */
3128
+ get resolvedEncodings() {
3129
+ const rows = Object.values(self.rowData);
3130
+ const features = self.filteredAnnotations;
3131
+ return self.encodings.map(encoding => ({
3132
+ ...encoding,
3133
+ ...resolveScale(encoding.scale, featureChannels.has(encoding.channel)
3134
+ ? features
3135
+ .map(a => featureField(a, encoding.field))
3136
+ .filter(notEmpty)
3137
+ : rows.map(row => row?.[encoding.field]).filter(notEmpty)),
3138
+ }));
3139
+ },
3140
+ /**
3141
+ * #getter
3142
+ * each row panel with its scale resolved against the values its field
3143
+ * takes across the row table, giving the color per row name, the pixel
3144
+ * column it draws in, and the entries its legend lists. Resolved once
3145
+ * per change of that table or the panels, never per block per frame.
3146
+ */
3147
+ get resolvedRowPanels() {
3148
+ const rows = Object.entries(self.rowData);
3149
+ const { fillPalette, featureFillEncoding, featureLabels } = this;
3150
+ const annotations = self.filteredAnnotations;
3151
+ let offsetX = 0;
3152
+ return self.rowPanels.map((panel, index) => {
3153
+ const width = rowPanelWidth(panel, self.rowHeight);
3154
+ const base = { id: `rowpanel-${index}`, width, offsetX };
3155
+ offsetX += width;
3156
+ if (panel.kind === 'features') {
3157
+ const color = panel.encoding?.color;
3158
+ const encoding = color
3159
+ ? resolveFeatureScale(color.field, color.scale, annotations)
3160
+ : featureFillEncoding;
3161
+ const label = panel.encoding?.label;
3162
+ const align = panel.transform?.find(t => t.type === 'align');
3163
+ return {
3164
+ ...base,
3165
+ kind: panel.kind,
3166
+ x: panel.x,
3167
+ header: panel.header ?? '',
3168
+ field: encoding?.field,
3169
+ legendTitle: encoding?.field,
3170
+ spans: featurePanelSpans({
3171
+ panel,
3172
+ width,
3173
+ colWidth: self.colWidth,
3174
+ domainBands: this.domainBands,
3175
+ annotationsByRow: self.annotationsByRow,
3176
+ shifts: align
3177
+ ? this.featureAlignShifts.get(align.on)
3178
+ : undefined,
3179
+ }),
3180
+ colors: featureColorMap(annotations, encoding, fillPalette),
3181
+ labels: label
3182
+ ? featureLabelMap(annotations, label)
3183
+ : featureLabels,
3184
+ legend: encoding?.legend ??
3185
+ this.visibleDomainTypes.map(d => ({
3186
+ id: d.accession,
3187
+ label: d.name,
3188
+ color: fillPalette[d.accession],
3189
+ })),
3190
+ };
3191
+ }
3192
+ const { colorOf, legend } = resolveScale(panel.scale, rows.map(([, row]) => row?.[panel.field]).filter(notEmpty));
3193
+ const colors = new Map();
3194
+ for (const [name, row] of rows) {
3195
+ const value = row?.[panel.field];
3196
+ const color = value === undefined ? undefined : colorOf(value);
3197
+ if (color) {
3198
+ colors.set(name, color);
2478
3199
  }
2479
- startGlobal = rowStart;
2480
- endGlobal = rowEnd;
2481
3200
  }
2482
- const startCol = toVisible(startGlobal);
2483
- const endVisible = self.globalColToVisibleCol(endGlobal);
2484
- const endCol = endVisible ?? visibleColsBefore(blanks, endGlobal) - 1;
2485
- return startCol <= endCol
2486
- ? [{ ...base, startCol, endCol, rowIndices: [] }]
2487
- : [];
3201
+ return {
3202
+ ...base,
3203
+ kind: panel.kind,
3204
+ field: panel.field,
3205
+ header: panel.header ?? panel.field,
3206
+ legendTitle: panel.legend ?? panel.field,
3207
+ colors,
3208
+ legend,
3209
+ };
2488
3210
  });
2489
3211
  },
2490
3212
  /**
2491
3213
  * #getter
2492
- * per-column summary statistics for the hovered column: consensus residue
2493
- * and its identity fraction, conservation score, gap fraction, and the
2494
- * sorted non-gap residue distribution. undefined when nothing is hovered.
3214
+ * the shift an `align` transform gives each row, keyed by the feature
3215
+ * name it aligns on: the offset putting the first feature of that name
3216
+ * at zero. A row carrying no such feature is absent, and keeps its own
3217
+ * origin.
3218
+ */
3219
+ get featureAlignShifts() {
3220
+ const names = new Set(self.rowPanels.flatMap(panel => panel.kind === 'features' && panel.x === 'position'
3221
+ ? (panel.transform ?? [])
3222
+ .filter(t => t.type === 'align')
3223
+ .map(t => t.on)
3224
+ : []));
3225
+ return new Map([...names].map(on => [
3226
+ on,
3227
+ new Map(Object.entries(self.annotationsByRow)
3228
+ .map(([name, annotations]) => {
3229
+ const hit = annotations.find(a => featureName(a) === on);
3230
+ return hit ? [name, 1 - hit.start] : undefined;
3231
+ })
3232
+ .filter(notEmpty)),
3233
+ ]));
3234
+ },
3235
+ /**
3236
+ * #getter
3237
+ * the color the `tipLabel` channel gives each row, by row name. Undefined
3238
+ * when no encoding names the channel, which leaves the labels the theme's
3239
+ * text color.
2495
3240
  */
2496
- get mouseOverColumnStats() {
2497
- const { mouseCol } = self;
2498
- if (mouseCol === undefined) {
3241
+ get tipLabelColors() {
3242
+ const encoding = this.resolvedEncodings.find(e => e.channel === 'tipLabel');
3243
+ if (!encoding) {
2499
3244
  return undefined;
2500
3245
  }
2501
- const { colStats } = self;
2502
- if (mouseCol >= colStats.numColumns) {
2503
- return undefined;
3246
+ const colors = new Map();
3247
+ for (const [name, row] of Object.entries(self.rowData)) {
3248
+ const value = row?.[encoding.field];
3249
+ const color = value === undefined ? undefined : encoding.colorOf(value);
3250
+ if (color) {
3251
+ colors.set(name, color);
3252
+ }
2504
3253
  }
2505
- const total = colStats.total(mouseCol);
2506
- if (!total) {
3254
+ return colors;
3255
+ },
3256
+ /**
3257
+ * #getter
3258
+ * the wash the `rowTint` channel draws over each row, indexed by row, or
3259
+ * undefined when no encoding names the channel. The overlay draws these,
3260
+ * so a tint stays out of the raster tile cache and its keys.
3261
+ */
3262
+ get rowTints() {
3263
+ const encoding = this.resolvedEncodings.find(e => e.channel === 'rowTint');
3264
+ if (!encoding) {
2507
3265
  return undefined;
2508
3266
  }
2509
- const gaps = colStats.gapCount(mouseCol);
2510
- const distribution = colStats
2511
- .residueEntries(mouseCol)
2512
- .sort((a, b) => b[1] - a[1]);
2513
- const consensus = distribution[0];
2514
- return {
2515
- col: mouseCol,
2516
- total,
2517
- gaps,
2518
- gapFraction: gaps / total,
2519
- conservation: self.conservation[mouseCol] ?? 0,
2520
- propertyConservation: self.propertyConservation[mouseCol],
2521
- consensusLetter: consensus?.[0] ?? '',
2522
- consensusCount: consensus?.[1] ?? 0,
2523
- consensusFraction: consensus ? consensus[1] / total : 0,
2524
- distribution,
2525
- };
3267
+ return self.rowNames.map(name => {
3268
+ const value = self.rowDataOf(name)?.[encoding.field];
3269
+ const color = value === undefined ? undefined : encoding.colorOf(value);
3270
+ if (!color) {
3271
+ return undefined;
3272
+ }
3273
+ return withAlpha(color, rowTintAlpha);
3274
+ });
2526
3275
  },
2527
3276
  /**
2528
- * #method
3277
+ * #getter
3278
+ * the color the `branch` channel gives each tree edge, by the node id at
3279
+ * the edge's far end, or undefined when no encoding names the channel. A
3280
+ * node takes the field value its tips agree on, so a clade of one value
3281
+ * colors down from where it splits off, and a node whose tips disagree or
3282
+ * whose value has no color is absent and draws in the default color.
3283
+ *
3284
+ * The pass runs over the whole tree, never `root`, so a collapsed or
3285
+ * focused clade keeps the color the full tree gives it.
2529
3286
  */
2530
- getRowData(name) {
2531
- return {
2532
- data: self.MSA?.getRowData(name),
2533
- treeMetadata: self.treeMetadata[name],
2534
- };
3287
+ get branchColors() {
3288
+ const encoding = this.resolvedEncodings.find(e => e.channel === 'branch');
3289
+ if (!encoding) {
3290
+ return undefined;
3291
+ }
3292
+ const order = preorder(self.tree);
3293
+ const values = new Map();
3294
+ const colors = new Map();
3295
+ for (let i = order.length - 1; i >= 0; i--) {
3296
+ const node = order[i];
3297
+ const value = node.children.length > 0
3298
+ ? sharedValue(node.children.map(child => values.get(child)))
3299
+ : self.rowDataOf(node.name)?.[encoding.field];
3300
+ values.set(node, value);
3301
+ const color = value === undefined ? undefined : encoding.colorOf(value);
3302
+ if (color) {
3303
+ colors.set(node.id, color);
3304
+ }
3305
+ }
3306
+ return colors;
2535
3307
  },
2536
3308
  }))
2537
3309
  .actions(self => ({
@@ -2544,33 +3316,33 @@ function stateModelFactory() {
2544
3316
  /**
2545
3317
  * #action
2546
3318
  */
2547
- setConservationTrackHeight(arg) {
2548
- self.conservationTrackHeight = arg;
3319
+ setHideHeader(arg) {
3320
+ self.hideHeader = arg;
2549
3321
  },
2550
3322
  /**
2551
3323
  * #action
3324
+ * focus the subtree a click `y` pixels down the tree overview lands on.
3325
+ * A click inside the box already drawn there clears the focus, the way
3326
+ * clicking the focused branch again does.
2552
3327
  */
2553
- setColumnTrackHeight(id, height) {
2554
- self.columnTrackHeights = { ...self.columnTrackHeights, [id]: height };
3328
+ treeOverviewClick(y) {
3329
+ const hit = self.treeOverviewHit(y);
3330
+ const focus = self.treeOverviewFocusRows;
3331
+ const inside = !!focus && !!hit && hit.rows[0] >= focus[0] && hit.rows[1] <= focus[1];
3332
+ self.setShowOnly(inside || !hit ? undefined : hit.id);
2555
3333
  },
2556
3334
  /**
2557
3335
  * #action
3336
+ * resize every track sharing a `heightKey`; see `trackHeights`
2558
3337
  */
2559
- setSequenceLogoTrackHeight(arg) {
2560
- self.sequenceLogoTrackHeight = arg;
3338
+ setTrackHeight(heightKey, height) {
3339
+ self.trackHeights.set(heightKey, height);
2561
3340
  },
2562
3341
  /**
2563
3342
  * #action
2564
- */
2565
- setArcTrackHeight(arg) {
2566
- self.arcTrackHeight = arg;
2567
- },
2568
- /**
2569
- * #action
2570
- * Return to the import form: every property off `preservedOnReset`
2571
- * (data, filehandles, collapsed/showOnly, zoom, scroll, ...) goes back
2572
- * to its default, then the file-derived volatiles applySnapshot cannot
2573
- * reach are cleared by hand.
3343
+ * Return to the import form: reset every property not in
3344
+ * `preservedOnReset` to its default, then clear the file-derived
3345
+ * volatiles applySnapshot does not touch.
2574
3346
  */
2575
3347
  reset() {
2576
3348
  self.resetCount++;
@@ -2596,8 +3368,8 @@ function stateModelFactory() {
2596
3368
  },
2597
3369
  /**
2598
3370
  * #action
2599
- * draw this annotation type, or stop drawing it. Only the "stop" is
2600
- * recorded -- see `turnedOffFeatures`
3371
+ * show or hide an annotation type. Only hidden types are recorded; see
3372
+ * `turnedOffFeatures`
2601
3373
  */
2602
3374
  setFilter(accession, shown) {
2603
3375
  if (shown) {
@@ -2611,12 +3383,8 @@ function stateModelFactory() {
2611
3383
  * #action
2612
3384
  */
2613
3385
  fit() {
2614
- // Each direction's viewport depends on the other's result: fitting the
2615
- // rows while the columns still overflow measures against a height the
2616
- // minimap is taking, and fitting the columns while the rows still
2617
- // overflow measures against a width the vertical scrollbar is taking.
2618
- // A second pass measures against the geometry the first pass produced,
2619
- // which is the one the reader ends up looking at.
3386
+ // two passes: each direction's viewport depends on whether the other
3387
+ // overflows (the minimap takes height, the vertical scrollbar width)
2620
3388
  transaction(() => {
2621
3389
  for (let pass = 0; pass < 2; pass++) {
2622
3390
  this.fitHorizontally();
@@ -2633,29 +3401,67 @@ function stateModelFactory() {
2633
3401
  }
2634
3402
  self.scrollY = 0;
2635
3403
  },
3404
+ /**
3405
+ * #action
3406
+ * zoom and scroll so a span fills the alignment's width, in highlight
3407
+ * coordinates (see visibleSpan). Does nothing before the viewer knows
3408
+ * its width, or for a span that resolves to no visible column.
3409
+ */
3410
+ zoomToRegion(region) {
3411
+ const span = self.visibleSpan(region);
3412
+ if (!span || !self.viewInitialized) {
3413
+ return;
3414
+ }
3415
+ transaction(() => {
3416
+ self.colWidth = clamp(self.msaCanvasWidth / (span.endCol - span.startCol + 1), minColWidth, maxCellSize);
3417
+ self.scrollX = clamp(-span.startCol * self.colWidth, self.maxScrollX, 0);
3418
+ });
3419
+ },
2636
3420
  /**
2637
3421
  * #action
2638
3422
  */
2639
3423
  fitHorizontally() {
2640
3424
  if (self.numColumns > 0) {
2641
- // fitting to msaAreaWidth instead left the last ~20px of columns off
2642
- // the right edge -- and short of the width that shows a minimap, so
2643
- // nothing on screen said they were there
3425
+ // msaCanvasWidth excludes the vertical scrollbar's 20px
2644
3426
  self.colWidth = clamp(self.msaCanvasWidth / self.numColumns, minColWidth, maxCellSize);
2645
3427
  }
2646
3428
  self.scrollX = 0;
2647
3429
  },
2648
3430
  afterCreate() {
2649
- // seed the highlighted-columns overlay from the declarative property so
2650
- // a shared snapshot/URL opens with those columns highlighted (the
2651
- // volatile highlightedColumns can later be driven by genome-hover sync)
2652
3431
  if (self.highlightColumns?.length) {
2653
3432
  self.setHighlightedColumns(self.highlightColumns);
2654
3433
  }
2655
- // track the live device pixel ratio so canvas backing stores re-scale
2656
- // when the window moves between monitors or the browser zooms. The
2657
- // matchMedia query is pinned to the current ratio, so each change
2658
- // re-registers against the new one to keep tracking further moves.
3434
+ // The `collapse` and `focus` clade marks seed the collapsed list and
3435
+ // the subtree in focus, which the tree, `hideGapsEffective` and the
3436
+ // alignment all read. The tree arrives with the model for inline data
3437
+ // and later for a filehandle, so the seeding waits for it and then runs
3438
+ // once: expanding a seeded clade sticks, and the record collapses it
3439
+ // again only on reload. `dataInitialized` is true once the MSA alone
3440
+ // has loaded, when the tree is still the flat stub, so a tree
3441
+ // filehandle holds the seeding until its text lands.
3442
+ let cladesSeeded = false;
3443
+ addDisposer(self, autorun(() => {
3444
+ if (cladesSeeded ||
3445
+ !self.dataInitialized ||
3446
+ (self.treeFilehandle && !self.data.tree) ||
3447
+ self.clades.length === 0) {
3448
+ return;
3449
+ }
3450
+ cladesSeeded = true;
3451
+ for (const { mark, nodeId } of self.resolvedClades) {
3452
+ if (nodeId === undefined) {
3453
+ continue;
3454
+ }
3455
+ if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
3456
+ self.toggleCollapsed(nodeId);
3457
+ }
3458
+ else if (mark === 'focus') {
3459
+ self.setShowOnly(nodeId);
3460
+ }
3461
+ }
3462
+ }));
3463
+ // the matchMedia query is pinned to the current device pixel ratio, so
3464
+ // each change re-registers against the new one
2659
3465
  if (typeof window !== 'undefined' &&
2660
3466
  typeof window.matchMedia === 'function') {
2661
3467
  const query = () => window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
@@ -2675,25 +3481,17 @@ function stateModelFactory() {
2675
3481
  * Fetch a filehandle whenever it changes, and hand the text to
2676
3482
  * `onLoad`.
2677
3483
  *
2678
- * Every loader carries a generation guard: the filehandle can change
2679
- * mid-fetch (a second file picked while the first is still in flight),
2680
- * and the slower earlier request must not clobber the data, status, or
2681
- * loading flag belonging to the newer one. A superseded or cleared
2682
- * request is also aborted, rather than left downloading a file nothing
2683
- * is waiting for, and it gives back the status line it was writing --
2684
- * a reset() mid-download used to leave "Downloading file" and a Cancel
2685
- * button behind on the import form.
3484
+ * A generation guard keeps a slower superseded request from overwriting
3485
+ * the newer one's data, status or loading flag. A superseded or cleared
3486
+ * request is aborted and clears its status line.
2686
3487
  *
2687
- * `clearFilehandle` serves two purposes for the loaders that pass it.
2688
- * A local file has no URL to refetch from, so the handle is dropped
2689
- * once its bytes are in the model; and a fetch the user cancels drops
2690
- * it too, returning the view to the import form rather than leaving a
2691
- * stuck spinner.
3488
+ * `clearFilehandle` runs after a local file loads, since a blob has no
3489
+ * URL to refetch, and when the user cancels, which returns the view to
3490
+ * the import form.
2692
3491
  *
2693
- * `what` names the layer in a failure message. An optional layer
2694
- * (annotations, row metadata) that fails to load is a warning: it is
2695
- * not worth replacing an alignment the reader is looking at with an
2696
- * error screen over a decorative file.
3492
+ * `what` names the layer in a failure message. An `optional` layer
3493
+ * (annotations, row metadata) that fails adds a warning instead of an
3494
+ * error.
2697
3495
  */
2698
3496
  const loadOnFilehandleChange = ({ what, getFilehandle, onLoad, setLoading, clearFilehandle, optional = false, }) => {
2699
3497
  let generation = 0;
@@ -2772,8 +3570,7 @@ function stateModelFactory() {
2772
3570
  self.setTreeFilehandle(undefined);
2773
3571
  },
2774
3572
  });
2775
- // treeMetadata is decorative and has no import-form step of its own, so
2776
- // it keeps no loading flag and nothing to return to on cancel
3573
+ // treeMetadata has no import-form step, so no loading flag or cancel
2777
3574
  loadOnFilehandleChange({
2778
3575
  what: 'row metadata',
2779
3576
  optional: true,
@@ -2782,11 +3579,8 @@ function stateModelFactory() {
2782
3579
  self.setTreeMetadata(text);
2783
3580
  },
2784
3581
  });
2785
- // autorun parses inline gff text from data.gff. Text that goes away
2786
- // takes its annotations with it -- setData with a new alignment and no
2787
- // gff used to leave the previous file's annotations drawn over it --
2788
- // while annotations a host set directly are left alone, which is why
2789
- // this tracks what it applied instead of reading the current list
3582
+ // parses data.gff into annotations. Clearing the text clears only the
3583
+ // annotations this autorun applied, not ones a host set directly
2790
3584
  let appliedGFF = false;
2791
3585
  addDisposer(self, autorun(() => {
2792
3586
  const gffText = self.data.gff;
@@ -2796,8 +3590,6 @@ function stateModelFactory() {
2796
3590
  appliedGFF = true;
2797
3591
  }
2798
3592
  catch (e) {
2799
- // a malformed overlay is not worth replacing the alignment
2800
- // with an error screen
2801
3593
  console.error(e);
2802
3594
  self.addWarning(`The annotations did not parse: ${e}`);
2803
3595
  }
@@ -2807,9 +3599,17 @@ function stateModelFactory() {
2807
3599
  self.setAnnotations([]);
2808
3600
  }
2809
3601
  }));
2810
- // gffFilehandle carries overlay annotations. It loads into data.gff and
2811
- // the autorun above parses it, so there is one parse path whether the
2812
- // text arrived from a file or from a snapshot
3602
+ // a GFF whose first column names rows of some other alignment parses
3603
+ // without error and draws nothing
3604
+ addDisposer(self, autorun(() => {
3605
+ const { annotations, rowNamesSet } = self;
3606
+ if (annotations.length > 0 &&
3607
+ rowNamesSet.size > 0 &&
3608
+ !annotations.some(a => rowNamesSet.has(a.id))) {
3609
+ self.addWarning(`0 of ${annotations.length} annotations name a row in this alignment`);
3610
+ }
3611
+ }));
3612
+ // gffFilehandle loads into data.gff, which the autorun above parses
2813
3613
  loadOnFilehandleChange({
2814
3614
  what: 'annotations',
2815
3615
  optional: true,
@@ -2834,19 +3634,12 @@ function stateModelFactory() {
2834
3634
  self.setMSAFilehandle(undefined);
2835
3635
  },
2836
3636
  });
2837
- // Keep the parse chain warm: reading self.columns transitively holds
2838
- // self.MSA (parseMSA) computed alive, so it is parsed once per data
2839
- // change rather than re-parsed on every non-reactive access. Do not
2840
- // remove.
3637
+ // Keep computeds alive that are read outside reactions, so they are not
3638
+ // recomputed on every access: self.columns (and through it the
3639
+ // parseMSA result), and the column statistics that dynamic color
3640
+ // schemes and the mousemove-driven hover tooltip read.
2841
3641
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
2842
3642
  // xref problem https://github.com/GMOD/react-msaview/issues/75
2843
- //
2844
- // The column statistics are held for the same reason whenever something
2845
- // reads them off the reactive path: dynamic color schemes, and the hover
2846
- // tooltip, which is read from a mousemove handler. Without this the
2847
- // tooltip's cost depends on whether some visible track happens to be
2848
- // observing them, so closing the conservation track would silently turn
2849
- // every mouse move into a full-alignment recount.
2850
3643
  addDisposer(self, autorun(() => {
2851
3644
  if (self.colorSchemeName.includes('dynamic')) {
2852
3645
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -2864,25 +3657,20 @@ function stateModelFactory() {
2864
3657
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2865
3658
  self.columns;
2866
3659
  }));
2867
- // autorun: when autoTreeAreaWidth is set and no tree is drawn, shrink the
2868
- // tree area to fit the row labels rather than padding it to the fixed
2869
- // default. Gated on noTree/!drawTree so it never fights the treeWidth sync
2870
- // below (treeAreaWidth here depends only on labelsWidth, not treeWidth).
3660
+ // with autoTreeAreaWidth and no tree drawn, size the tree area to the
3661
+ // labels. Gated on noTree/!drawTree so it does not loop with the
3662
+ // treeWidth autorun below
2871
3663
  addDisposer(self, autorun(() => {
2872
3664
  if (self.autoTreeAreaWidth &&
2873
3665
  (self.noTree || !self.drawTree) &&
2874
3666
  self.labelsWidth) {
2875
- self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12);
3667
+ self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12 + self.cladeGutterWidth);
2876
3668
  }
2877
3669
  }));
2878
- // treeWidth trails the tree area, less whatever the labels take out of
2879
- // it. A width that arrived with the snapshot is left alone until the
2880
- // tree area itself moves: a host that opens a view with a narrow tree
2881
- // beside wide labels -- jbrowse-plugin-msaview does, at treeWidth 100
2882
- // in a 200px area -- had its value overwritten on the first frame, and
2883
- // a restored session came back re-derived rather than as it was left.
2884
- // A getter cannot do this: labelsWidth is measured off the leaves,
2885
- // which are laid out against treeWidth.
3670
+ // treeWidth follows the tree area less the labels. A non-default
3671
+ // snapshot treeWidth (jbrowse-plugin-msaview opens at 100 in a 200px
3672
+ // area) is kept until the tree area changes. Not a getter, because
3673
+ // labelsWidth is measured off leaves laid out against treeWidth.
2886
3674
  let pinnedAreaWidth = self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth;
2887
3675
  addDisposer(self, autorun(() => {
2888
3676
  const areaWidth = self.treeAreaWidth;
@@ -2891,14 +3679,17 @@ function stateModelFactory() {
2891
3679
  return;
2892
3680
  }
2893
3681
  pinnedAreaWidth = undefined;
2894
- self.setTreeWidth(Math.max(50, areaWidth - labelsWidth - 10 - self.marginLeft));
3682
+ self.setTreeWidth(Math.max(50, areaWidth -
3683
+ labelsWidth -
3684
+ 10 -
3685
+ self.marginLeft -
3686
+ self.cladeGutterWidth));
2895
3687
  }));
2896
3688
  },
2897
3689
  }))
2898
3690
  .postProcessSnapshot(({ data, columnTracks, ...rest }) => ({
2899
- // per-property defaults are stripped by the stripDefault helper; the only thing
2900
- // it can't express is this cross-field rule: drop inline tree/msa/metadata
2901
- // when a sibling filehandle can refetch them, keeping sessions/URLs small
3691
+ // stripDefault handles per-property defaults; this drops inline documents
3692
+ // whose filehandle can refetch them
2902
3693
  ...rest,
2903
3694
  ...smallColumnTracks(columnTracks),
2904
3695
  data: {