react-msaview 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) 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/colorSchemes.js +1 -2
  5. package/dist/colorSchemes.js.map +1 -1
  6. package/dist/columnStats.d.ts +28 -0
  7. package/dist/columnStats.js +34 -0
  8. package/dist/columnStats.js.map +1 -0
  9. package/dist/components/DragHandle.d.ts +3 -2
  10. package/dist/components/DragHandle.js +11 -5
  11. package/dist/components/DragHandle.js.map +1 -1
  12. package/dist/components/Loading.js.map +1 -1
  13. package/dist/components/MSAView.js +17 -6
  14. package/dist/components/MSAView.js.map +1 -1
  15. package/dist/components/MSAViewer.d.ts +64 -7
  16. package/dist/components/MSAViewer.js +128 -23
  17. package/dist/components/MSAViewer.js.map +1 -1
  18. package/dist/components/PortalTooltip.d.ts +11 -0
  19. package/dist/components/PortalTooltip.js +29 -0
  20. package/dist/components/PortalTooltip.js.map +1 -0
  21. package/dist/components/ResizeHandles.js +11 -29
  22. package/dist/components/ResizeHandles.js.map +1 -1
  23. package/dist/components/SequenceTextArea.js +3 -3
  24. package/dist/components/SequenceTextArea.js.map +1 -1
  25. package/dist/components/Track.js +41 -22
  26. package/dist/components/Track.js.map +1 -1
  27. package/dist/components/dialogs/ExportSVGDialog.js +5 -3
  28. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  29. package/dist/components/dialogs/TrackInfoDialog.js +1 -1
  30. package/dist/components/dialogs/TrackInfoDialog.js.map +1 -1
  31. package/dist/components/header/GappynessSlider.js +6 -11
  32. package/dist/components/header/GappynessSlider.js.map +1 -1
  33. package/dist/components/header/Header.js +3 -0
  34. package/dist/components/header/Header.js.map +1 -1
  35. package/dist/components/header/LoadWarnings.js +3 -4
  36. package/dist/components/header/LoadWarnings.js.map +1 -1
  37. package/dist/components/header/ScrollZoomIcons.d.ts +6 -0
  38. package/dist/components/header/ScrollZoomIcons.js +35 -0
  39. package/dist/components/header/ScrollZoomIcons.js.map +1 -0
  40. package/dist/components/header/TreeSettingsMenu.js +1 -3
  41. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  42. package/dist/components/header/UnshareableDataWarning.js +2 -4
  43. package/dist/components/header/UnshareableDataWarning.js.map +1 -1
  44. package/dist/components/header/ZoomControls.js +49 -6
  45. package/dist/components/header/ZoomControls.js.map +1 -1
  46. package/dist/components/header/getAnnotationsMenu.js +3 -6
  47. package/dist/components/header/getAnnotationsMenu.js.map +1 -1
  48. package/dist/components/header/settingsMenuItems.js +10 -7
  49. package/dist/components/header/settingsMenuItems.js.map +1 -1
  50. package/dist/components/import/util.js +1 -2
  51. package/dist/components/import/util.js.map +1 -1
  52. package/dist/components/minimap/Minimap.js +1 -2
  53. package/dist/components/minimap/Minimap.js.map +1 -1
  54. package/dist/components/msa/AnnotationLegend.js +27 -19
  55. package/dist/components/msa/AnnotationLegend.js.map +1 -1
  56. package/dist/components/msa/MSACanvas.js +1 -1
  57. package/dist/components/msa/MSACanvas.js.map +1 -1
  58. package/dist/components/msa/MSACanvasBlock.js +13 -15
  59. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  60. package/dist/components/msa/MSAMouseoverCanvas.js +2 -1
  61. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  62. package/dist/components/msa/domainBandCursor.d.ts +7 -11
  63. package/dist/components/msa/domainBandCursor.js +7 -11
  64. package/dist/components/msa/domainBandCursor.js.map +1 -1
  65. package/dist/components/msa/drawFeatureSpans.d.ts +53 -0
  66. package/dist/components/msa/drawFeatureSpans.js +78 -0
  67. package/dist/components/msa/drawFeatureSpans.js.map +1 -0
  68. package/dist/components/msa/legendRows.d.ts +8 -0
  69. package/dist/components/msa/legendRows.js +16 -0
  70. package/dist/components/msa/legendRows.js.map +1 -0
  71. package/dist/components/msa/msaRaster.d.ts +24 -31
  72. package/dist/components/msa/msaRaster.js +30 -39
  73. package/dist/components/msa/msaRaster.js.map +1 -1
  74. package/dist/components/msa/packDomainLanes.d.ts +20 -0
  75. package/dist/components/msa/packDomainLanes.js +33 -0
  76. package/dist/components/msa/packDomainLanes.js.map +1 -0
  77. package/dist/components/msa/renderBoxFeatureCanvasBlock.d.ts +3 -1
  78. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +51 -87
  79. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  80. package/dist/components/msa/renderHighlights.d.ts +15 -13
  81. package/dist/components/msa/renderHighlights.js +43 -20
  82. package/dist/components/msa/renderHighlights.js.map +1 -1
  83. package/dist/components/msa/renderMSABlock.js +5 -4
  84. package/dist/components/msa/renderMSABlock.js.map +1 -1
  85. package/dist/components/msa/tileColor.js +4 -7
  86. package/dist/components/msa/tileColor.js.map +1 -1
  87. package/dist/components/msa/useMsaBlockMouse.d.ts +5 -3
  88. package/dist/components/msa/useMsaBlockMouse.js +6 -6
  89. package/dist/components/msa/useMsaBlockMouse.js.map +1 -1
  90. package/dist/components/rowpanels/RowPanelBlock.d.ts +9 -0
  91. package/dist/components/rowpanels/RowPanelBlock.js +86 -0
  92. package/dist/components/rowpanels/RowPanelBlock.js.map +1 -0
  93. package/dist/components/rowpanels/RowPanelHeaders.d.ts +12 -0
  94. package/dist/components/rowpanels/RowPanelHeaders.js +38 -0
  95. package/dist/components/rowpanels/RowPanelHeaders.js.map +1 -0
  96. package/dist/components/{msa/ColumnStats.d.ts → rowpanels/RowPanels.d.ts} +2 -2
  97. package/dist/components/rowpanels/RowPanels.js +32 -0
  98. package/dist/components/rowpanels/RowPanels.js.map +1 -0
  99. package/dist/components/rowpanels/headerLayout.d.ts +1 -0
  100. package/dist/components/rowpanels/headerLayout.js +5 -0
  101. package/dist/components/rowpanels/headerLayout.js.map +1 -0
  102. package/dist/components/rowpanels/renderFeaturePanel.d.ts +21 -0
  103. package/dist/components/rowpanels/renderFeaturePanel.js +37 -0
  104. package/dist/components/rowpanels/renderFeaturePanel.js.map +1 -0
  105. package/dist/components/rowpanels/renderRowPanel.d.ts +23 -0
  106. package/dist/components/rowpanels/renderRowPanel.js +22 -0
  107. package/dist/components/rowpanels/renderRowPanel.js.map +1 -0
  108. package/dist/components/rowpanels/renderStrip.d.ts +20 -0
  109. package/dist/components/rowpanels/renderStrip.js +26 -0
  110. package/dist/components/rowpanels/renderStrip.js.map +1 -0
  111. package/dist/components/tracks/TrackBlocks.js +3 -5
  112. package/dist/components/tracks/TrackBlocks.js.map +1 -1
  113. package/dist/components/tracks/TrackTooltipContent.d.ts +15 -0
  114. package/dist/components/tracks/TrackTooltipContent.js +109 -0
  115. package/dist/components/tracks/TrackTooltipContent.js.map +1 -0
  116. package/dist/components/tracks/drawTracks.d.ts +4 -4
  117. package/dist/components/tracks/drawTracks.js +17 -34
  118. package/dist/components/tracks/drawTracks.js.map +1 -1
  119. package/dist/components/tracks/useTrackHover.d.ts +22 -0
  120. package/dist/components/tracks/useTrackHover.js +30 -0
  121. package/dist/components/tracks/useTrackHover.js.map +1 -0
  122. package/dist/components/tree/CladeLabels.d.ts +12 -0
  123. package/dist/components/tree/CladeLabels.js +48 -0
  124. package/dist/components/tree/CladeLabels.js.map +1 -0
  125. package/dist/components/tree/TreeCanvas.js +4 -1
  126. package/dist/components/tree/TreeCanvas.js.map +1 -1
  127. package/dist/components/tree/TreeCanvasBlock.js +4 -21
  128. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  129. package/dist/components/tree/TreeOverview.d.ts +14 -0
  130. package/dist/components/tree/TreeOverview.js +75 -0
  131. package/dist/components/tree/TreeOverview.js.map +1 -0
  132. package/dist/components/tree/TreeRuler.js +9 -13
  133. package/dist/components/tree/TreeRuler.js.map +1 -1
  134. package/dist/components/tree/cladeBrackets.d.ts +25 -0
  135. package/dist/components/tree/cladeBrackets.js +51 -0
  136. package/dist/components/tree/cladeBrackets.js.map +1 -0
  137. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js +2 -2
  138. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js.map +1 -1
  139. package/dist/components/tree/renderTreeCanvas.js +158 -24
  140. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  141. package/dist/components/tree/renderTreeOverview.d.ts +52 -0
  142. package/dist/components/tree/renderTreeOverview.js +138 -0
  143. package/dist/components/tree/renderTreeOverview.js.map +1 -0
  144. package/dist/components/tree/useTreeHover.js +2 -4
  145. package/dist/components/tree/useTreeHover.js.map +1 -1
  146. package/dist/constants.d.ts +15 -0
  147. package/dist/constants.js +48 -22
  148. package/dist/constants.js.map +1 -1
  149. package/dist/createPaletteMap.d.ts +7 -6
  150. package/dist/createPaletteMap.js +10 -9
  151. package/dist/createPaletteMap.js.map +1 -1
  152. package/dist/element.d.ts +13 -0
  153. package/dist/element.js +269 -0
  154. package/dist/element.js.map +1 -0
  155. package/dist/featureFields.d.ts +11 -0
  156. package/dist/featureFields.js +17 -0
  157. package/dist/featureFields.js.map +1 -0
  158. package/dist/flatToTree.js +1 -2
  159. package/dist/flatToTree.js.map +1 -1
  160. package/dist/ggplotPalettes.d.ts +10 -0
  161. package/dist/ggplotPalettes.js +58 -0
  162. package/dist/ggplotPalettes.js.map +1 -1
  163. package/dist/hierarchy.d.ts +27 -0
  164. package/dist/hierarchy.js +66 -9
  165. package/dist/hierarchy.js.map +1 -1
  166. package/dist/index.d.ts +9 -1
  167. package/dist/index.js +5 -3
  168. package/dist/index.js.map +1 -1
  169. package/dist/model/DataModel.js +2 -5
  170. package/dist/model/DataModel.js.map +1 -1
  171. package/dist/model/msaModel.d.ts +3 -2
  172. package/dist/model/msaModel.js +3 -2
  173. package/dist/model/msaModel.js.map +1 -1
  174. package/dist/model/treeModel.d.ts +30 -0
  175. package/dist/model/treeModel.js +37 -1
  176. package/dist/model/treeModel.js.map +1 -1
  177. package/dist/model.d.ts +531 -275
  178. package/dist/model.js +1295 -547
  179. package/dist/model.js.map +1 -1
  180. package/dist/mount.d.ts +11 -0
  181. package/dist/mount.js +22 -0
  182. package/dist/mount.js.map +1 -0
  183. package/dist/neighborJoining.js +8 -32
  184. package/dist/neighborJoining.js.map +1 -1
  185. package/dist/renderToStaticMarkup.d.ts +10 -19
  186. package/dist/renderToStaticMarkup.js +13 -24
  187. package/dist/renderToStaticMarkup.js.map +1 -1
  188. package/dist/renderToSvg.js +180 -56
  189. package/dist/renderToSvg.js.map +1 -1
  190. package/dist/rowCoordinateCalculations.d.ts +4 -6
  191. package/dist/rowCoordinateCalculations.js +4 -6
  192. package/dist/rowCoordinateCalculations.js.map +1 -1
  193. package/dist/scales.d.ts +22 -0
  194. package/dist/scales.js +30 -0
  195. package/dist/scales.js.map +1 -0
  196. package/dist/seqPosToGlobalCol.d.ts +2 -3
  197. package/dist/seqPosToGlobalCol.js +2 -3
  198. package/dist/seqPosToGlobalCol.js.map +1 -1
  199. package/dist/sequenceLogo.d.ts +6 -11
  200. package/dist/sequenceLogo.js +6 -11
  201. package/dist/sequenceLogo.js.map +1 -1
  202. package/dist/stripDefault.d.ts +7 -14
  203. package/dist/stripDefault.js +7 -14
  204. package/dist/stripDefault.js.map +1 -1
  205. package/dist/svgTestUtil.d.ts +172 -52
  206. package/dist/types.d.ts +219 -21
  207. package/dist/umd.d.ts +2 -0
  208. package/dist/umd.js +4 -2
  209. package/dist/umd.js.map +1 -1
  210. package/dist/useCanvasAutorun.d.ts +5 -10
  211. package/dist/useCanvasAutorun.js +5 -10
  212. package/dist/useCanvasAutorun.js.map +1 -1
  213. package/dist/useMsaSvgFigure.d.ts +20 -0
  214. package/dist/useMsaSvgFigure.js +46 -0
  215. package/dist/useMsaSvgFigure.js.map +1 -0
  216. package/dist/util.d.ts +5 -6
  217. package/dist/util.js +13 -6
  218. package/dist/util.js.map +1 -1
  219. package/dist/version.d.ts +1 -1
  220. package/dist/version.js +1 -1
  221. package/package.json +19 -12
  222. package/src/colorSchemes.ts +1 -2
  223. package/src/columnStats.ts +58 -0
  224. package/src/components/DragHandle.tsx +18 -7
  225. package/src/components/Loading.tsx +2 -6
  226. package/src/components/MSAView.tsx +19 -5
  227. package/src/components/MSAViewer.tsx +240 -28
  228. package/src/components/PortalTooltip.tsx +47 -0
  229. package/src/components/ResizeHandles.tsx +17 -32
  230. package/src/components/SequenceTextArea.tsx +3 -3
  231. package/src/components/Track.tsx +55 -18
  232. package/src/components/dialogs/ExportSVGDialog.tsx +7 -2
  233. package/src/components/dialogs/TrackInfoDialog.tsx +1 -1
  234. package/src/components/header/GappynessSlider.tsx +6 -11
  235. package/src/components/header/Header.tsx +6 -0
  236. package/src/components/header/LoadWarnings.tsx +3 -4
  237. package/src/components/header/ScrollZoomIcons.tsx +52 -0
  238. package/src/components/header/TreeSettingsMenu.tsx +1 -3
  239. package/src/components/header/UnshareableDataWarning.tsx +2 -4
  240. package/src/components/header/ZoomControls.tsx +74 -14
  241. package/src/components/header/getAnnotationsMenu.ts +3 -6
  242. package/src/components/header/settingsMenuItems.ts +11 -6
  243. package/src/components/import/ImportFormExamples.tsx +2 -2
  244. package/src/components/import/util.ts +1 -2
  245. package/src/components/minimap/Minimap.tsx +1 -2
  246. package/src/components/msa/AnnotationLegend.tsx +48 -38
  247. package/src/components/msa/MSACanvas.tsx +3 -5
  248. package/src/components/msa/MSACanvasBlock.tsx +13 -16
  249. package/src/components/msa/MSAMouseoverCanvas.tsx +2 -1
  250. package/src/components/msa/domainBandCursor.ts +7 -11
  251. package/src/components/msa/drawFeatureSpans.ts +150 -0
  252. package/src/components/msa/legendRows.ts +24 -0
  253. package/src/components/msa/msaRaster.ts +30 -39
  254. package/src/components/msa/packDomainLanes.ts +36 -0
  255. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +69 -123
  256. package/src/components/msa/renderHighlights.ts +53 -20
  257. package/src/components/msa/renderMSABlock.ts +5 -3
  258. package/src/components/msa/tileColor.ts +4 -7
  259. package/src/components/msa/useMsaBlockMouse.ts +6 -6
  260. package/src/components/rowpanels/RowPanelBlock.tsx +139 -0
  261. package/src/components/rowpanels/RowPanelHeaders.tsx +57 -0
  262. package/src/components/rowpanels/RowPanels.tsx +77 -0
  263. package/src/components/rowpanels/headerLayout.ts +5 -0
  264. package/src/components/rowpanels/renderFeaturePanel.ts +66 -0
  265. package/src/components/rowpanels/renderRowPanel.ts +41 -0
  266. package/src/components/rowpanels/renderStrip.ts +46 -0
  267. package/src/components/tracks/TrackBlocks.tsx +3 -5
  268. package/src/components/tracks/TrackTooltipContent.tsx +156 -0
  269. package/src/components/tracks/drawTracks.ts +17 -34
  270. package/src/components/tracks/useTrackHover.ts +42 -0
  271. package/src/components/tree/CladeLabels.tsx +77 -0
  272. package/src/components/tree/TreeCanvas.tsx +2 -0
  273. package/src/components/tree/TreeCanvasBlock.tsx +8 -32
  274. package/src/components/tree/TreeOverview.tsx +106 -0
  275. package/src/components/tree/TreeRuler.tsx +11 -22
  276. package/src/components/tree/cladeBrackets.ts +73 -0
  277. package/src/components/tree/dialogs/TreeNodeInfoDialog.tsx +2 -2
  278. package/src/components/tree/renderTreeCanvas.ts +240 -20
  279. package/src/components/tree/renderTreeOverview.ts +212 -0
  280. package/src/components/tree/useTreeHover.ts +2 -4
  281. package/src/constants.ts +56 -22
  282. package/src/createPaletteMap.ts +10 -9
  283. package/src/element.ts +321 -0
  284. package/src/featureFields.ts +19 -0
  285. package/src/flatToTree.ts +1 -2
  286. package/src/ggplotPalettes.ts +60 -0
  287. package/src/hierarchy.ts +77 -9
  288. package/src/index.ts +31 -7
  289. package/src/model/DataModel.ts +2 -5
  290. package/src/model/msaModel.ts +3 -2
  291. package/src/model/treeModel.ts +42 -0
  292. package/src/model.ts +1523 -560
  293. package/src/mount.tsx +32 -0
  294. package/src/neighborJoining.ts +8 -32
  295. package/src/renderToStaticMarkup.ts +13 -24
  296. package/src/renderToSvg.tsx +335 -68
  297. package/src/rowCoordinateCalculations.ts +4 -6
  298. package/src/scales.ts +47 -0
  299. package/src/seqPosToGlobalCol.ts +2 -3
  300. package/src/sequenceLogo.ts +6 -11
  301. package/src/stripDefault.ts +7 -14
  302. package/src/types.ts +258 -45
  303. package/src/umd.ts +4 -2
  304. package/src/useCanvasAutorun.ts +5 -10
  305. package/src/useMsaSvgFigure.ts +72 -0
  306. package/src/util.ts +15 -6
  307. package/src/version.ts +1 -1
  308. package/dist/components/msa/ColumnStats.js +0 -42
  309. package/dist/components/msa/ColumnStats.js.map +0 -1
  310. package/src/components/msa/ColumnStats.tsx +0 -54
package/src/model.ts CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  isAlive,
10
10
  types,
11
11
  } from '@jbrowse/mobx-state-tree'
12
- import { colord } from 'colord'
13
12
  import { autorun, transaction } from 'mobx'
14
13
  import {
15
14
  generateNodeIds,
@@ -25,19 +24,28 @@ import { calculateBlocks } from './calculateBlocks.ts'
25
24
  import { clustalXColumnColors } from './clustalX.ts'
26
25
  import colorSchemes from './colorSchemes.ts'
27
26
  import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.ts'
27
+ import { columnStats } from './columnStats.ts'
28
+ import { packDomainLanes } from './components/msa/packDomainLanes.ts'
29
+ import { visibleColRange } from './components/msa/visibleColRange.ts'
28
30
  import TrackBlocks from './components/tracks/TrackBlocks.tsx'
31
+ import { cladeGutterWidth } from './components/tree/cladeBrackets.ts'
32
+ import { scaleBarLength } from './components/tree/scaleBar.ts'
29
33
  import {
34
+ cladeHighlightAlpha,
35
+ cladeHighlightColor,
30
36
  defaultAllowedGappyness,
31
37
  defaultColWidth,
32
38
  defaultColorSchemeName,
33
39
  defaultCurrentAlignment,
34
40
  defaultDrawMsaLetters,
41
+ defaultFeaturePanelWidth,
35
42
  defaultHeight,
36
43
  defaultHideGaps,
37
44
  defaultRowHeight,
38
45
  defaultScrollX,
39
46
  defaultScrollY,
40
47
  defaultScrollZoom,
48
+ defaultScrollZoomAxis,
41
49
  defaultShowDomainLegend,
42
50
  defaultShowDomains,
43
51
  defaultSubFeatureRows,
@@ -50,11 +58,16 @@ import {
50
58
  minLetterColWidth,
51
59
  minLetterRowHeight,
52
60
  minRowHeight,
61
+ rowPanelHeaderHeight,
62
+ rowTintAlpha,
63
+ scrollZoomAxes,
53
64
  segmentFeatureTypes,
54
65
  segmentShades,
66
+ treeScaleBarHeight,
55
67
  } from './constants.ts'
56
68
  import { createPaletteMap } from './createPaletteMap.ts'
57
69
  import { exportFileName } from './exportFileName.ts'
70
+ import { featureField, featureName } from './featureFields.ts'
58
71
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
59
72
  import { flatToTree } from './flatToTree.ts'
60
73
  import {
@@ -65,8 +78,11 @@ import {
65
78
  find,
66
79
  forEachDescendant,
67
80
  hierarchy,
81
+ leafIndex,
68
82
  leaves,
69
83
  maxLength,
84
+ mrca,
85
+ nodeCoveringRows,
70
86
  setBrLength,
71
87
  sort,
72
88
  sum as hierarchySum,
@@ -85,6 +101,7 @@ import {
85
101
  visibleColToGlobalCol,
86
102
  visibleColsBefore,
87
103
  } from './rowCoordinateCalculations.ts'
104
+ import { resolveScale } from './scales.ts'
88
105
  import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
89
106
  import { maxBitsFor } from './sequenceLogo.ts'
90
107
  import { stripDefault } from './stripDefault.ts'
@@ -92,30 +109,50 @@ import {
92
109
  computeRowInsertions,
93
110
  dropBlanks,
94
111
  len,
112
+ outlineColor,
95
113
  skipBlanks,
96
114
  transform,
115
+ withAlpha,
97
116
  } from './util.ts'
98
117
  import { saveAs } from './vendor/fileSaver.ts'
99
118
  import { parseWuss } from './wuss.ts'
100
119
 
120
+ import type { ColumnStats } from './columnStats.ts'
121
+ import type { ScrollZoomAxis } from './constants.ts'
101
122
  import type { HierarchyNode } from './hierarchy.ts'
102
123
  import type { ExportSvgOptions } from './renderToSvg.tsx'
124
+ import type { ScaleSpec } from './scales.ts'
103
125
  import type {
104
126
  Annotation,
105
127
  Arc,
106
128
  BasicTrack,
129
+ Cell,
130
+ Clade,
107
131
  ResidueMappingProblem,
108
132
  ColumnTrackSpec,
109
133
  DomainBand,
134
+ Encoding,
135
+ EncodingChannel,
110
136
  Highlight,
137
+ Legend,
138
+ LegendEntry,
111
139
  NodeWithIds,
112
140
  NodeWithIdsAndLength,
141
+ Region,
113
142
  ResidueMapping,
114
143
  ResidueSegment,
144
+ ResolvedClade,
145
+ ResolvedEncoding,
115
146
  ResolvedHighlight,
147
+ ResolvedRowPanel,
148
+ RowFeaturesSpec,
149
+ RowPanelSpan,
150
+ RowPanelSpec,
116
151
  RowResidue,
117
152
  StructureResidue,
153
+ TrackKind,
118
154
  UnshareableData,
155
+ Viewport,
119
156
  } from './types.ts'
120
157
  import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types'
121
158
  import type { Instance } from '@jbrowse/mobx-state-tree'
@@ -128,16 +165,195 @@ function parseTreeText(text: string) {
128
165
  return parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text)
129
166
  }
130
167
 
131
- // Tracks that start hidden. The sequence logo answers a narrower question than
132
- // conservation does and costs three times the vertical space, so it waits to be
133
- // asked for.
168
+ // The height each kind draws at before the user drags a divider. A text track
169
+ // is absent: it is one alignment row tall and follows rowHeight, so the zoom
170
+ // controls already size it.
171
+ export const defaultTrackHeights: Partial<Record<TrackKind, number>> = {
172
+ bar: 40,
173
+ // taller than a bar track: in a 40px stack of four residues each glyph is
174
+ // too short to identify. Twice the height of the tracks above it, so it
175
+ // starts hidden
176
+ logo: 80,
177
+ arc: 50,
178
+ ruler: 20,
179
+ }
180
+
181
+ // the kinds a divider resizes. The ruler is a fixed scale, and a text track
182
+ // follows the vertical zoom.
183
+ const resizableKinds = new Set<TrackKind>(['bar', 'logo', 'arc'])
184
+
134
185
  const defaultOffTracks = new Set(['sequence-logo', 'position-ruler'])
135
186
 
136
- // base-pair arcs: one color for the nested helices, one for a pseudoknot, whose
137
- // whole point is that it crosses them
187
+ // a data track carries its own height rather than its kind's, so its divider
188
+ // resizes it alone
189
+ const ownHeightKey = (id: string) => `own:${id}`
190
+
191
+ // base-pair arcs: one color for nested helices, one for pseudoknots, which cross
192
+ // them
138
193
  const HELIX_ARC = '#4e79a7'
139
194
  const PSEUDOKNOT_ARC = '#e15759'
140
195
 
196
+ // the fill of a span under a scale that gives its value no color, such as a
197
+ // gene a `map` over gene names leaves out
198
+ const UNSCALED_FEATURE = '#d9d9d9'
199
+
200
+ // the fraction of a span a following one may cover before it takes a lane of
201
+ // its own in a `features` panel
202
+ const laneOverlap = 0.1
203
+
204
+ // a row panel's own width, or a default: the row height, which makes a strip
205
+ // cell square, and a gene neighborhood's width for a features panel
206
+ function rowPanelWidth(panel: RowPanelSpec, rowHeight: number) {
207
+ return (
208
+ panel.width ??
209
+ (panel.kind === 'features' ? defaultFeaturePanelWidth : rowHeight)
210
+ )
211
+ }
212
+
213
+ // the fill and outline of every feature in a list: its own GFF `color=` first,
214
+ // then the scale an encoding resolves over one of its fields, then the
215
+ // accession palette
216
+ function featureColorMap(
217
+ annotations: Annotation[],
218
+ encoding:
219
+ | { field: string; colorOf: (value: string) => string | undefined }
220
+ | undefined,
221
+ fillPalette: Record<string, string>,
222
+ ) {
223
+ const strokes = new Map<string, string>()
224
+ const strokeOf = (fill: string) => {
225
+ const hit = strokes.get(fill)
226
+ if (hit !== undefined) {
227
+ return hit
228
+ }
229
+ const stroke = outlineColor(fill)
230
+ strokes.set(fill, stroke)
231
+ return stroke
232
+ }
233
+ return new Map(
234
+ annotations.map(annotation => {
235
+ const value = encoding
236
+ ? featureField(annotation, encoding.field)
237
+ : undefined
238
+ const fill =
239
+ annotation.color ??
240
+ (value === undefined ? undefined : encoding!.colorOf(value)) ??
241
+ (encoding ? UNSCALED_FEATURE : fillPalette[annotation.accession]!)
242
+ return [annotation, { fill, stroke: strokeOf(fill) }]
243
+ }),
244
+ )
245
+ }
246
+
247
+ // the text a label channel draws inside each span, for the features carrying
248
+ // the field it names
249
+ function featureLabelMap(annotations: Annotation[], field: string) {
250
+ const labels = new Map<Annotation, string>()
251
+ for (const annotation of annotations) {
252
+ const value = featureField(annotation, field)
253
+ if (value !== undefined) {
254
+ labels.set(annotation, value)
255
+ }
256
+ }
257
+ return labels
258
+ }
259
+
260
+ /**
261
+ * The spans a `features` panel draws, keyed by row name and measured in the
262
+ * panel's own pixels. `column` takes the bands the overlay draws, at the
263
+ * alignment's column width. `position` packs each row's features in its own
264
+ * residue positions, shifted by an `align` transform, and maps the extent they
265
+ * cover across every row onto the panel width, so a row with no alignment
266
+ * still has an x.
267
+ */
268
+ function featurePanelSpans({
269
+ panel,
270
+ width,
271
+ rowHeight,
272
+ colWidth,
273
+ domainBands,
274
+ annotationsByRow,
275
+ shifts,
276
+ }: {
277
+ panel: RowFeaturesSpec
278
+ width: number
279
+ rowHeight: number
280
+ colWidth: number
281
+ domainBands: Map<string, DomainBand[]>
282
+ annotationsByRow: Record<string, Annotation[]>
283
+ shifts: Map<string, number> | undefined
284
+ }): Map<string, RowPanelSpan[]> {
285
+ if (panel.x === 'column') {
286
+ return new Map(
287
+ [...domainBands].map(([name, bands]) => [
288
+ name,
289
+ bands.map(band => ({
290
+ ...band,
291
+ xStart: band.startCol * colWidth,
292
+ xEnd: band.endCol * colWidth,
293
+ })),
294
+ ]),
295
+ )
296
+ }
297
+ const shifted = Object.entries(annotationsByRow).map(
298
+ ([name, annotations]) => {
299
+ const shift = shifts?.get(name) ?? 0
300
+ return [
301
+ name,
302
+ annotations.map(annotation => ({
303
+ annotation,
304
+ start: annotation.start - 1 + shift,
305
+ end: annotation.end + shift,
306
+ })),
307
+ ] as const
308
+ },
309
+ )
310
+ let min = Infinity
311
+ let max = -Infinity
312
+ for (const [, features] of shifted) {
313
+ for (const { start, end } of features) {
314
+ min = Math.min(min, start)
315
+ max = Math.max(max, end)
316
+ }
317
+ }
318
+ // the arrowhead on the rightmost feature reaches a row height past its end,
319
+ // so the extent maps onto the panel less that much
320
+ const drawable = Math.max(1, width - rowHeight)
321
+ const scale = max > min ? drawable / (max - min) : 0
322
+ // a lane opens where a span covers more than a tenth of the one before it,
323
+ // which keeps the genes of an operon on one lane: adjacent genes commonly
324
+ // share a few bases, and a stop codon overlapping the next start reads as a
325
+ // second lane over the whole row
326
+ return new Map(
327
+ shifted.map(([name, features]) => [
328
+ name,
329
+ packDomainLanes(
330
+ features.map(({ annotation, start, end }) => ({
331
+ annotation,
332
+ xStart: (start - min) * scale,
333
+ xEnd: (end - min) * scale,
334
+ startCol: start,
335
+ endCol: end - (end - start) * laneOverlap,
336
+ })),
337
+ ),
338
+ ]),
339
+ )
340
+ }
341
+
342
+ // a scale over the values a field takes across the features drawn
343
+ function resolveFeatureScale(
344
+ field: string,
345
+ scale: ScaleSpec | undefined,
346
+ annotations: Annotation[],
347
+ ) {
348
+ return {
349
+ field,
350
+ ...resolveScale(
351
+ scale,
352
+ annotations.map(a => featureField(a, field)).filter(notEmpty),
353
+ ),
354
+ }
355
+ }
356
+
141
357
  // a data track over this size stays in the live model but leaves the snapshot,
142
358
  // the same rule DataModel applies to an inline document
143
359
  function columnTrackSizes(tracks?: readonly ColumnTrackSpec[]) {
@@ -154,17 +370,13 @@ function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
154
370
  * The snapshot properties reset() carries across a return to the import form:
155
371
  * display preferences and layout, nothing derived from the loaded file.
156
372
  *
157
- * reset() applies a default snapshot filtered to this list, so the list is the
158
- * whole decision: a property left off it resets to its default, a visible and
159
- * benign failure. The previous shape — a hand-maintained list of things to
160
- * CLEAR — failed in the dangerous direction: a forgotten property silently
161
- * carried the previous file's state into the next one, and because node ids
162
- * are path-derived (node-0-0-1), a stale `collapsed` or `showOnly` id matched
163
- * a real node in the new tree and folded it. Downstream composed properties
164
- * (e.g. the jbrowse plugin's) are not on the list, so they reset too.
373
+ * reset() applies a default snapshot filtered to this list, so any property
374
+ * left off it resets, including downstream composed ones (e.g. the jbrowse
375
+ * plugin's). Node ids are path-derived (node-0-0-1), so a carried-over
376
+ * `collapsed` or `showOnly` id would match a real node in the next tree and
377
+ * fold it.
165
378
  *
166
- * Exported for modelReset.test.ts, which checks that everything off this list
167
- * matches a freshly created model after reset().
379
+ * Exported for modelReset.test.ts.
168
380
  */
169
381
  export const preservedOnReset = new Set([
170
382
  'id',
@@ -172,6 +384,7 @@ export const preservedOnReset = new Set([
172
384
  'height',
173
385
  'drawMsaLetters',
174
386
  'scrollZoom',
387
+ 'scrollZoomAxis',
175
388
  'bgColor',
176
389
  'colorSchemeName',
177
390
  'showColumnStats',
@@ -182,21 +395,22 @@ export const preservedOnReset = new Set([
182
395
  'showBranchLen',
183
396
  'drawTree',
184
397
  'drawNodeBubbles',
398
+ 'drawNodeLabels',
399
+ 'showTreeOverview',
400
+ 'overviewHeight',
185
401
  'autoTreeAreaWidth',
186
402
  'turnedOffTracks',
403
+ 'trackHeights',
187
404
  'hideGaps',
188
405
  'allowedGappyness',
189
406
  'subFeatureRows',
190
407
  'showDomainLegend',
191
408
  ])
192
409
 
193
- // `turnedOffTracks` records the user's explicit choices only: an id is absent
194
- // until they touch that track, and then its value is whether the track is OFF.
195
- // Reading the default through this is what lets a track ship hidden without
196
- // writing an entry into every snapshot and shared URL.
197
- // A track the file itself supplies says whether it starts hidden, since only
198
- // the file knows how many of them there are: a Pfam seed carries a couple of
199
- // #=GR lines, an Rfam family one per row.
410
+ // `turnedOffTracks` holds only the user's explicit choices, so a hidden-by-default
411
+ // track writes nothing into the snapshot. A file-supplied track passes its own
412
+ // `defaultOff`, since the count varies by file: a Pfam seed has a couple of #=GR
413
+ // lines, an Rfam family one per row.
200
414
  function trackIsOff(
201
415
  turnedOffTracks: { get: (id: string) => boolean | undefined },
202
416
  id: string,
@@ -205,20 +419,144 @@ function trackIsOff(
205
419
  return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id))
206
420
  }
207
421
 
208
- // one array for every "nothing under the pointer", since a fresh [] is a fresh
209
- // value to every observer of it
422
+ // shared empty results, so observers don't see a fresh [] as a change
210
423
  const noDomains: Annotation[] = []
424
+ const noClades: ResolvedClade[] = []
425
+
426
+ /**
427
+ * The rows a clade covers and the node an `mrca` names, or undefined when the
428
+ * clade does not resolve: a tip name the tree does not have or has twice, or a
429
+ * leaf count `tips` disagrees with. `index` and `rowNamesSet` are the memoized
430
+ * passes over the tree.
431
+ */
432
+ function cladeRows(
433
+ clade: Clade,
434
+ root: HierarchyNode<NodeWithIds>,
435
+ index: Map<string, HierarchyNode<NodeWithIds> | undefined>,
436
+ rowNamesSet: Map<string, number>,
437
+ ): { rows: [number, number]; nodeId?: string } | undefined {
438
+ if (clade.range) {
439
+ const [a, b] = clade.range.map(name =>
440
+ index.get(name) ? rowNamesSet.get(name) : undefined,
441
+ )
442
+ if (a === undefined || b === undefined) {
443
+ return undefined
444
+ }
445
+ const rows: [number, number] = a <= b ? [a, b] : [b, a]
446
+ return rows[1] - rows[0] + 1 === clade.tips ? { rows } : undefined
447
+ }
448
+ const node = clade.mrca ? mrca(root, clade.mrca, index) : undefined
449
+ if (!node) {
450
+ return undefined
451
+ }
452
+ const tips = leaves(node)
453
+ if (tips.length !== clade.tips) {
454
+ return undefined
455
+ }
456
+ let first = Infinity
457
+ let last = -Infinity
458
+ for (const tip of tips) {
459
+ const row = rowNamesSet.get(tip.data.name)
460
+ if (row !== undefined) {
461
+ first = Math.min(first, row)
462
+ last = Math.max(last, row)
463
+ }
464
+ }
465
+ return first <= last
466
+ ? { rows: [first, last], nodeId: node.data.id }
467
+ : undefined
468
+ }
469
+
470
+ /**
471
+ * Every clade that resolves, with its fill color settled, against the leaf
472
+ * order `rowNamesSet` gives. The tree panel resolves against the displayed
473
+ * rows and the overview against its own, so both take the tree they draw. A
474
+ * `range` record names no node, so `collapse` and `focus`, which need one,
475
+ * drop it.
476
+ */
477
+ function resolveClades(
478
+ clades: Clade[],
479
+ root: HierarchyNode<NodeWithIds>,
480
+ rowNamesSet: Map<string, number>,
481
+ ): ResolvedClade[] {
482
+ const index = leafIndex(root)
483
+ return clades.flatMap(clade => {
484
+ const resolved = cladeRows(clade, root, index, rowNamesSet)
485
+ const seeding = clade.mark === 'collapse' || clade.mark === 'focus'
486
+ return resolved && !(seeding && resolved.nodeId === undefined)
487
+ ? [
488
+ {
489
+ ...resolved,
490
+ mark: clade.mark,
491
+ color: withAlpha(
492
+ clade.color ?? cladeHighlightColor,
493
+ cladeHighlightAlpha,
494
+ ),
495
+ markColor: clade.color,
496
+ label: clade.label,
497
+ },
498
+ ]
499
+ : []
500
+ })
501
+ }
502
+
503
+ /**
504
+ * The tree with the display transforms applied: leaf counts summed, children
505
+ * sorted by branch length, `showOnly` taken as the new root, and each
506
+ * `collapsed` clade folded. The overview builds the same tree without the
507
+ * focus, so it shows the focused subtree inside the whole.
508
+ */
509
+ function buildTreeRoot(
510
+ tree: NodeWithIds,
511
+ collapsed: readonly string[],
512
+ showOnly?: string,
513
+ ) {
514
+ let hier = hierarchy(tree, d => d.children)
515
+ hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1))
516
+ sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1))
517
+
518
+ if (showOnly) {
519
+ const res = find(hier, n => n.data.id === showOnly)
520
+ if (res) {
521
+ hier = res
522
+ }
523
+ }
211
524
 
212
- // seqPos -> column indexes, per row, hung off the parse the rows came from so
213
- // they are collected with it. A computed would rebuild every row's index on
214
- // each miss, and rebuild all of them again whenever it was read outside a
215
- // reactive context.
525
+ for (const collapsedId of collapsed) {
526
+ const node = find(hier, n => n.data.id === collapsedId)
527
+ if (node) {
528
+ if (node.children) {
529
+ collapse(node)
530
+ } else if (node.parent?.children) {
531
+ node.parent.children = node.parent.children.filter(
532
+ c => c.data.id !== collapsedId,
533
+ )
534
+ }
535
+ }
536
+ }
537
+
538
+ return hier
539
+ }
540
+
541
+ // the inclusive tip indices a node covers, from the row-space extent
542
+ // clusterLayout writes to xMin/xMax as tip centers
543
+ function tipRange(node: HierarchyNode): [number, number] {
544
+ return [Math.round(node.xMin! - 0.5), Math.round(node.xMax! - 0.5)]
545
+ }
546
+
547
+ // the channels reading the feature table; every other channel reads rowData
548
+ const featureChannels = new Set<EncodingChannel>([
549
+ 'featureFill',
550
+ 'featureLabel',
551
+ ])
552
+
553
+ // seqPos -> column indexes per row, keyed on the parse so they are garbage
554
+ // collected with it. A computed would rebuild every row's index when read
555
+ // outside a reactive context.
216
556
  const seqPosIndexCache = new WeakMap<object, Map<string, Int32Array>>()
217
557
 
218
- // A segment asserts a 1:1 run, so its two sides have to be the same length.
219
- // One that is not is malformed data, and the arithmetic below would answer
220
- // anyway -- with a residue that is off by however much the sides disagree. Skip
221
- // it, the same refusal an uncovered position gets.
558
+ // A segment is a 1:1 run. A segment whose sides differ in length is malformed,
559
+ // and the lookups treat it like an uncovered position.
222
560
  function sameLength(segment: ResidueSegment) {
223
561
  return (
224
562
  segment.rowEnd - segment.rowStart ===
@@ -226,11 +564,9 @@ function sameLength(segment: ResidueSegment) {
226
564
  )
227
565
  }
228
566
 
229
- // Does the content need a scrollbar? fit() divides the viewport by the row or
230
- // column count and multiplies it back, so an exact fit lands a fraction of a
231
- // pixel over -- enough for a `>` to answer yes and hand the reader a minimap or
232
- // a scrollbar for half a pixel of nothing, which then shrinks the viewport and
233
- // leaves a gap.
567
+ // fit() divides the viewport by the row or column count and multiplies back, so
568
+ // an exact fit can land a fraction of a pixel over. A plain `>` would then show
569
+ // a scrollbar that shrinks the viewport and leaves a gap.
234
570
  function overflows(content: number, viewport: number) {
235
571
  return content - viewport > 0.5
236
572
  }
@@ -239,6 +575,29 @@ function inRanges(ranges: [number, number][] | undefined, position: number) {
239
575
  return !!ranges?.some(([start, end]) => position >= start && position <= end)
240
576
  }
241
577
 
578
+ // the value every one of a node's children reports, or undefined where one of
579
+ // them has no value or they disagree
580
+ function sharedValue(values: (string | undefined)[]) {
581
+ const [first] = values
582
+ return first !== undefined && values.every(v => v === first)
583
+ ? first
584
+ : undefined
585
+ }
586
+
587
+ // preorder, so reversing the list puts every node after its descendants
588
+ function preorder(tree: NodeWithIds) {
589
+ const order: NodeWithIds[] = []
590
+ const stack = [tree]
591
+ while (stack.length > 0) {
592
+ const node = stack.pop()!
593
+ order.push(node)
594
+ for (const child of node.children) {
595
+ stack.push(child)
596
+ }
597
+ }
598
+ return order
599
+ }
600
+
242
601
  /**
243
602
  * #stateModel MsaView
244
603
  *
@@ -279,10 +638,8 @@ function stateModelFactory() {
279
638
  /**
280
639
  * #property
281
640
  * whether the domain legend is expanded. The legend floats over the
282
- * top-right of the alignment, so on a tall panel it covers real
283
- * residues -- persisting the state is what lets a reader collapse it
284
- * and keep it collapsed, and what lets a session or a figure open with
285
- * it already out of the way.
641
+ * top-right of the alignment and covers residues, so a session or
642
+ * figure can open with it collapsed.
286
643
  */
287
644
  showDomainLegend: stripDefault(types.boolean, defaultShowDomainLegend),
288
645
  /**
@@ -315,6 +672,15 @@ function stateModelFactory() {
315
672
  */
316
673
  scrollZoom: stripDefault(types.boolean, defaultScrollZoom),
317
674
 
675
+ /**
676
+ * #property
677
+ * which cell dimensions a wheel zoom scales, while `scrollZoom` is on
678
+ */
679
+ scrollZoomAxis: stripDefault(
680
+ types.enumeration('ScrollZoomAxis', [...scrollZoomAxes]),
681
+ defaultScrollZoomAxis,
682
+ ),
683
+
318
684
  /**
319
685
  * #property
320
686
  * height of the div containing the view, px
@@ -395,11 +761,20 @@ function stateModelFactory() {
395
761
  * hidden-by-default track adds nothing to the shared URL.
396
762
  */
397
763
  turnedOffTracks: stripDefault(types.map(types.boolean), {}),
764
+
398
765
  /**
399
766
  * #property
400
- * tracks supplied as data rather than computed from the alignment:
401
- * per-column values drawn as bars, or a per-column string drawn as a
402
- * text track. See docs/layers.md
767
+ * the height of every track one divider resizes, keyed by `heightKey`:
768
+ * the `kind` for the computed tracks, `own:<id>` for a data track. A
769
+ * key is absent until the user drags that divider, and
770
+ * `defaultTrackHeights` answers for it until then, so an untouched
771
+ * viewer adds nothing to the shared URL.
772
+ */
773
+ trackHeights: stripDefault(types.map(types.number), {}),
774
+ /**
775
+ * #property
776
+ * tracks supplied as data: per-column values drawn as bars, or a
777
+ * per-column string drawn as a text track. See docs/layers.md
403
778
  */
404
779
  columnTracks: stripDefault(
405
780
  types.array(types.frozen<ColumnTrackSpec>()),
@@ -408,11 +783,10 @@ function stateModelFactory() {
408
783
 
409
784
  /**
410
785
  * #property
411
- * which residue of which structure each row's residues are, as data.
412
- * The viewer cannot infer this -- matching a row to a structure by
413
- * sequence equality fails for a tagged construct, a truncation or a
414
- * subsequence row, and fails in the direction that looks like it
415
- * worked -- so it arrives computed. See docs/layers.md
786
+ * row-to-structure residue correspondence, computed outside the viewer
787
+ * (e.g. from SIFTS). Matching by sequence equality places a tagged
788
+ * construct, a truncation or a subsequence row on the wrong residue.
789
+ * See docs/layers.md
416
790
  */
417
791
  residueMappings: stripDefault(
418
792
  types.array(types.frozen<ResidueMapping>()),
@@ -433,10 +807,9 @@ function stateModelFactory() {
433
807
  /**
434
808
  * #property
435
809
  * the user's explicit hide choices per annotation accession, keyed by
436
- * accession with the value meaning "off", the same shape as
437
- * `turnedOffTracks`. An accession the user has never touched is absent
438
- * and drawn, so a file of two hundred domain types adds nothing to the
439
- * shared URL until someone filters one out
810
+ * accession with the value meaning "off", like `turnedOffTracks`. An
811
+ * untouched accession is absent and drawn, so the shared URL grows
812
+ * only with the user's filters
440
813
  */
441
814
  turnedOffFeatures: stripDefault(types.map(types.boolean), {}),
442
815
  /**
@@ -457,9 +830,34 @@ function stateModelFactory() {
457
830
  * labeled highlights in 1-based inclusive coordinates: a column span
458
831
  * `{start, end}`, a residue span `{row, start, end}` of a named row,
459
832
  * or a row set `{rows}`, each with an optional `label` and `color`.
460
- * Persists in the snapshot, so a computed answer travels in the URL.
833
+ * Persists in the snapshot and the URL.
461
834
  */
462
835
  highlights: stripDefault(types.array(types.frozen<Highlight>()), []),
836
+ /**
837
+ * #property
838
+ * clades of the tree with a mark drawn over them. `mrca` names tips
839
+ * whose common ancestor is the clade, or `range` its first and last
840
+ * tip in display order, and `tips` is the leaf count the producer
841
+ * measured. `mark` is `highlight`, `bracket`, `collapse` or `focus`.
842
+ * See docs/layers.md
843
+ */
844
+ clades: stripDefault(types.array(types.frozen<Clade>()), []),
845
+ /**
846
+ * #property
847
+ * what the viewer's own marks read from `rowData`:
848
+ * `{channel, field, scale?}` per channel, where `channel` is
849
+ * `tipLabel` or `rowTint`. See docs/layers.md
850
+ */
851
+ encodings: stripDefault(types.array(types.frozen<Encoding>()), []),
852
+ /**
853
+ * #property
854
+ * panels drawn between the tree and the alignment:
855
+ * `{kind: "strip", field, scale?, width?, header?}` colors one cell
856
+ * per row from a `rowData` field, and
857
+ * `{kind: "features", x, encoding?, transform?, width?, header?}`
858
+ * draws the spans the GFF carries. See docs/layers.md
859
+ */
860
+ rowPanels: stripDefault(types.array(types.frozen<RowPanelSpec>()), []),
463
861
  }),
464
862
  )
465
863
  .volatile(() => ({
@@ -467,15 +865,19 @@ function stateModelFactory() {
467
865
  * #volatile
468
866
  */
469
867
  headerHeight: 0,
868
+ /**
869
+ * #volatile
870
+ * leaves the toolbar out, for a host drawing its own controls. Kept out
871
+ * of the snapshot so a link opened in the full app shows the toolbar.
872
+ */
873
+ hideHeader: false,
470
874
  /**
471
875
  * #volatile
472
876
  */
473
877
  status: undefined as { msg: string; onCancel?: () => void } | undefined,
474
878
  /**
475
879
  * #volatile
476
- * high resolution scale factor, helps make canvas look better on hi-dpi
477
- * screens. derived from the device pixel ratio so canvases are crisp on
478
- * retina/4k displays and not needlessly oversized on standard ones
880
+ * canvas scale factor, from the device pixel ratio
479
881
  */
480
882
  highResScaleFactor:
481
883
  typeof window === 'undefined' ? 1 : window.devicePixelRatio,
@@ -544,11 +946,8 @@ function stateModelFactory() {
544
946
 
545
947
  /**
546
948
  * #volatile
547
- * transient highlights keyed by who asked for them. One slot cannot hold
548
- * two sources -- a structure viewer's hover and a genome view's hover
549
- * both want to point at a column, and with one slot whoever clears last
550
- * erases the other's. Keyed by owner, each source adds and removes only
551
- * its own. Not persisted: a hover is not part of the document.
949
+ * transient highlights keyed by owner, so a structure viewer's hover and
950
+ * a genome view's hover each clear only their own. Not persisted.
552
951
  */
553
952
  transientHighlights: {} as Record<string, Highlight[]>,
554
953
 
@@ -557,33 +956,6 @@ function stateModelFactory() {
557
956
  */
558
957
  minimapHeight: 56,
559
958
 
560
- /**
561
- * #volatile
562
- */
563
- conservationTrackHeight: 40,
564
-
565
- /**
566
- * #volatile
567
- * heights of individual `columnTracks`, by track id. A data track is
568
- * resized on its own: the shared per-kind heights below belong to the
569
- * tracks the viewer computes, and dragging a data track's handle used to
570
- * resize those instead.
571
- */
572
- columnTrackHeights: {} as Record<string, number>,
573
-
574
- /**
575
- * #volatile
576
- * taller than the conservation track by default: the logo spends its
577
- * height on stacked glyphs, and a 40px stack of four residues leaves each
578
- * one too short to identify
579
- */
580
- sequenceLogoTrackHeight: 80,
581
-
582
- /**
583
- * #volatile
584
- */
585
- arcTrackHeight: 50,
586
-
587
959
  /**
588
960
  * #volatile
589
961
  */
@@ -597,36 +969,31 @@ function stateModelFactory() {
597
969
 
598
970
  /**
599
971
  * #volatile
600
- * load problems the view carried on through: an optional layer that did
601
- * not arrive, an overlay that did not parse. `error` is the other kind --
602
- * it replaces the view, which is right for the alignment and wrong for a
603
- * decorative file
972
+ * non-fatal load problems: an optional layer that failed to load, an
973
+ * overlay that failed to parse. `error` replaces the view and is for the
974
+ * alignment itself
604
975
  */
605
976
  warnings: [] as string[],
606
977
 
607
978
  /**
608
979
  * #volatile
609
- * bumped by reset(). The React error boundary above the view keeps its
610
- * caught error until it is remounted, so "Return to import form" did
611
- * nothing after a render error until this became its key
980
+ * bumped by reset(). The error boundary above the view uses it as its key,
981
+ * since the boundary keeps its caught error until remounted
612
982
  */
613
983
  resetCount: 0,
614
984
 
615
985
  /**
616
986
  * #volatile
617
- * set by a host that restores the loaded documents by its own means --
618
- * a jbrowse session that holds them, a page that refetches them on load.
619
- * `unshareableData` then reports nothing, since what it warns about is a
620
- * link that opens empty, and under such a host the link does not
987
+ * set by a host that restores the loaded documents itself, such as a
988
+ * jbrowse session or a page that refetches them. `unshareableData` then
989
+ * reports nothing
621
990
  */
622
991
  hostCarriesData: false,
623
992
 
624
993
  /**
625
994
  * #volatile
626
- * overlay annotations drawn on the alignment, whatever their source.
627
- * Every source -- InterProScan, GFF, a user upload -- converts to this
628
- * flat list before it reaches the model, so nothing downstream of here
629
- * knows which one it came from
995
+ * overlay annotations drawn on the alignment. InterProScan JSON, GFF and
996
+ * user uploads all convert to this flat list
630
997
  */
631
998
  annotations: [] as Annotation[],
632
999
  }))
@@ -672,8 +1039,8 @@ function stateModelFactory() {
672
1039
  },
673
1040
  /**
674
1041
  * #action
675
- * high-res scale factor, tracks the device pixel ratio so canvases stay
676
- * crisp when the window moves between monitors or the browser zooms
1042
+ * update the canvas scale factor when the device pixel ratio changes
1043
+ * (moving between monitors, browser zoom)
677
1044
  */
678
1045
  setHighResScaleFactor(arg: number) {
679
1046
  self.highResScaleFactor = arg
@@ -696,8 +1063,8 @@ function stateModelFactory() {
696
1063
 
697
1064
  /**
698
1065
  * #action
699
- * report something the view survived: a layer that failed to load, a
700
- * file that failed to parse
1066
+ * record a non-fatal load problem: a layer that failed to load, a file
1067
+ * that failed to parse
701
1068
  */
702
1069
  addWarning(warning: string) {
703
1070
  self.warnings = [...self.warnings, warning]
@@ -713,7 +1080,7 @@ function stateModelFactory() {
713
1080
  /**
714
1081
  * #action
715
1082
  * declare that this host restores the loaded documents itself, which
716
- * takes down the "Not in the link" warning. See `hostCarriesData`
1083
+ * hides the "Not in the link" warning. See `hostCarriesData`
717
1084
  */
718
1085
  setHostCarriesData(arg: boolean) {
719
1086
  self.hostCarriesData = arg
@@ -723,9 +1090,9 @@ function stateModelFactory() {
723
1090
  * #action
724
1091
  * set mouse position (row, column) in the MSA
725
1092
  *
726
- * PUBLIC API: a host drives this (and reads the `mouseCol` volatile) to
727
- * sync the alignment's hover with a view of its own -- a genome view, a
728
- * 3D structure. Keep the name and signature stable.
1093
+ * Public API: a host calls this (and reads `mouseCol`) to sync hover with
1094
+ * its own view, such as a genome view or 3D structure. Keep the name and
1095
+ * signature stable.
729
1096
  */
730
1097
  setMousePos(col?: number, row?: number) {
731
1098
  self.mouseCol = col
@@ -736,9 +1103,9 @@ function stateModelFactory() {
736
1103
  * #action
737
1104
  * set highlighted columns
738
1105
  *
739
- * PUBLIC API: jbrowse-plugin-msaview calls this from its
740
- * afterCreateAutoruns to highlight alignment columns, and MSAViewer
741
- * passes its `highlightColumns` prop through it. Not dead code.
1106
+ * Public API: jbrowse-plugin-msaview calls this from its
1107
+ * afterCreateAutoruns, and MSAViewer passes its `highlightColumns` prop
1108
+ * through it.
742
1109
  */
743
1110
  setHighlightedColumns(columns?: number[]) {
744
1111
  self.highlightedColumns = columns
@@ -749,12 +1116,18 @@ function stateModelFactory() {
749
1116
  setHighlights(highlights: Highlight[]) {
750
1117
  self.highlights.replace(highlights)
751
1118
  },
1119
+
752
1120
  /**
753
1121
  * #action
754
- * show `highlights` on behalf of `owner`, replacing whatever that owner
755
- * showed before and leaving every other owner's alone. The object is
756
- * replaced rather than mutated so one assignment is the observable
757
- * change.
1122
+ * replace the clades the viewer marks (see docs/layers.md)
1123
+ */
1124
+ setClades(clades: Clade[]) {
1125
+ self.clades.replace(clades)
1126
+ },
1127
+ /**
1128
+ * #action
1129
+ * show `highlights` for `owner`, replacing that owner's previous ones and
1130
+ * leaving other owners' in place
758
1131
  */
759
1132
  applyHighlight(owner: string, highlights: Highlight[]) {
760
1133
  self.transientHighlights = {
@@ -764,7 +1137,7 @@ function stateModelFactory() {
764
1137
  },
765
1138
  /**
766
1139
  * #action
767
- * drop what `owner` was showing, leaving every other owner's in place
1140
+ * remove `owner`'s highlights, leaving other owners' in place
768
1141
  */
769
1142
  clearHighlight(owner: string) {
770
1143
  if (owner in self.transientHighlights) {
@@ -819,10 +1192,9 @@ function stateModelFactory() {
819
1192
 
820
1193
  /**
821
1194
  * #action
822
- * switch to another alignment of a multi-alignment file (Stockholm). The
823
- * new alignment has its own rows and its own tree, so everything naming
824
- * the old one's -- the collapsed node ids, the subtree in focus, the
825
- * reference row, the scroll position -- goes with it
1195
+ * switch to another alignment of a multi-alignment file (Stockholm).
1196
+ * Clears the collapsed node ids, the subtree in focus, the reference row
1197
+ * and the scroll position, which all refer to the previous alignment
826
1198
  */
827
1199
  setCurrentAlignment(n: number) {
828
1200
  if (n === self.currentAlignment) {
@@ -924,11 +1296,10 @@ function stateModelFactory() {
924
1296
 
925
1297
  /**
926
1298
  * #action
927
- * keep the GFF text the way the alignment and the tree are kept, rather
928
- * than only its parsed annotations. The annotations are volatile, so a
929
- * file opened from disk used to leave no trace in the snapshot at all --
930
- * not the text, and not the filehandle, which is cleared once a blob is
931
- * read. An autorun parses this back into annotations.
1299
+ * store the GFF text in the snapshot like the alignment and tree. The
1300
+ * parsed annotations are volatile and a blob filehandle is cleared once
1301
+ * read, so the text is the only persisted copy. An autorun parses it
1302
+ * into annotations.
932
1303
  */
933
1304
  setGFF(result: string) {
934
1305
  self.data.setGFF(result)
@@ -960,16 +1331,14 @@ function stateModelFactory() {
960
1331
  },
961
1332
  /**
962
1333
  * #getter
963
- * whether this host brings the loaded documents back by means the
964
- * snapshot cannot see, which is what decides whether `unshareableData`
965
- * has anything to warn about.
1334
+ * whether the host restores the loaded documents outside the snapshot.
1335
+ * When true, `unshareableData` is empty.
966
1336
  *
967
- * A simple host flips the `hostCarriesData` volatile. A host whose
968
- * answer depends on how the view was opened overrides this getter in a
969
- * `.views` block of its own composed model -- jbrowse-plugin-msaview's
970
- * indexed-location views refetch from a URL the session holds, while its
971
- * data-store views really are absent from a link someone pastes
972
- * elsewhere. `unshareableData` reads it off `self`, so an override wins.
1337
+ * A simple host sets `hostCarriesData`. A host where this depends on how
1338
+ * the view was opened overrides the getter in its own composed model's
1339
+ * `.views` block; jbrowse-plugin-msaview's indexed-location views refetch
1340
+ * from a URL the session holds, while its data-store views do not.
1341
+ * `unshareableData` reads it off `self`, so an override takes effect.
973
1342
  */
974
1343
  get hostRestoresData() {
975
1344
  return self.hostCarriesData
@@ -992,8 +1361,8 @@ function stateModelFactory() {
992
1361
  * #getter
993
1362
  */
994
1363
  get colorScheme() {
995
- // colorSchemeName is a free string (menus, snapshots, URL params); fall
996
- // back to the default rather than returning undefined on a stale name
1364
+ // colorSchemeName is a free string (menus, snapshots, URL params), so a
1365
+ // stale name falls back to the default
997
1366
  return (
998
1367
  colorSchemes[self.colorSchemeName] ??
999
1368
  colorSchemes[defaultColorSchemeName]!
@@ -1025,29 +1394,23 @@ function stateModelFactory() {
1025
1394
 
1026
1395
  /**
1027
1396
  * #getter
1028
- * the loaded documents this view's own snapshot cannot carry, largest
1029
- * first. A file opened from disk or pasted in becomes inline text, and
1030
- * DataModel drops an inline document past `maxInlineSnapshotBytes`
1031
- * rather than put megabytes of sequence into a session or a URL.
1397
+ * loaded documents left out of the snapshot, largest first. A file opened
1398
+ * from disk or pasted in becomes inline text, and DataModel drops an
1399
+ * inline document past `maxInlineSnapshotBytes`.
1032
1400
  *
1033
- * Dropping it is right. Dropping it silently is what makes a copied link
1034
- * open an empty viewer, so the header says so and the standalone app
1035
- * stops rewriting the address bar while this is non-empty. A document
1036
- * fetched from a URL never appears here whatever its size: the snapshot
1037
- * keeps the filehandle and refetches through it.
1401
+ * The header lists these, and the standalone app stops rewriting the
1402
+ * address bar while the list is non-empty, so a copied link does not
1403
+ * open an empty viewer unannounced. A document fetched from a URL never
1404
+ * appears here, since the snapshot keeps its filehandle.
1038
1405
  *
1039
- * Nothing is unshareable when the host restores the data by its own
1040
- * means (see `hostRestoresData`) -- inside a session that reloads these
1041
- * documents from somewhere the snapshot does not show, the warning is
1042
- * simply wrong.
1406
+ * Empty when `hostRestoresData` is true.
1043
1407
  */
1044
1408
  get unshareableData(): UnshareableData[] {
1045
1409
  if (self.hostRestoresData) {
1046
1410
  return []
1047
1411
  }
1048
1412
  const { data } = self
1049
- // a data track past the limit leaves the snapshot the same way an
1050
- // inline document does, and left unreported the same way too
1413
+ // data tracks past the limit also leave the snapshot
1051
1414
  const trackBytes = columnTrackSizes(self.columnTracks)
1052
1415
  .filter(bytes => bytes > maxInlineSnapshotBytes)
1053
1416
  .reduce((a, b) => a + b, 0)
@@ -1074,13 +1437,14 @@ function stateModelFactory() {
1074
1437
  },
1075
1438
  /**
1076
1439
  * #getter
1077
- * extra per-row attributes, keyed by row name. Parsed defensively: the
1078
- * source is a user-supplied document (treeMetadataFilehandle, or a
1079
- * session snapshot), and this computed is read by labelWidthMap on every
1080
- * layout, so a malformed file would otherwise throw out of rendering and
1081
- * take the whole view down over a decorative field.
1082
- */
1083
- get treeMetadata(): Record<string, Record<string, string> | undefined> {
1440
+ * the row table: extra fields per row, keyed by row name, which the
1441
+ * `encodings` channels read. It is stored as the JSON string
1442
+ * `data.treeMetadata`, the name that travels in existing links, so the
1443
+ * inline size limit and `treeMetadataFilehandle` cover it. labelWidthMap
1444
+ * reads it on every layout, so a malformed user-supplied file returns {}
1445
+ * instead of throwing out of rendering.
1446
+ */
1447
+ get rowData(): Record<string, Record<string, string> | undefined> {
1084
1448
  const text = self.data.treeMetadata
1085
1449
  if (!text) {
1086
1450
  return {}
@@ -1091,10 +1455,31 @@ function stateModelFactory() {
1091
1455
  ? (parsed as Record<string, Record<string, string> | undefined>)
1092
1456
  : {}
1093
1457
  } catch (e) {
1094
- console.error('failed to parse treeMetadata', e)
1458
+ console.error('failed to parse rowData', e)
1095
1459
  return {}
1096
1460
  }
1097
1461
  },
1462
+ /**
1463
+ * #method
1464
+ * one row's fields, the single reader of the row table
1465
+ */
1466
+ rowDataOf(name: string) {
1467
+ return this.rowData[name]
1468
+ },
1469
+ /**
1470
+ * #getter
1471
+ * the field names the row table carries, sorted, for a producer or a UI
1472
+ * choosing one to encode
1473
+ */
1474
+ get rowFields(): string[] {
1475
+ const fields = new Set<string>()
1476
+ for (const row of Object.values(this.rowData)) {
1477
+ for (const field of Object.keys(row ?? {})) {
1478
+ fields.add(field)
1479
+ }
1480
+ }
1481
+ return [...fields].sort((a, b) => a.localeCompare(b))
1482
+ },
1098
1483
  /**
1099
1484
  * #getter
1100
1485
  */
@@ -1144,6 +1529,37 @@ function stateModelFactory() {
1144
1529
  this.leaves.map((leaf, index) => [leaf.data.name, index] as const),
1145
1530
  )
1146
1531
  },
1532
+ /**
1533
+ * #getter
1534
+ * `clades` resolved to the rows each one covers. The tip names resolve
1535
+ * against `tree` rather than `root`, so a clade whose ancestor the user
1536
+ * collapsed keeps its rows. One leaf pass over the tree serves every
1537
+ * clade. A `range` record names no node, so `collapse` and `focus`, which
1538
+ * need one, drop it.
1539
+ */
1540
+ get resolvedClades(): ResolvedClade[] {
1541
+ if (self.clades.length === 0) {
1542
+ return noClades
1543
+ }
1544
+ return resolveClades(
1545
+ self.clades,
1546
+ hierarchy(this.tree, d => d.children),
1547
+ this.rowNamesSet,
1548
+ )
1549
+ },
1550
+ /**
1551
+ * #getter
1552
+ * the pixel column reserved at the right of the tree area for the bracket
1553
+ * mark, which the tip labels and the tree itself stay clear of. Zero
1554
+ * where no clade draws a bar or a label.
1555
+ */
1556
+ get cladeGutterWidth() {
1557
+ return cladeGutterWidth({
1558
+ clades: this.resolvedClades,
1559
+ rowHeight: self.rowHeight,
1560
+ fontSize: this.fontSize,
1561
+ })
1562
+ },
1147
1563
  /**
1148
1564
  * #getter
1149
1565
  */
@@ -1177,46 +1593,143 @@ function stateModelFactory() {
1177
1593
  * #getter
1178
1594
  */
1179
1595
  get root() {
1180
- let hier = hierarchy(this.tree, d => d.children)
1181
- hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1))
1182
- sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1))
1183
-
1184
- if (self.showOnly) {
1185
- const res = find(hier, n => n.data.id === self.showOnly)
1186
- if (res) {
1187
- hier = res
1188
- }
1596
+ return buildTreeRoot(this.tree, self.collapsed, self.showOnly)
1597
+ },
1598
+
1599
+ /**
1600
+ * #getter
1601
+ * height of the band the tree overview draws in, zero when it is off
1602
+ */
1603
+ get treeOverviewHeight() {
1604
+ return self.showTreeOverview ? self.overviewHeight : 0
1605
+ },
1606
+
1607
+ /**
1608
+ * #getter
1609
+ * the whole tree laid out for the overview, or undefined when the
1610
+ * overview is off. The focus is left out, so the focused subtree draws
1611
+ * inside the whole tree, and the collapsed clades are folded, since
1612
+ * those are rows the view no longer has. `x` is in tip-index space and
1613
+ * `len` is a fraction of the root-to-tip length, so one layout serves
1614
+ * any band size.
1615
+ */
1616
+ get treeOverviewLayout() {
1617
+ if (!self.showTreeOverview) {
1618
+ return undefined
1619
+ }
1620
+ const root = buildTreeRoot(this.tree, self.collapsed)
1621
+ const numTips = leaves(root).length
1622
+ clusterLayout(root, numTips, 1)
1623
+ const rootLen = Math.max(root.data.length || 0, 0)
1624
+ const extent = maxLength(root) - rootLen
1625
+ setBrLength(root, -rootLen, extent ? 1 / extent : 0)
1626
+ return {
1627
+ root,
1628
+ numTips,
1629
+ maxDepthToLeaf: calcDepthToLeaf(root),
1630
+ showBranchLen: self.showBranchLen && extent > 0,
1189
1631
  }
1632
+ },
1190
1633
 
1191
- for (const collapsedId of self.collapsed) {
1192
- const node = find(hier, n => n.data.id === collapsedId)
1193
- if (node) {
1194
- if (node.children) {
1195
- collapse(node)
1196
- } else if (node.parent?.children) {
1197
- node.parent.children = node.parent.children.filter(
1198
- c => c.data.id !== collapsedId,
1199
- )
1200
- }
1201
- }
1634
+ /**
1635
+ * #getter
1636
+ * the `clades` highlights in the overview's own row space, which the
1637
+ * focus does not narrow
1638
+ */
1639
+ get treeOverviewClades(): ResolvedClade[] {
1640
+ const layout = this.treeOverviewLayout
1641
+ if (!layout || self.clades.length === 0) {
1642
+ return noClades
1202
1643
  }
1644
+ const rowNames = new Map(
1645
+ leaves(layout.root).map((leaf, index) => [leaf.data.name, index]),
1646
+ )
1647
+ return resolveClades(self.clades, layout.root, rowNames)
1648
+ },
1649
+
1650
+ /**
1651
+ * #getter
1652
+ * the inclusive tip rows the focused subtree covers in the overview,
1653
+ * which is the box drawn on it. undefined with no focus
1654
+ */
1655
+ get treeOverviewFocusRows(): [number, number] | undefined {
1656
+ const layout = this.treeOverviewLayout
1657
+ if (!layout || !self.showOnly) {
1658
+ return undefined
1659
+ }
1660
+ const node = find(layout.root, n => n.data.id === self.showOnly)
1661
+ return node ? tipRange(node) : undefined
1662
+ },
1203
1663
 
1204
- return hier
1664
+ /**
1665
+ * #method
1666
+ * the subtree a point `y` pixels down the tree overview picks: the
1667
+ * deepest one whose tip range covers every row under that pixel, with
1668
+ * the rows it covers. A pixel stands for several tips on a large tree,
1669
+ * which is what keeps the pick off the individual tips. undefined when
1670
+ * the overview is off or the point picks the whole tree.
1671
+ */
1672
+ treeOverviewHit(y: number) {
1673
+ const layout = this.treeOverviewLayout
1674
+ if (!layout) {
1675
+ return undefined
1676
+ }
1677
+ const { root, numTips } = layout
1678
+ const perPixel = numTips / self.overviewHeight
1679
+ const first = clamp(Math.floor(y * perPixel), 0, numTips - 1)
1680
+ const last = clamp(
1681
+ Math.ceil((y + 1) * perPixel) - 1,
1682
+ first,
1683
+ numTips - 1,
1684
+ )
1685
+ let node = nodeCoveringRows(root, first + 0.5, last + 0.5)
1686
+ // focusing one tip leaves a single row on screen, so the pick lifts to
1687
+ // the subtree that tip sits in
1688
+ while (!node.children && node.parent) {
1689
+ node = node.parent
1690
+ }
1691
+ return node === root
1692
+ ? undefined
1693
+ : { id: node.data.id, rows: tipRange(node) }
1205
1694
  },
1206
1695
 
1207
1696
  /**
1208
1697
  * #getter
1209
- * widget width minus the tree area gives the space for the MSA
1698
+ * the pixel column the row panels occupy between the tree and the
1699
+ * alignment, the sum of each record's width
1700
+ */
1701
+ get rowPanelsWidth() {
1702
+ return sum(self.rowPanels.map(p => rowPanelWidth(p, self.rowHeight)))
1703
+ },
1704
+ /**
1705
+ * #getter
1706
+ * height of the band the row panel headers draw in, which is zero with
1707
+ * no row panels and leaves the top area as it was
1708
+ */
1709
+ get rowPanelsHeaderHeight() {
1710
+ return self.rowPanels.length > 0 ? rowPanelHeaderHeight : 0
1711
+ },
1712
+ /**
1713
+ * #getter
1714
+ * widget width minus the tree area and the row panels gives the space
1715
+ * for the MSA
1210
1716
  */
1211
1717
  get msaAreaWidth() {
1212
- return self.width - self.treeAreaWidth - self.resizeHandleWidth
1718
+ return (
1719
+ self.width -
1720
+ self.treeAreaWidth -
1721
+ this.rowPanelsWidth -
1722
+ self.resizeHandleWidth
1723
+ )
1213
1724
  },
1214
1725
 
1215
1726
  /**
1216
1727
  * #getter
1728
+ * the right edge the tip labels end at, which is the tree area less the
1729
+ * margin and the bracket gutter
1217
1730
  */
1218
1731
  get treeAreaWidthMinusMargin() {
1219
- return self.treeAreaWidth - self.marginLeft
1732
+ return self.treeAreaWidth - self.marginLeft - this.cladeGutterWidth
1220
1733
  },
1221
1734
  /**
1222
1735
  * #getter
@@ -1286,10 +1799,8 @@ function stateModelFactory() {
1286
1799
  },
1287
1800
  /**
1288
1801
  * #getter
1289
- * number of rows the alignment occupies on screen. This is the leaf count,
1290
- * not `rows.length`: a tree leaf with no matching MSA row still takes up a
1291
- * row of vertical space (drawn blank), so row hit-testing and fit-to-height
1292
- * must count it.
1802
+ * number of rows on screen: the leaf count, which includes tree leaves
1803
+ * with no matching MSA row (drawn blank), unlike `rows.length`.
1293
1804
  */
1294
1805
  get numRows() {
1295
1806
  return this.leaves.length
@@ -1298,12 +1809,8 @@ function stateModelFactory() {
1298
1809
  /**
1299
1810
  * #method
1300
1811
  * index of the global column holding each ungapped sequence position of a
1301
- * row, so seqPos -> column is a lookup rather than a scan. The domain
1302
- * overlay resolves thousands of these per redraw.
1303
- *
1304
- * Built per row, on the row asked for: the first lookup used to index
1305
- * every row in the alignment. The cache is keyed on the parse the rows
1306
- * came from, so a new alignment brings a new one.
1812
+ * row. The domain overlay resolves thousands of these per redraw. Built
1813
+ * lazily per row and cached on the parse.
1307
1814
  */
1308
1815
  seqPosIndex(rowName: string): Int32Array | undefined {
1309
1816
  const MSA = this.MSA
@@ -1329,10 +1836,9 @@ function stateModelFactory() {
1329
1836
 
1330
1837
  /**
1331
1838
  * #getter
1332
- * every sequence the alignment holds, keyed by row name, whatever the
1333
- * tree currently shows. `rows` is the rows on screen; this is the rows
1334
- * that exist, and every lookup about a named row goes through it --
1335
- * collapsing a clade hides rows, it does not delete their sequence
1839
+ * every sequence in the alignment, keyed by row name, including rows a
1840
+ * collapsed clade hides. `rows` holds only the rows on screen; lookups
1841
+ * by row name use this
1336
1842
  */
1337
1843
  get rowMap() {
1338
1844
  const MSA = this.MSA
@@ -1479,10 +1985,8 @@ function stateModelFactory() {
1479
1985
  clusterLayout(r, this.totalHeight, self.treeWidth)
1480
1986
  const max = this.rootToTipLength
1481
1987
  const k = max ? self.treeWidth / max : 0
1482
- // the displayed root starts at x=0 whatever branch length it carries,
1483
- // so its own length is subtracted here rather than zeroed on the parsed
1484
- // node -- `root` hands out the cached parse, and writing to it made
1485
- // showOnly shorten that branch for good
1988
+ // the displayed root starts at x=0, so subtract its length here; `root`
1989
+ // returns the cached parse, which must not be mutated
1486
1990
  setBrLength(r, -Math.max(r.data.length || 0, 0), k)
1487
1991
  // for each collapsed clade, record the pixel x-position of its farthest
1488
1992
  // tip so the renderer can draw a triangle spanning the branch-length
@@ -1522,10 +2026,8 @@ function stateModelFactory() {
1522
2026
 
1523
2027
  /**
1524
2028
  * #getter
1525
- * x-position of the farthest tip in a phylogram, px. The layout scales
1526
- * the longest root-to-tip path onto treeWidth, so that is where it lands
1527
- * -- and 0 for a tree carrying no lengths at all, which draws as a
1528
- * cladogram instead
2029
+ * x-position of the farthest tip in a phylogram, px: treeWidth, or 0 for
2030
+ * a tree with no branch lengths (drawn as a cladogram)
1529
2031
  */
1530
2032
  get maxBranchLength() {
1531
2033
  return this.rootToTipLength ? self.treeWidth : 0
@@ -1533,9 +2035,8 @@ function stateModelFactory() {
1533
2035
 
1534
2036
  /**
1535
2037
  * #getter
1536
- * pixels per unit of branch length in the current phylogram layout, and 0
1537
- * in cladogram mode, where the x-positions carry no length at all. The
1538
- * scale bar over the tree is drawn from it.
2038
+ * pixels per unit of branch length in the phylogram layout, 0 in
2039
+ * cladogram mode. The tree's scale bar uses it.
1539
2040
  */
1540
2041
  get pxPerBranchLength() {
1541
2042
  const max = maxLength(this.root)
@@ -1598,21 +2099,18 @@ function stateModelFactory() {
1598
2099
  * #getter
1599
2100
  */
1600
2101
  get labelWidthMap() {
1601
- const { showTreeText, leaves, treeMetadata } = self
1602
- // gated on the same condition the renderer draws labels under, so the
1603
- // gutter labelsWidth reserves and the labels actually drawn cannot
1604
- // disagree -- and so turning labels off hands their space to the tree.
1605
- // Measured at a fixed reference size and scaled by labelWidthScale:
1606
- // re-measuring every leaf on every vertical-zoom frame cost ~200ms on a
2102
+ const { showTreeText, leaves } = self
2103
+ // gated on the renderer's label condition, so hidden labels reserve no
2104
+ // gutter. Measured once at a reference size and scaled by
2105
+ // labelWidthScale: re-measuring per vertical-zoom frame cost ~200ms on a
1607
2106
  // 50k-leaf tree
1608
2107
  return showTreeText
1609
2108
  ? new Map(
1610
2109
  leaves.map(node => {
1611
2110
  const { name } = node.data
1612
2111
  // `||`, matching renderTreeLabels: an empty genome falls back
1613
- // to the row name, and measuring '' would size the gutter (and
1614
- // the label's click target) to nothing
1615
- const displayName = treeMetadata[name]?.genome || name
2112
+ // to the row name
2113
+ const displayName = self.rowDataOf(name)?.genome || name
1616
2114
  return [
1617
2115
  name,
1618
2116
  measureTextCanvas(displayName, labelReferenceFontSize),
@@ -1632,11 +2130,9 @@ function stateModelFactory() {
1632
2130
  },
1633
2131
 
1634
2132
  get labelsWidth() {
1635
- // a loop, not Math.max(...widths.values()): spreading a map of every
1636
- // leaf passes one argument per row, and the argument limit is somewhere
1637
- // around 125k -- so the bundled 230k-tip COVID tree threw
1638
- // "RangeError: Maximum call stack size exceeded" out of a getter the
1639
- // treeWidth autorun reads on load
2133
+ // a loop, not Math.max(...): spreading passes one argument per leaf,
2134
+ // and the ~125k argument limit throws RangeError on the 230k-tip COVID
2135
+ // tree
1640
2136
  let max = 0
1641
2137
  for (const width of this.labelWidthMap.values()) {
1642
2138
  if (width > max) {
@@ -1662,9 +2158,8 @@ function stateModelFactory() {
1662
2158
 
1663
2159
  /**
1664
2160
  * #getter
1665
- * the base pairs of the consensus secondary structure, as arcs. The WUSS
1666
- * string is collapsed through the hidden columns before it is parsed, so
1667
- * the pairs land in the same visible column space the text track does
2161
+ * the base pairs of the consensus secondary structure, as arcs, in
2162
+ * visible column space (hidden columns are removed before parsing)
1668
2163
  */
1669
2164
  get secondaryStructureArcs(): Arc[] | undefined {
1670
2165
  const { blanks, hideGapsEffective } = self
@@ -1688,9 +2183,8 @@ function stateModelFactory() {
1688
2183
  const { MSA, hideGapsEffective, blanks } = self
1689
2184
  const tracks = (MSA?.tracks ?? []).filter(t => !!t.data)
1690
2185
  if (tracks.length === 0) {
1691
- // reading rowHeight up front made every zoom frame rebuild the track
1692
- // list, and the canvases redraw on the track object they are handed
1693
- // changing
2186
+ // return before reading rowHeight, so zooming does not rebuild the
2187
+ // list and redraw every track canvas
1694
2188
  return []
1695
2189
  }
1696
2190
  const { rowHeight } = self
@@ -1729,11 +2223,9 @@ function stateModelFactory() {
1729
2223
  }
1730
2224
  const skip = <T>(items: T[]) =>
1731
2225
  hideGapsEffective ? dropBlanks(blanks, items) : items
1732
- // an arc names two positions rather than one per column, so it takes
1733
- // the same two steps the arrays take -- a row's residues onto columns,
1734
- // then columns onto the visible ones -- as a lookup. visibleColsBefore,
1735
- // not globalColToVisibleCol: an endpoint in a hidden column collapses
1736
- // to where that column went instead of taking the whole arc with it
2226
+ // an arc endpoint maps row residue -> column -> visible column.
2227
+ // visibleColsBefore, not globalColToVisibleCol, so an endpoint in a
2228
+ // hidden column moves to the neighboring visible one and the arc stays
1737
2229
  const resolve = (track: ColumnTrackSpec, pos: number) => {
1738
2230
  const col = track.row
1739
2231
  ? self.seqPosIndex(track.row)?.[pos - 1]
@@ -1773,47 +2265,50 @@ function stateModelFactory() {
1773
2265
  )
1774
2266
  },
1775
2267
  /**
1776
- * #getter
2268
+ * #method
2269
+ * the height a track draws at: what the user dragged its divider to,
2270
+ * then the height its snapshot asked for, then its kind's default. Only
2271
+ * a text track falls through to rowHeight, and `??` short-circuits
2272
+ * before reading it, so vertical zoom does not rebuild the other tracks
1777
2273
  */
1778
- get columnTrackModels(): BasicTrack[] {
1779
- // read per kind, not up front: a text track is the only kind sized by
1780
- // the row height, and reading it here rebuilt every data track on every
1781
- // vertical zoom step
1782
- const defaultHeight = (kind: ColumnTrackSpec['kind']) =>
1783
- kind === 'bar'
1784
- ? self.conservationTrackHeight
1785
- : kind === 'arc'
1786
- ? self.arcTrackHeight
1787
- : self.rowHeight
1788
- return self.columnTracks.map(track => ({
1789
- model: {
1790
- id: track.id,
1791
- name: track.name,
1792
- kind: track.kind,
1793
- height:
1794
- self.columnTrackHeights[track.id] ??
1795
- track.height ??
1796
- defaultHeight(track.kind),
1797
- // the spec has one `color`; bar and arc are separate track models
1798
- // that read it under their own name
1799
- barColor: track.color,
1800
- arcColor: track.color,
1801
- customColorScheme: track.colors,
1802
- data: this.columnTrackContent.get(track.id)?.data,
1803
- arcs: this.columnTrackContent.get(track.id)?.arcs,
1804
- },
1805
- ReactComponent: TrackBlocks,
1806
- }))
2274
+ trackHeight(kind: TrackKind, heightKey = kind as string, given?: number) {
2275
+ return (
2276
+ self.trackHeights.get(heightKey) ??
2277
+ given ??
2278
+ defaultTrackHeights[kind] ??
2279
+ self.rowHeight
2280
+ )
1807
2281
  },
1808
2282
  /**
1809
2283
  * #getter
1810
2284
  */
2285
+ get columnTrackModels(): BasicTrack[] {
2286
+ return self.columnTracks.map(track => {
2287
+ const heightKey = resizableKinds.has(track.kind)
2288
+ ? ownHeightKey(track.id)
2289
+ : undefined
2290
+ return {
2291
+ model: {
2292
+ id: track.id,
2293
+ name: track.name,
2294
+ kind: track.kind,
2295
+ heightKey,
2296
+ height: this.trackHeight(track.kind, heightKey, track.height),
2297
+ barColor: track.color,
2298
+ arcColor: track.color,
2299
+ customColorScheme: track.colors,
2300
+ data: this.columnTrackContent.get(track.id)?.data,
2301
+ arcs: this.columnTrackContent.get(track.id)?.arcs,
2302
+ },
2303
+ ReactComponent: TrackBlocks,
2304
+ }
2305
+ })
2306
+ },
1811
2307
  /**
1812
2308
  * #getter
1813
- * the consensus secondary structure as a track, when there is one. Its
1814
- * own getter so the object keeps its identity across a zoom: the canvas
1815
- * redraws on the track it is handed changing, and rebuilding these
1816
- * alongside everything else made every zoom frame redraw every track
2309
+ * the consensus secondary structure as a track, when there is one. A
2310
+ * separate getter keeps the object stable across zoom, so its canvas
2311
+ * does not redraw
1817
2312
  */
1818
2313
  get basePairTrackModels(): BasicTrack[] {
1819
2314
  const arcs = this.secondaryStructureArcs
@@ -1824,7 +2319,8 @@ function stateModelFactory() {
1824
2319
  id: 'base-pairs',
1825
2320
  name: 'Base pairs',
1826
2321
  kind: 'arc' as const,
1827
- height: self.arcTrackHeight,
2322
+ heightKey: 'arc',
2323
+ height: this.trackHeight('arc'),
1828
2324
  arcs,
1829
2325
  },
1830
2326
  ReactComponent: TrackBlocks,
@@ -1835,8 +2331,8 @@ function stateModelFactory() {
1835
2331
 
1836
2332
  /**
1837
2333
  * #getter
1838
- * the tracks computed from the alignment itself, which depend on their
1839
- * own heights and on the alphabet -- and on nothing zoom changes
2334
+ * the tracks computed from the alignment; they depend on their heights
2335
+ * and the alphabet, not on zoom
1840
2336
  */
1841
2337
  get computedTrackModels(): BasicTrack[] {
1842
2338
  return [
@@ -1844,7 +2340,6 @@ function stateModelFactory() {
1844
2340
  id: 'conservation',
1845
2341
  name: 'Conservation',
1846
2342
  kind: 'bar' as const,
1847
- height: self.conservationTrackHeight,
1848
2343
  barColor: 'gray',
1849
2344
  },
1850
2345
  ...(self.sequenceType === 'amino'
@@ -1853,7 +2348,6 @@ function stateModelFactory() {
1853
2348
  id: 'property-conservation',
1854
2349
  name: 'Property conservation',
1855
2350
  kind: 'bar' as const,
1856
- height: self.conservationTrackHeight,
1857
2351
  barColor: '#6a51a3',
1858
2352
  },
1859
2353
  ]
@@ -1862,16 +2356,23 @@ function stateModelFactory() {
1862
2356
  id: 'sequence-logo',
1863
2357
  name: 'Sequence logo',
1864
2358
  kind: 'logo' as const,
1865
- height: self.sequenceLogoTrackHeight,
1866
2359
  },
1867
2360
  // last, so it sits against the alignment it numbers
1868
2361
  {
1869
2362
  id: 'position-ruler',
1870
2363
  name: 'Position',
1871
2364
  kind: 'ruler' as const,
1872
- height: 20,
1873
2365
  },
1874
- ].map(model => ({ model, ReactComponent: TrackBlocks }))
2366
+ ].map(model => ({
2367
+ // every computed track of a kind shares that kind's height, so the
2368
+ // kind is its key
2369
+ model: {
2370
+ ...model,
2371
+ heightKey: resizableKinds.has(model.kind) ? model.kind : undefined,
2372
+ height: this.trackHeight(model.kind),
2373
+ },
2374
+ ReactComponent: TrackBlocks,
2375
+ }))
1875
2376
  },
1876
2377
 
1877
2378
  get tracks(): BasicTrack[] {
@@ -1879,7 +2380,9 @@ function stateModelFactory() {
1879
2380
  ...this.adapterTrackModels,
1880
2381
  ...this.basePairTrackModels,
1881
2382
  ...this.columnTrackModels,
1882
- ...this.computedTrackModels,
2383
+ // every computed track reads the alignment's columns, and a tree, a
2384
+ // GFF and a features panel draw a figure with none
2385
+ ...(self.numColumns > 0 ? this.computedTrackModels : []),
1883
2386
  ]
1884
2387
  },
1885
2388
 
@@ -1919,19 +2422,18 @@ function stateModelFactory() {
1919
2422
  * Convert a visible column to a row-specific sequence position (0-based).
1920
2423
  * Returns undefined if the position is a gap in the sequence.
1921
2424
  *
1922
- * PUBLIC API: this and the sibling coordinate converters
1923
- * (visibleColToGlobalCol, seqPosToVisibleCol, globalColToVisibleCol,
1924
- * seqPosToGlobalCol) are how a host translates between alignment columns
1925
- * and a row's residue positions across gaps. Keep them stable.
2425
+ * Public API, like the sibling converters (visibleColToGlobalCol,
2426
+ * seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) hosts
2427
+ * use to translate between columns and residue positions. Keep them
2428
+ * stable.
1926
2429
  *
1927
2430
  * @param rowName - The name of the row
1928
2431
  * @param visibleCol - The visible column index
1929
2432
  * @returns The sequence position (0-based), or undefined if it's a gap
1930
2433
  */
1931
2434
  visibleColToSeqPos(rowName: string, visibleCol: number) {
1932
- // a binary search of the row's index, not a scan of the row: this
1933
- // answers on every mouse move, and a 30k-column row scanned per event
1934
- // is the whole frame
2435
+ // binary search: this runs on every mouse move, and scanning a
2436
+ // 30k-column row per event takes the whole frame
1935
2437
  return seqPosOfGlobalCol(
1936
2438
  self.seqPosIndex(rowName),
1937
2439
  this.visibleColToGlobalCol(visibleCol),
@@ -1971,10 +2473,9 @@ function stateModelFactory() {
1971
2473
 
1972
2474
  /**
1973
2475
  * #method
1974
- * Convert a visible column index (what a mouse handler reports) back to a
1975
- * column of the full alignment. Hidden columns shift everything to their
1976
- * right, so a host that holds per-column data of its own has to make this
1977
- * hop before indexing it.
2476
+ * Convert a visible column index (what a mouse handler reports) to a
2477
+ * column of the full alignment. A host indexing its own per-column data
2478
+ * needs this when columns are hidden.
1978
2479
  *
1979
2480
  * @param visibleCol - The visible column index
1980
2481
  * @returns The global column index in the full MSA
@@ -1989,8 +2490,7 @@ function stateModelFactory() {
1989
2490
  /**
1990
2491
  * #method
1991
2492
  * Convert a sequence position (ungapped) to a global column index.
1992
- * Returns undefined for a row the alignment does not have -- answering
1993
- * anyway is how a mistyped or stale row name came to highlight column 0.
2493
+ * Returns undefined for a row name the alignment does not have.
1994
2494
  *
1995
2495
  * @param rowName - The name of the row
1996
2496
  * @param seqPos - The sequence position (0-based, ungapped)
@@ -2023,12 +2523,42 @@ function stateModelFactory() {
2023
2523
  : this.globalColToVisibleCol(globalCol)
2024
2524
  },
2025
2525
 
2526
+ /**
2527
+ * #method
2528
+ * the visible columns a span covers, in highlight coordinates: `start`
2529
+ * and `end` are 1-based residues of `row`, or columns of the file
2530
+ * without it. A fractional position, as a zoom gesture reports one,
2531
+ * widens to the whole residue or column it falls in. A span entirely on
2532
+ * hidden columns, or naming a row the alignment lacks, gives undefined.
2533
+ */
2534
+ visibleSpan({ row, start: rawStart, end: rawEnd }: Region) {
2535
+ const { blanks } = self
2536
+ const start = Math.max(1, Math.floor(rawStart))
2537
+ const end = Math.ceil(rawEnd)
2538
+ let startGlobal = start - 1
2539
+ let endGlobal = end - 1
2540
+ if (row !== undefined) {
2541
+ const rowStart = this.seqPosToGlobalCol(row, start - 1)
2542
+ const rowEnd = this.seqPosToGlobalCol(row, end - 1)
2543
+ if (rowStart === undefined || rowEnd === undefined) {
2544
+ return undefined
2545
+ }
2546
+ startGlobal = rowStart
2547
+ endGlobal = rowEnd
2548
+ }
2549
+ const startCol =
2550
+ this.globalColToVisibleCol(startGlobal) ??
2551
+ visibleColsBefore(blanks, startGlobal)
2552
+ const endCol =
2553
+ this.globalColToVisibleCol(endGlobal) ??
2554
+ visibleColsBefore(blanks, endGlobal) - 1
2555
+ return startCol <= endCol ? { startCol, endCol } : undefined
2556
+ },
2557
+
2026
2558
  /**
2027
2559
  * #getter
2028
- * every reason a mapping is being ignored, so a host can say which. A
2029
- * mapping outlives the alignment it was computed for; when the two no
2030
- * longer agree the lookups have to refuse, and refusing invisibly is how
2031
- * "there is no structure here" gets confused with "this data is stale".
2560
+ * why each ignored residue mapping is ignored, so a host can tell a
2561
+ * missing structure from a mapping made against a different alignment.
2032
2562
  */
2033
2563
  get residueMappingProblems(): ResidueMappingProblem[] {
2034
2564
  const problems: ResidueMappingProblem[] = []
@@ -2080,10 +2610,8 @@ function stateModelFactory() {
2080
2610
 
2081
2611
  /**
2082
2612
  * #getter
2083
- * the mappings that still fit the loaded alignment. A row-level problem
2084
- * takes the whole mapping out; a single malformed segment takes only
2085
- * itself, since the rest of the mapping is still a claim about residues
2086
- * that exist.
2613
+ * the mappings that fit the loaded alignment. A row-level problem drops
2614
+ * the whole mapping; a malformed segment drops only that segment.
2087
2615
  */
2088
2616
  get usableResidueMappings(): ResidueMapping[] {
2089
2617
  const unusable = new Set(
@@ -2101,9 +2629,8 @@ function stateModelFactory() {
2101
2629
 
2102
2630
  /**
2103
2631
  * #getter
2104
- * the structures the loaded alignment has usable mappings onto. A row can
2105
- * have several -- an experimental entry and a predicted model, say -- so
2106
- * a host that means a particular one has to name it.
2632
+ * the structures with usable mappings. A row can map onto several, such
2633
+ * as an experimental entry and a predicted model.
2107
2634
  */
2108
2635
  get mappedStructures() {
2109
2636
  return this.usableResidueMappings.map(m => ({
@@ -2114,18 +2641,12 @@ function stateModelFactory() {
2114
2641
 
2115
2642
  /**
2116
2643
  * #method
2117
- * The structure residue a row residue is, or undefined. Refusing is the
2118
- * point: the guess this replaces answered every query, with a wrong
2119
- * residue when it did not know.
2120
- *
2121
- * It also refuses when the answer is not unique. A row commonly maps onto
2122
- * several structures -- an experimental entry and two predicted models --
2123
- * and returning whichever came first would be the same class of wrong,
2124
- * quieter. Name one with `structureId`, or use `mappedStructures` to see
2125
- * what there is.
2644
+ * The structure residue for a row residue. Returns undefined when no
2645
+ * segment covers `seqPos`, or when the row maps onto several structures
2646
+ * and `structureId` does not pick one (see `mappedStructures`).
2126
2647
  *
2127
- * Positions are 1-based, as `residueMappings` and `highlights` are --
2128
- * note that the column helpers above take 0-based ones.
2648
+ * Positions are 1-based, like `residueMappings` and `highlights`; the
2649
+ * column helpers above are 0-based.
2129
2650
  *
2130
2651
  * @param rowName - The alignment row
2131
2652
  * @param seqPos - Residue of that row, 1-based
@@ -2164,10 +2685,10 @@ function stateModelFactory() {
2164
2685
 
2165
2686
  /**
2166
2687
  * #method
2167
- * The row residue a structure residue is, the same lookup backwards, and
2168
- * refusing on the same terms. `asymId` picks between mappings onto the
2169
- * same entry, which a homodimer -- two rows, two chains, one id -- always
2170
- * needs; without it such a lookup is ambiguous and gets nothing.
2688
+ * The row residue for a structure residue; the inverse of
2689
+ * `structureResidue`, returning undefined in the same cases. `asymId`
2690
+ * picks a chain when several mappings share an entry id, as in a
2691
+ * homodimer.
2171
2692
  *
2172
2693
  * @param structureId - The structure's id, as the mapping names it
2173
2694
  * @param position - Residue of that structure, 1-based label_seq_id
@@ -2203,17 +2724,39 @@ function stateModelFactory() {
2203
2724
  .views(self => ({
2204
2725
  /**
2205
2726
  * #getter
2206
- * the vertical space the alignment rows actually get: the widget height
2207
- * less everything stacked above and below them -- the header, the tracks,
2208
- * and the minimap when the columns overflow. Every consumer wants this
2209
- * same subtraction, so there is one of it: blocksY, maxScrollY, the
2210
- * vertical scrollbar and fitVertically all read it, and a second getter
2211
- * that forgot the tracks is what put the last rows out of reach.
2727
+ * the branch-length scale bar over the tree, undefined in cladogram mode
2728
+ * or when the tree area is too narrow for one
2729
+ */
2730
+ get treeScaleBar() {
2731
+ return scaleBarLength(
2732
+ self.pxPerBranchLength,
2733
+ self.treeAreaWidth - self.marginLeft * 2,
2734
+ )
2735
+ },
2736
+ /**
2737
+ * #getter
2738
+ * the band across the top: the minimap over the alignment, the row panel
2739
+ * headers, and the tree overview stacked on the scale bar, as tall as the
2740
+ * tallest of the three
2741
+ */
2742
+ get topBandHeight() {
2743
+ return Math.max(
2744
+ self.showHorizontalScrollbar ? self.minimapHeight : 0,
2745
+ self.rowPanelsHeaderHeight,
2746
+ self.treeOverviewHeight +
2747
+ (this.treeScaleBar ? treeScaleBarHeight : 0),
2748
+ )
2749
+ },
2750
+ /**
2751
+ * #getter
2752
+ * the vertical space for alignment rows: the widget height less the
2753
+ * header, the top band and the tracks. Shared by blocksY, maxScrollY, the
2754
+ * vertical scrollbar and fitVertically.
2212
2755
  */
2213
2756
  get msaAreaHeight() {
2214
2757
  return (
2215
2758
  self.height -
2216
- (self.showHorizontalScrollbar ? self.minimapHeight : 0) -
2759
+ this.topBandHeight -
2217
2760
  self.headerHeight -
2218
2761
  this.totalTrackAreaHeight
2219
2762
  )
@@ -2226,13 +2769,12 @@ function stateModelFactory() {
2226
2769
  return sum(self.turnedOnTracks.map(r => r.model.height))
2227
2770
  },
2228
2771
  /**
2229
- * one representative annotation per accession, which is what the legend,
2230
- * the filter dialog and the palettes key off
2772
+ * one representative annotation per accession, for the legend, the
2773
+ * filter dialog and the palettes
2231
2774
  */
2232
2775
  get annotationTypes() {
2233
- // first occurrence wins. The representative supplies only the name,
2234
- // description and -- for ordinal segments -- the start that orders
2235
- // them, and those agree across an accession's instances
2776
+ // first occurrence wins; only name, description and segment start are
2777
+ // read from it
2236
2778
  const types = new Map<string, Annotation>()
2237
2779
  for (const annot of self.annotations) {
2238
2780
  if (!types.has(annot.accession)) {
@@ -2263,11 +2805,8 @@ function stateModelFactory() {
2263
2805
  * #getter
2264
2806
  */
2265
2807
  get dataInitialized() {
2266
- // truthiness, not `!== ''`: these are types.maybe, and DataModel's
2267
- // postProcessSnapshot drops a document over 50kb, so a restored session
2268
- // that inlined a large alignment comes back `undefined` here -- which
2269
- // `!== ''` reads as initialized and renders an empty view instead of
2270
- // the import form
2808
+ // truthiness, not `!== ''`: DataModel drops an inline document over
2809
+ // 50kb from the snapshot, so a restored session can hold `undefined`
2271
2810
  return !!(self.data.msa || self.data.tree) && !self.error
2272
2811
  },
2273
2812
  /**
@@ -2317,12 +2856,20 @@ function stateModelFactory() {
2317
2856
  },
2318
2857
  /**
2319
2858
  * #getter
2320
- * most-negative allowed scrollY, keeping the last row in view rather than
2321
- * letting the whole alignment scroll off the top.
2859
+ * most-negative allowed scrollY, which keeps the last row in view
2322
2860
  */
2323
2861
  get maxScrollY() {
2324
2862
  return Math.min(-self.totalHeight + self.msaAreaHeight, 0)
2325
2863
  },
2864
+ /**
2865
+ * #getter
2866
+ * axis a wheel zoom scales, for ctrl+wheel as much as for scroll-zoom.
2867
+ * With scroll-zoom off the toolbar shows no axis, so ctrl+wheel takes
2868
+ * both.
2869
+ */
2870
+ get wheelZoomAxis(): ScrollZoomAxis {
2871
+ return self.scrollZoom ? self.scrollZoomAxis : 'both'
2872
+ },
2326
2873
  }))
2327
2874
  .actions(self => ({
2328
2875
  /**
@@ -2339,15 +2886,20 @@ function stateModelFactory() {
2339
2886
  self.scrollZoom = arg
2340
2887
  },
2341
2888
 
2889
+ /**
2890
+ * #action
2891
+ */
2892
+ setScrollZoomAxis(arg: ScrollZoomAxis) {
2893
+ self.scrollZoomAxis = arg
2894
+ },
2895
+
2342
2896
  /**
2343
2897
  * #action
2344
2898
  * set hovered tree node and its descendants
2345
2899
  */
2346
2900
  setHoveredTreeNode(nodeId?: string) {
2347
- // the tree's mousemove handler calls this on every event, and both the
2348
- // lookup and the write are expensive: `find` walks the whole hierarchy,
2349
- // and a fresh object here invalidates hoveredRowIndices and redraws the
2350
- // tree and MSA overlays. Re-hovering the same node is the common case
2901
+ // called on every tree mousemove; `find` walks the whole hierarchy and a
2902
+ // new object redraws the tree and MSA overlays
2351
2903
  if (nodeId === self.hoveredTreeNode?.nodeId) {
2352
2904
  return
2353
2905
  }
@@ -2367,15 +2919,12 @@ function stateModelFactory() {
2367
2919
  /**
2368
2920
  * #action
2369
2921
  * Calculate a neighbor joining tree from the current MSA using BLOSUM62
2370
- * distances. Refuses above `maxNeighborJoiningRows`: the join loop is
2371
- * cubic and runs on the main thread, so 800 rows is a ten-second freeze
2372
- * with no progress and no cancel, and a tree that size wants a tool built
2373
- * for it anyway.
2922
+ * distances. Throws above `maxNeighborJoiningRows`: the join loop is
2923
+ * cubic and runs on the main thread, and 800 rows freeze the tab for ten
2924
+ * seconds with no cancel.
2374
2925
  */
2375
2926
  calculateNeighborJoiningTreeFromMSA() {
2376
- // every sequence in the alignment, not the rows on screen: a collapsed
2377
- // clade is a display state, and building the tree from what it leaves
2378
- // showing drops the sequences it hides out of the result
2927
+ // every sequence, including rows in collapsed clades
2379
2928
  const rows = [...self.rowMap]
2380
2929
  if (rows.length < 2) {
2381
2930
  throw new Error('Need at least 2 sequences to build a tree')
@@ -2390,10 +2939,9 @@ function stateModelFactory() {
2390
2939
 
2391
2940
  /**
2392
2941
  * #action
2393
- * swap in a different tree over the same alignment. Node ids are derived
2394
- * from the path (node-0-0-1), so a `collapsed` or `showOnly` id held over
2395
- * from the old tree matches a real node in the new one and folds whatever
2396
- * happens to sit there -- the ids go with the tree they name.
2942
+ * swap in a different tree over the same alignment. Clears `collapsed`
2943
+ * and `showOnly`, since path-derived node ids (node-0-0-1) from the old
2944
+ * tree would match unrelated nodes in the new one.
2397
2945
  */
2398
2946
  replaceTree(newick: string) {
2399
2947
  transaction(() => {
@@ -2462,26 +3010,36 @@ function stateModelFactory() {
2462
3010
  * #action
2463
3011
  * Smoothly zoom by a continuous scaleFactor. The column under the cursor
2464
3012
  * (offsetX/offsetY, px relative to the MSA area) stays anchored
2465
- * horizontally. Vertically the anchor is biased toward the top: when the
2466
- * alignment nearly fits the viewport, snap to y=0 rather than pinning a
2467
- * random row under the cursor, with the bias fading out as the alignment
2468
- * grows taller than the viewport (where cursor-anchoring is useful).
2469
- * Drives wheel/trackpad-pinch zoom.
2470
- */
2471
- zoomToPos(scaleFactor: number, offsetX: number, offsetY: number) {
3013
+ * horizontally. Vertically the anchor is biased toward y=0 when the
3014
+ * alignment nearly fits the viewport, fading to cursor-anchoring as the
3015
+ * alignment grows taller than the viewport.
3016
+ * Drives wheel/trackpad-pinch zoom. `axis` holds one cell dimension
3017
+ * fixed; the held axis still re-anchors its scroll offset, since the
3018
+ * other one can change how much of the alignment fits.
3019
+ */
3020
+ zoomToPos(
3021
+ scaleFactor: number,
3022
+ offsetX: number,
3023
+ offsetY: number,
3024
+ axis: ScrollZoomAxis = 'both',
3025
+ ) {
2472
3026
  transaction(() => {
2473
3027
  const colInView = (-self.scrollX + offsetX) / self.colWidth
2474
3028
  const rowInView = (-self.scrollY + offsetY) / self.rowHeight
2475
- self.colWidth = clamp(
2476
- self.colWidth * scaleFactor,
2477
- minColWidth,
2478
- maxCellSize,
2479
- )
2480
- self.rowHeight = clamp(
2481
- self.rowHeight * scaleFactor,
2482
- minRowHeight,
2483
- maxCellSize,
2484
- )
3029
+ if (axis !== 'vertical') {
3030
+ self.colWidth = clamp(
3031
+ self.colWidth * scaleFactor,
3032
+ minColWidth,
3033
+ maxCellSize,
3034
+ )
3035
+ }
3036
+ if (axis !== 'horizontal') {
3037
+ self.rowHeight = clamp(
3038
+ self.rowHeight * scaleFactor,
3039
+ minRowHeight,
3040
+ maxCellSize,
3041
+ )
3042
+ }
2485
3043
  self.scrollX = clamp(
2486
3044
  offsetX - colInView * self.colWidth,
2487
3045
  self.maxScrollX,
@@ -2489,9 +3047,7 @@ function stateModelFactory() {
2489
3047
  )
2490
3048
 
2491
3049
  const anchoredScrollY = offsetY - rowInView * self.rowHeight
2492
- // maxScrollY is -(totalHeight - visibleMsaHeight) when the alignment
2493
- // overflows, so -maxScrollY is exactly that overflow past the
2494
- // scrollable MSA viewport (0 when it fits)
3050
+ // -maxScrollY is the overflow past the viewport, 0 when it fits
2495
3051
  const overflow = Math.max(0, -self.maxScrollY)
2496
3052
  const visibleHeight = self.totalHeight - overflow
2497
3053
  const topBias =
@@ -2520,13 +3076,11 @@ function stateModelFactory() {
2520
3076
 
2521
3077
  /**
2522
3078
  * #action
2523
- * Set the overlay annotations (an empty list clears them). Every source
2524
- * funnels through here after its own adapter has flattened it:
2525
- * InterProScan, GFF, user uploads, NCBI CDD.
3079
+ * Set the overlay annotations (an empty list clears them). InterProScan,
3080
+ * GFF, user uploads and NCBI CDD all arrive here as Annotation[].
2526
3081
  *
2527
- * It does not touch `showDomains`. Loading used to force the overlay on,
2528
- * and since a restored snapshot loads its GFF again on the way in, a link
2529
- * shared with the overlay hidden reopened with it drawn.
3082
+ * Leaves `showDomains` alone, because a restored snapshot reloads its
3083
+ * GFF and must keep a hidden overlay hidden.
2530
3084
  */
2531
3085
  setAnnotations(annotations: Annotation[]) {
2532
3086
  self.annotations = annotations
@@ -2566,6 +3120,28 @@ function stateModelFactory() {
2566
3120
  setColumnTracks(tracks: ColumnTrackSpec[]) {
2567
3121
  self.columnTracks.replace(tracks)
2568
3122
  },
3123
+ /**
3124
+ * #action
3125
+ * replace the row table, which the model keeps as the JSON string
3126
+ * `data.treeMetadata` (see docs/layers.md)
3127
+ */
3128
+ setRowData(rowData: Record<string, Record<string, string>>) {
3129
+ self.data.setTreeMetadata(JSON.stringify(rowData))
3130
+ },
3131
+ /**
3132
+ * #action
3133
+ * replace what the viewer's marks read from the row table
3134
+ */
3135
+ setEncodings(encodings: Encoding[]) {
3136
+ self.encodings.replace(encodings)
3137
+ },
3138
+ /**
3139
+ * #action
3140
+ * replace the panels drawn between the tree and the alignment
3141
+ */
3142
+ setRowPanels(panels: RowPanelSpec[]) {
3143
+ self.rowPanels.replace(panels)
3144
+ },
2569
3145
  /**
2570
3146
  * #action
2571
3147
  * replace the alignment<->structure correspondence (see docs/layers.md)
@@ -2577,8 +3153,6 @@ function stateModelFactory() {
2577
3153
  * #action
2578
3154
  */
2579
3155
  toggleTrack(id: string) {
2580
- // the stored value is "is off", so the current shown state is exactly
2581
- // what the flipped entry should hold
2582
3156
  const defaultOff = self.MSA?.tracks.find(t => t.id === id)?.defaultOff
2583
3157
  self.turnedOffTracks.set(
2584
3158
  id,
@@ -2601,18 +3175,78 @@ function stateModelFactory() {
2601
3175
  },
2602
3176
  /**
2603
3177
  * #getter
2604
- * width of the alignment canvas itself: the msa area less the vertical
2605
- * scrollbar sitting in it. Not usable from showHorizontalScrollbar, which
2606
- * feeds msaAreaHeight -> showVerticalScrollbar and would close a cycle
3178
+ * width of the alignment canvas: the msa area less the vertical
3179
+ * scrollbar. showHorizontalScrollbar must not read it, since that feeds
3180
+ * msaAreaHeight -> showVerticalScrollbar and would form a cycle
2607
3181
  */
2608
3182
  get msaCanvasWidth() {
2609
3183
  return self.msaAreaWidth - this.verticalScrollbarWidth
2610
3184
  },
3185
+ /**
3186
+ * #method
3187
+ * the cell at a visible column and row index, in the coordinates a host
3188
+ * writes highlights in
3189
+ */
3190
+ cellAt(visibleCol: number, rowIndex?: number): Cell {
3191
+ const column = self.visibleColToGlobalCol(visibleCol)
3192
+ const row =
3193
+ rowIndex === undefined ? undefined : self.leaves[rowIndex]?.data.name
3194
+ return row === undefined
3195
+ ? { column: column + 1 }
3196
+ : {
3197
+ column: column + 1,
3198
+ row,
3199
+ residue: self.visibleColToSeqPosOneBased(row, visibleCol),
3200
+ letter: self.rowMap.get(row)?.[column],
3201
+ }
3202
+ },
3203
+ /**
3204
+ * #getter
3205
+ * the cell under the pointer. Public API: MSAViewer's onCellHover
3206
+ * reports it.
3207
+ */
3208
+ get hoveredCell() {
3209
+ const { mouseCol, mouseRow } = self
3210
+ return mouseCol === undefined
3211
+ ? undefined
3212
+ : this.cellAt(mouseCol, mouseRow)
3213
+ },
3214
+ /**
3215
+ * #getter
3216
+ * the cell a click pinned. Public API: MSAViewer's onCellClick reports it.
3217
+ */
3218
+ get clickedCell() {
3219
+ const { mouseClickCol, mouseClickRow } = self
3220
+ return mouseClickCol === undefined
3221
+ ? undefined
3222
+ : this.cellAt(mouseClickCol, mouseClickRow)
3223
+ },
3224
+ /**
3225
+ * #getter
3226
+ * the columns on screen. Public API: MSAViewer's onViewportChange reports
3227
+ * it.
3228
+ */
3229
+ get viewport(): Viewport | undefined {
3230
+ const { scrollX, colWidth, numColumns, viewInitialized } = self
3231
+ if (numColumns === 0 || !viewInitialized) {
3232
+ return undefined
3233
+ }
3234
+ const { xStart, xEnd } = visibleColRange({
3235
+ offsetX: -scrollX,
3236
+ blockWidth: this.msaCanvasWidth,
3237
+ colWidth,
3238
+ })
3239
+ const last = Math.max(0, Math.min(xEnd, numColumns) - 1)
3240
+ return {
3241
+ startColumn: self.visibleColToGlobalCol(Math.min(xStart, last)) + 1,
3242
+ endColumn: self.visibleColToGlobalCol(last) + 1,
3243
+ }
3244
+ },
2611
3245
  /**
2612
3246
  * #getter
2613
3247
  * ordinal segment types (exons etc.), ordered by sequence position so
2614
- * exon-1..exon-14 read left-to-right; colored by alternating shade and
2615
- * labeled by number rather than each getting a distinct hue + legend row
3248
+ * exon-1..exon-14 run left-to-right; colored by alternating shade and
3249
+ * labeled by number, with no legend row
2616
3250
  */
2617
3251
  get segmentDomainTypes() {
2618
3252
  return [...self.annotationTypes.values()]
@@ -2644,10 +3278,48 @@ function stateModelFactory() {
2644
3278
  get strokePalette() {
2645
3279
  return transform(this.fillPalette, ([key, val]) => [
2646
3280
  key,
2647
- colord(val).darken(0.1).toHex(),
3281
+ outlineColor(val),
2648
3282
  ])
2649
3283
  },
2650
3284
 
3285
+ /**
3286
+ * #getter
3287
+ * the encoding coloring the overlay's spans, undefined when none does,
3288
+ * which leaves each span the color its accession takes in `fillPalette`
3289
+ */
3290
+ get featureFillEncoding(): ResolvedEncoding | undefined {
3291
+ return this.resolvedEncodings.find(e => e.channel === 'featureFill')
3292
+ },
3293
+
3294
+ /**
3295
+ * #getter
3296
+ * the fill and outline of every feature's span: its own GFF `color=`
3297
+ * first, then the `featureFill` scale, then the accession palette.
3298
+ * Computed once per change of the features, the encodings or the palette
3299
+ */
3300
+ get featureColors(): Map<Annotation, { fill: string; stroke: string }> {
3301
+ return featureColorMap(
3302
+ self.filteredAnnotations,
3303
+ this.featureFillEncoding,
3304
+ this.fillPalette,
3305
+ )
3306
+ },
3307
+
3308
+ /**
3309
+ * #getter
3310
+ * the text the `featureLabel` channel draws inside each span, undefined
3311
+ * when no encoding names the channel. A data channel, so it draws
3312
+ * whether or not the residue letters do
3313
+ */
3314
+ get featureLabels(): Map<Annotation, string> | undefined {
3315
+ const encoding = this.resolvedEncodings.find(
3316
+ e => e.channel === 'featureLabel',
3317
+ )
3318
+ return encoding
3319
+ ? featureLabelMap(self.filteredAnnotations, encoding.field)
3320
+ : undefined
3321
+ },
3322
+
2651
3323
  /**
2652
3324
  * #getter
2653
3325
  * accession -> number drawn on each segment band: the trailing number of
@@ -2665,9 +3337,9 @@ function stateModelFactory() {
2665
3337
  /**
2666
3338
  * #getter
2667
3339
  * the domain types currently drawn on the alignment (filtered-on), shared
2668
- * by the on-screen legend and the SVG export legend. Ordinal segments
2669
- * (exons) are excluded — they read as a numbered gene model, not a color
2670
- * key — so this is the categorical types ordered by sequence position
3340
+ * by the on-screen legend and the SVG export legend: the categorical types
3341
+ * ordered by sequence position. Ordinal segments (exons) are numbered on
3342
+ * the band instead
2671
3343
  */
2672
3344
  get visibleDomainTypes() {
2673
3345
  return this.categoricalDomainTypes
@@ -2675,15 +3347,94 @@ function stateModelFactory() {
2675
3347
  .toSorted((a, b) => a.start - b.start)
2676
3348
  },
2677
3349
 
3350
+ /**
3351
+ * #getter
3352
+ * the categorical color keys drawn for this view, shared by the on-screen
3353
+ * legend overlay and the SVG export's reserved column. The domain overlay
3354
+ * produces the first, listing the `featureFill` scale where an encoding
3355
+ * names one. Every field a row-table encoding or a row panel reads
3356
+ * produces one more, so two channels over one field, or two strips over
3357
+ * it, list that field once. A strip's `legend` names the title it lists
3358
+ * under, so strips over eight fields with one set of colors list one
3359
+ * legend between them
3360
+ */
3361
+ get legends(): Legend[] {
3362
+ const { featureFillEncoding, fillPalette, visibleDomainTypes } = this
3363
+ const byKey = new Map<string, Legend>()
3364
+ // the accession palette keys on no field, so it takes a key of its own
3365
+ const add = (
3366
+ field: string | undefined,
3367
+ entries: LegendEntry[],
3368
+ id?: string,
3369
+ ) => {
3370
+ if (entries.length === 0) {
3371
+ return
3372
+ }
3373
+ const key = field === undefined ? 'domains' : `field:${field}`
3374
+ const legend = byKey.get(key)
3375
+ if (legend) {
3376
+ const seen = new Set(legend.entries.map(e => e.id))
3377
+ legend.entries.push(...entries.filter(e => !seen.has(e.id)))
3378
+ } else {
3379
+ byKey.set(key, {
3380
+ id: id ?? (field === undefined ? 'domains' : `rowData-${field}`),
3381
+ title: field ?? 'Domains',
3382
+ entries: [...entries],
3383
+ })
3384
+ }
3385
+ }
3386
+ // the overlay draws the domain key; with no columns it draws nothing,
3387
+ // and a features panel taking the same colors lists them instead
3388
+ if (self.actuallyShowDomains && self.numColumns > 0) {
3389
+ add(
3390
+ featureFillEncoding?.field,
3391
+ featureFillEncoding
3392
+ ? featureFillEncoding.legend
3393
+ : visibleDomainTypes.map(d => ({
3394
+ id: d.accession,
3395
+ label: d.name,
3396
+ color: fillPalette[d.accession]!,
3397
+ })),
3398
+ 'domains',
3399
+ )
3400
+ }
3401
+ for (const { channel, field, legend } of this.resolvedEncodings) {
3402
+ if (!featureChannels.has(channel)) {
3403
+ add(field, legend)
3404
+ }
3405
+ }
3406
+ for (const { legendTitle, legend } of this.resolvedRowPanels) {
3407
+ add(legendTitle, legend)
3408
+ }
3409
+ return [...byKey.values()]
3410
+ },
3411
+
3412
+ /**
3413
+ * #getter
3414
+ * whether the overlay marks each domain with a bar under its row instead
3415
+ * of filling the row behind the letters. Letter-color mode hands the
3416
+ * background to the color scheme, so a filled box would paint over it and
3417
+ * leave the setting with nothing to show. Sub-row layout already stacks
3418
+ * the boxes clear of the letters, and with the letters too small to draw
3419
+ * the filled box is the only thing left to read.
3420
+ */
3421
+ get domainUnderline() {
3422
+ return (
3423
+ self.actuallyShowDomains &&
3424
+ !self.bgColor &&
3425
+ !self.subFeatureRows &&
3426
+ self.showMsaLetters
3427
+ )
3428
+ },
3429
+
2678
3430
  /**
2679
3431
  * #getter
2680
3432
  * every filtered-on annotation resolved to the visible column span it is
2681
3433
  * drawn across, keyed by row name. Each row is ordered longest-first so a
2682
- * short domain nested inside a long one draws on top of it rather than
2683
- * under it. Resolving these once here rather than inside each canvas
2684
- * block removes a per-feature, per-block sequence position conversion
2685
- * from every redraw, and gives the letter renderer the band colors it
2686
- * needs to keep residues readable on top of the boxes.
3434
+ * nested short domain draws on top, and each band carries the lane the
3435
+ * sub-row layout puts it in. Resolved once here instead of per canvas
3436
+ * block per redraw; the letter renderer also reads the band colors to
3437
+ * pick legible letter colors.
2687
3438
  */
2688
3439
  get domainBands() {
2689
3440
  const { blanks } = self
@@ -2694,14 +3445,11 @@ function stateModelFactory() {
2694
3445
  const rowBands = annotations
2695
3446
  .toSorted((a, b) => len(b) - len(a))
2696
3447
  .map(annotation => {
2697
- // annotation positions are 1-based and inclusive. Both ends count
2698
- // the visible columns in front of a global column, so endCol is
2699
- // the exclusive column after the last residue's own column --
2700
- // the band stops there rather than stretching across a following
2701
- // gap run -- and a residue whose column is itself hidden
2702
- // collapses onto the neighbouring boundary instead of dropping
2703
- // the band. A band whose every column is hidden spans nothing
2704
- // and is left out, as is one naming a row the alignment lacks.
3448
+ // annotation positions are 1-based and inclusive. endCol is
3449
+ // exclusive, one past the last residue's column, so the band does
3450
+ // not extend over a following gap run. An endpoint in a hidden
3451
+ // column moves to the neighboring boundary. Bands with no visible
3452
+ // columns, or naming a missing row, are dropped.
2705
3453
  const start = self.seqPosToGlobalCol(name, annotation.start - 1)
2706
3454
  const end = self.seqPosToGlobalCol(name, annotation.end - 1)
2707
3455
  if (start === undefined || end === undefined) {
@@ -2714,11 +3462,10 @@ function stateModelFactory() {
2714
3462
  : undefined
2715
3463
  })
2716
3464
  .filter(notEmpty)
2717
- // numbered after the drop, so a band that resolved to nothing does
2718
- // not leave an empty sub-row behind it
3465
+ // numbered after filtering, so a dropped band leaves no gap
2719
3466
  .map((band, stackIndex) => ({ ...band, stackIndex }))
2720
3467
  if (rowBands.length > 0) {
2721
- bands.set(name, rowBands)
3468
+ bands.set(name, packDomainLanes(rowBands))
2722
3469
  }
2723
3470
  }
2724
3471
  return bands
@@ -2755,9 +3502,6 @@ function stateModelFactory() {
2755
3502
  const hits = (this.domainBands.get(name) ?? [])
2756
3503
  .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
2757
3504
  .map(b => b.annotation)
2758
- // the shared empty array, so moving the mouse across an alignment with
2759
- // no annotations does not hand every canvas block a new value to
2760
- // re-render on
2761
3505
  return hits.length > 0 ? hits : noDomains
2762
3506
  },
2763
3507
 
@@ -2776,8 +3520,7 @@ function stateModelFactory() {
2776
3520
  * #getter
2777
3521
  * row indices highlighted by the current tree hover (a hovered internal
2778
3522
  * node highlights every tip below it). Shared by the tree and MSA overlay
2779
- * canvases so they cannot disagree, and resolved through the memoized
2780
- * name->index map rather than rebuilding a lookup on each mouse move.
3523
+ * canvases, via the memoized name->index map.
2781
3524
  */
2782
3525
  get hoveredRowIndices() {
2783
3526
  const { hoveredTreeNode, rowNamesSet } = self
@@ -2791,8 +3534,8 @@ function stateModelFactory() {
2791
3534
  /**
2792
3535
  * #getter
2793
3536
  * contiguous runs of `highlightedColumns`, so a run of highlighted columns
2794
- * draws as one bordered band. Computed here because the overlay canvas
2795
- * redraws on every mouse move while the highlight itself rarely changes.
3537
+ * draws as one bordered band. Memoized because the overlay canvas redraws
3538
+ * on every mouse move.
2796
3539
  */
2797
3540
  get highlightedColumnRuns() {
2798
3541
  const { highlightedColumns } = self
@@ -2818,13 +3561,8 @@ function stateModelFactory() {
2818
3561
  * is dropped. Row names that match no row are ignored.
2819
3562
  */
2820
3563
  get resolvedHighlights(): ResolvedHighlight[] {
2821
- const { blanks, rowNamesSet, transientHighlights } = self
2822
- const toVisible = (globalCol: number) => {
2823
- const visible = self.globalColToVisibleCol(globalCol)
2824
- return visible ?? visibleColsBefore(blanks, globalCol)
2825
- }
2826
- // the document's own highlights first, then what each owner is showing
2827
- // right now, so a hover draws over a persisted band rather than under it
3564
+ const { rowNamesSet, transientHighlights } = self
3565
+ // persisted highlights first, so transient ones draw on top
2828
3566
  const all = [
2829
3567
  ...self.highlights,
2830
3568
  ...Object.values(transientHighlights).flat(),
@@ -2840,72 +3578,272 @@ function stateModelFactory() {
2840
3578
  if (start === undefined || end === undefined) {
2841
3579
  return []
2842
3580
  }
2843
- let startGlobal = start - 1
2844
- let endGlobal = end - 1
2845
- if (row !== undefined) {
2846
- const rowStart = self.seqPosToGlobalCol(row, start - 1)
2847
- const rowEnd = self.seqPosToGlobalCol(row, end - 1)
2848
- if (rowStart === undefined || rowEnd === undefined) {
2849
- return []
3581
+ const span = self.visibleSpan({ row, start, end })
3582
+ return span ? [{ ...base, ...span, rowIndices: [] }] : []
3583
+ })
3584
+ },
3585
+
3586
+ /**
3587
+ * #method
3588
+ * per-column summary statistics: consensus residue and its identity
3589
+ * fraction, both conservation scores, gap fraction, and the sorted non-gap
3590
+ * residue distribution. undefined past the end of the alignment or for an
3591
+ * all-gap column.
3592
+ */
3593
+ columnStatsAt(col: number): ColumnStats | undefined {
3594
+ const { colStats, conservation, propertyConservation } = self
3595
+ return columnStats({
3596
+ col,
3597
+ colStats,
3598
+ conservation,
3599
+ propertyConservation,
3600
+ })
3601
+ },
3602
+
3603
+ /**
3604
+ * #getter
3605
+ * `columnStatsAt` for the hovered column, undefined when nothing is
3606
+ * hovered
3607
+ */
3608
+ get mouseOverColumnStats(): ColumnStats | undefined {
3609
+ const { mouseCol, colStats, conservation, propertyConservation } = self
3610
+ return mouseCol === undefined
3611
+ ? undefined
3612
+ : columnStats({
3613
+ col: mouseCol,
3614
+ colStats,
3615
+ conservation,
3616
+ propertyConservation,
3617
+ })
3618
+ },
3619
+
3620
+ /**
3621
+ * #method
3622
+ */
3623
+ getRowData(name: string) {
3624
+ return {
3625
+ data: self.MSA?.getRowData(name),
3626
+ rowData: self.rowDataOf(name),
3627
+ }
3628
+ },
3629
+
3630
+ /**
3631
+ * #getter
3632
+ * each encoding with its scale resolved against the values its field
3633
+ * takes: a feature channel reads them across the features drawn, every
3634
+ * other channel across the row table. Resolved once per change of that
3635
+ * table or the encodings, never per row per frame.
3636
+ */
3637
+ get resolvedEncodings(): ResolvedEncoding[] {
3638
+ const rows = Object.values(self.rowData)
3639
+ const features = self.filteredAnnotations
3640
+ return self.encodings.map(encoding => ({
3641
+ ...encoding,
3642
+ ...resolveScale(
3643
+ encoding.scale,
3644
+ featureChannels.has(encoding.channel)
3645
+ ? features
3646
+ .map(a => featureField(a, encoding.field))
3647
+ .filter(notEmpty)
3648
+ : rows.map(row => row?.[encoding.field]).filter(notEmpty),
3649
+ ),
3650
+ }))
3651
+ },
3652
+
3653
+ /**
3654
+ * #getter
3655
+ * each row panel with its scale resolved against the values its field
3656
+ * takes across the row table, giving the color per row name, the pixel
3657
+ * column it draws in, and the entries its legend lists. Resolved once
3658
+ * per change of that table or the panels, never per block per frame.
3659
+ */
3660
+ get resolvedRowPanels(): ResolvedRowPanel[] {
3661
+ const rows = Object.entries(self.rowData)
3662
+ const { fillPalette, featureFillEncoding, featureLabels } = this
3663
+ const annotations = self.filteredAnnotations
3664
+ let offsetX = 0
3665
+ return self.rowPanels.map((panel, index) => {
3666
+ const width = rowPanelWidth(panel, self.rowHeight)
3667
+ const base = { id: `rowpanel-${index}`, width, offsetX }
3668
+ offsetX += width
3669
+ if (panel.kind === 'features') {
3670
+ const color = panel.encoding?.color
3671
+ const encoding = color
3672
+ ? resolveFeatureScale(color.field, color.scale, annotations)
3673
+ : featureFillEncoding
3674
+ const label = panel.encoding?.label
3675
+ const align = panel.transform?.find(t => t.type === 'align')
3676
+ return {
3677
+ ...base,
3678
+ kind: panel.kind,
3679
+ x: panel.x,
3680
+ header: panel.header ?? '',
3681
+ field: encoding?.field,
3682
+ legendTitle: encoding?.field,
3683
+ spans: featurePanelSpans({
3684
+ panel,
3685
+ width,
3686
+ rowHeight: self.rowHeight,
3687
+ colWidth: self.colWidth,
3688
+ domainBands: this.domainBands,
3689
+ annotationsByRow: self.annotationsByRow,
3690
+ shifts: align
3691
+ ? this.featureAlignShifts.get(align.on)
3692
+ : undefined,
3693
+ }),
3694
+ colors: featureColorMap(annotations, encoding, fillPalette),
3695
+ labels: label
3696
+ ? featureLabelMap(annotations, label)
3697
+ : featureLabels,
3698
+ legend:
3699
+ encoding?.legend ??
3700
+ this.visibleDomainTypes.map(d => ({
3701
+ id: d.accession,
3702
+ label: d.name,
3703
+ color: fillPalette[d.accession]!,
3704
+ })),
3705
+ }
3706
+ }
3707
+ const { colorOf, legend } = resolveScale(
3708
+ panel.scale,
3709
+ rows.map(([, row]) => row?.[panel.field]).filter(notEmpty),
3710
+ )
3711
+ const colors = new Map<string, string>()
3712
+ for (const [name, row] of rows) {
3713
+ const value = row?.[panel.field]
3714
+ const color = value === undefined ? undefined : colorOf(value)
3715
+ if (color) {
3716
+ colors.set(name, color)
2850
3717
  }
2851
- startGlobal = rowStart
2852
- endGlobal = rowEnd
2853
3718
  }
2854
- const startCol = toVisible(startGlobal)
2855
- const endVisible = self.globalColToVisibleCol(endGlobal)
2856
- const endCol = endVisible ?? visibleColsBefore(blanks, endGlobal) - 1
2857
- return startCol <= endCol
2858
- ? [{ ...base, startCol, endCol, rowIndices: [] }]
2859
- : []
3719
+ return {
3720
+ ...base,
3721
+ kind: panel.kind,
3722
+ field: panel.field,
3723
+ header: panel.header ?? panel.field,
3724
+ legendTitle: panel.legend ?? panel.field,
3725
+ colors,
3726
+ legend,
3727
+ }
2860
3728
  })
2861
3729
  },
2862
3730
 
2863
3731
  /**
2864
3732
  * #getter
2865
- * per-column summary statistics for the hovered column: consensus residue
2866
- * and its identity fraction, conservation score, gap fraction, and the
2867
- * sorted non-gap residue distribution. undefined when nothing is hovered.
3733
+ * the shift an `align` transform gives each row, keyed by the feature
3734
+ * name it aligns on: the offset putting the first feature of that name
3735
+ * at zero. A row carrying no such feature is absent, and keeps its own
3736
+ * origin.
3737
+ */
3738
+ get featureAlignShifts(): Map<string, Map<string, number>> {
3739
+ const names = new Set(
3740
+ self.rowPanels.flatMap(panel =>
3741
+ panel.kind === 'features' && panel.x === 'position'
3742
+ ? (panel.transform ?? [])
3743
+ .filter(t => t.type === 'align')
3744
+ .map(t => t.on)
3745
+ : [],
3746
+ ),
3747
+ )
3748
+ return new Map(
3749
+ [...names].map(on => [
3750
+ on,
3751
+ new Map(
3752
+ Object.entries(self.annotationsByRow)
3753
+ .map(([name, annotations]) => {
3754
+ const hit = annotations.find(a => featureName(a) === on)
3755
+ return hit ? ([name, 1 - hit.start] as const) : undefined
3756
+ })
3757
+ .filter(notEmpty),
3758
+ ),
3759
+ ]),
3760
+ )
3761
+ },
3762
+
3763
+ /**
3764
+ * #getter
3765
+ * the color the `tipLabel` channel gives each row, by row name. Undefined
3766
+ * when no encoding names the channel, which leaves the labels the theme's
3767
+ * text color.
2868
3768
  */
2869
- get mouseOverColumnStats() {
2870
- const { mouseCol } = self
2871
- if (mouseCol === undefined) {
3769
+ get tipLabelColors(): Map<string, string> | undefined {
3770
+ const encoding = this.resolvedEncodings.find(
3771
+ e => e.channel === 'tipLabel',
3772
+ )
3773
+ if (!encoding) {
2872
3774
  return undefined
2873
3775
  }
2874
- const { colStats } = self
2875
- if (mouseCol >= colStats.numColumns) {
2876
- return undefined
3776
+ const colors = new Map<string, string>()
3777
+ for (const [name, row] of Object.entries(self.rowData)) {
3778
+ const value = row?.[encoding.field]
3779
+ const color =
3780
+ value === undefined ? undefined : encoding.colorOf(value)
3781
+ if (color) {
3782
+ colors.set(name, color)
3783
+ }
2877
3784
  }
2878
- const total = colStats.total(mouseCol)
2879
- if (!total) {
3785
+ return colors
3786
+ },
3787
+
3788
+ /**
3789
+ * #getter
3790
+ * the wash the `rowTint` channel draws over each row, indexed by row, or
3791
+ * undefined when no encoding names the channel. The overlay draws these,
3792
+ * so a tint stays out of the raster tile cache and its keys.
3793
+ */
3794
+ get rowTints(): (string | undefined)[] | undefined {
3795
+ const encoding = this.resolvedEncodings.find(
3796
+ e => e.channel === 'rowTint',
3797
+ )
3798
+ if (!encoding) {
2880
3799
  return undefined
2881
3800
  }
2882
- const gaps = colStats.gapCount(mouseCol)
2883
- const distribution = colStats
2884
- .residueEntries(mouseCol)
2885
- .sort((a, b) => b[1] - a[1])
2886
- const consensus = distribution[0]
2887
- return {
2888
- col: mouseCol,
2889
- total,
2890
- gaps,
2891
- gapFraction: gaps / total,
2892
- conservation: self.conservation[mouseCol] ?? 0,
2893
- propertyConservation: self.propertyConservation[mouseCol],
2894
- consensusLetter: consensus?.[0] ?? '',
2895
- consensusCount: consensus?.[1] ?? 0,
2896
- consensusFraction: consensus ? consensus[1] / total : 0,
2897
- distribution,
2898
- }
3801
+ return self.rowNames.map(name => {
3802
+ const value = self.rowDataOf(name)?.[encoding.field]
3803
+ const color =
3804
+ value === undefined ? undefined : encoding.colorOf(value)
3805
+ if (!color) {
3806
+ return undefined
3807
+ }
3808
+ return withAlpha(color, rowTintAlpha)
3809
+ })
2899
3810
  },
2900
3811
 
2901
3812
  /**
2902
- * #method
3813
+ * #getter
3814
+ * the color the `branch` channel gives each tree edge, by the node id at
3815
+ * the edge's far end, or undefined when no encoding names the channel. A
3816
+ * node takes the field value its tips agree on, so a clade of one value
3817
+ * colors down from where it splits off, and a node whose tips disagree or
3818
+ * whose value has no color is absent and draws in the default color.
3819
+ *
3820
+ * The pass runs over the whole tree, never `root`, so a collapsed or
3821
+ * focused clade keeps the color the full tree gives it.
2903
3822
  */
2904
- getRowData(name: string) {
2905
- return {
2906
- data: self.MSA?.getRowData(name),
2907
- treeMetadata: self.treeMetadata[name],
3823
+ get branchColors(): Map<string, string> | undefined {
3824
+ const encoding = this.resolvedEncodings.find(
3825
+ e => e.channel === 'branch',
3826
+ )
3827
+ if (!encoding) {
3828
+ return undefined
2908
3829
  }
3830
+ const order = preorder(self.tree)
3831
+ const values = new Map<NodeWithIds, string | undefined>()
3832
+ const colors = new Map<string, string>()
3833
+ for (let i = order.length - 1; i >= 0; i--) {
3834
+ const node = order[i]!
3835
+ const value =
3836
+ node.children.length > 0
3837
+ ? sharedValue(node.children.map(child => values.get(child)))
3838
+ : self.rowDataOf(node.name)?.[encoding.field]
3839
+ values.set(node, value)
3840
+ const color =
3841
+ value === undefined ? undefined : encoding.colorOf(value)
3842
+ if (color) {
3843
+ colors.set(node.id, color)
3844
+ }
3845
+ }
3846
+ return colors
2909
3847
  },
2910
3848
  }))
2911
3849
  .actions(self => ({
@@ -2918,33 +3856,34 @@ function stateModelFactory() {
2918
3856
  /**
2919
3857
  * #action
2920
3858
  */
2921
- setConservationTrackHeight(arg: number) {
2922
- self.conservationTrackHeight = arg
3859
+ setHideHeader(arg: boolean) {
3860
+ self.hideHeader = arg
2923
3861
  },
2924
3862
  /**
2925
3863
  * #action
3864
+ * focus the subtree a click `y` pixels down the tree overview lands on.
3865
+ * A click inside the box already drawn there clears the focus, the way
3866
+ * clicking the focused branch again does.
2926
3867
  */
2927
- setColumnTrackHeight(id: string, height: number) {
2928
- self.columnTrackHeights = { ...self.columnTrackHeights, [id]: height }
3868
+ treeOverviewClick(y: number) {
3869
+ const hit = self.treeOverviewHit(y)
3870
+ const focus = self.treeOverviewFocusRows
3871
+ const inside =
3872
+ !!focus && !!hit && hit.rows[0] >= focus[0] && hit.rows[1] <= focus[1]
3873
+ self.setShowOnly(inside || !hit ? undefined : hit.id)
2929
3874
  },
2930
3875
  /**
2931
3876
  * #action
3877
+ * resize every track sharing a `heightKey`; see `trackHeights`
2932
3878
  */
2933
- setSequenceLogoTrackHeight(arg: number) {
2934
- self.sequenceLogoTrackHeight = arg
3879
+ setTrackHeight(heightKey: string, height: number) {
3880
+ self.trackHeights.set(heightKey, height)
2935
3881
  },
2936
3882
  /**
2937
3883
  * #action
2938
- */
2939
- setArcTrackHeight(arg: number) {
2940
- self.arcTrackHeight = arg
2941
- },
2942
- /**
2943
- * #action
2944
- * Return to the import form: every property off `preservedOnReset`
2945
- * (data, filehandles, collapsed/showOnly, zoom, scroll, ...) goes back
2946
- * to its default, then the file-derived volatiles applySnapshot cannot
2947
- * reach are cleared by hand.
3884
+ * Return to the import form: reset every property not in
3885
+ * `preservedOnReset` to its default, then clear the file-derived
3886
+ * volatiles applySnapshot does not touch.
2948
3887
  */
2949
3888
  reset() {
2950
3889
  self.resetCount++
@@ -2977,8 +3916,8 @@ function stateModelFactory() {
2977
3916
  },
2978
3917
  /**
2979
3918
  * #action
2980
- * draw this annotation type, or stop drawing it. Only the "stop" is
2981
- * recorded -- see `turnedOffFeatures`
3919
+ * show or hide an annotation type. Only hidden types are recorded; see
3920
+ * `turnedOffFeatures`
2982
3921
  */
2983
3922
  setFilter(accession: string, shown: boolean) {
2984
3923
  if (shown) {
@@ -2992,12 +3931,8 @@ function stateModelFactory() {
2992
3931
  * #action
2993
3932
  */
2994
3933
  fit() {
2995
- // Each direction's viewport depends on the other's result: fitting the
2996
- // rows while the columns still overflow measures against a height the
2997
- // minimap is taking, and fitting the columns while the rows still
2998
- // overflow measures against a width the vertical scrollbar is taking.
2999
- // A second pass measures against the geometry the first pass produced,
3000
- // which is the one the reader ends up looking at.
3934
+ // two passes: each direction's viewport depends on whether the other
3935
+ // overflows (the minimap takes height, the vertical scrollbar width)
3001
3936
  transaction(() => {
3002
3937
  for (let pass = 0; pass < 2; pass++) {
3003
3938
  this.fitHorizontally()
@@ -3018,14 +3953,36 @@ function stateModelFactory() {
3018
3953
  }
3019
3954
  self.scrollY = 0
3020
3955
  },
3956
+ /**
3957
+ * #action
3958
+ * zoom and scroll so a span fills the alignment's width, in highlight
3959
+ * coordinates (see visibleSpan). Does nothing before the viewer knows
3960
+ * its width, or for a span that resolves to no visible column.
3961
+ */
3962
+ zoomToRegion(region: Region) {
3963
+ const span = self.visibleSpan(region)
3964
+ if (!span || !self.viewInitialized) {
3965
+ return
3966
+ }
3967
+ transaction(() => {
3968
+ self.colWidth = clamp(
3969
+ self.msaCanvasWidth / (span.endCol - span.startCol + 1),
3970
+ minColWidth,
3971
+ maxCellSize,
3972
+ )
3973
+ self.scrollX = clamp(
3974
+ -span.startCol * self.colWidth,
3975
+ self.maxScrollX,
3976
+ 0,
3977
+ )
3978
+ })
3979
+ },
3021
3980
  /**
3022
3981
  * #action
3023
3982
  */
3024
3983
  fitHorizontally() {
3025
3984
  if (self.numColumns > 0) {
3026
- // fitting to msaAreaWidth instead left the last ~20px of columns off
3027
- // the right edge -- and short of the width that shows a minimap, so
3028
- // nothing on screen said they were there
3985
+ // msaCanvasWidth excludes the vertical scrollbar's 20px
3029
3986
  self.colWidth = clamp(
3030
3987
  self.msaCanvasWidth / self.numColumns,
3031
3988
  minColWidth,
@@ -3036,17 +3993,43 @@ function stateModelFactory() {
3036
3993
  },
3037
3994
 
3038
3995
  afterCreate() {
3039
- // seed the highlighted-columns overlay from the declarative property so
3040
- // a shared snapshot/URL opens with those columns highlighted (the
3041
- // volatile highlightedColumns can later be driven by genome-hover sync)
3042
3996
  if (self.highlightColumns?.length) {
3043
3997
  self.setHighlightedColumns(self.highlightColumns)
3044
3998
  }
3045
3999
 
3046
- // track the live device pixel ratio so canvas backing stores re-scale
3047
- // when the window moves between monitors or the browser zooms. The
3048
- // matchMedia query is pinned to the current ratio, so each change
3049
- // re-registers against the new one to keep tracking further moves.
4000
+ // The `collapse` and `focus` clade marks seed the collapsed list and
4001
+ // the subtree in focus, which the tree, `hideGapsEffective` and the
4002
+ // alignment all read. The tree arrives with the model for inline data
4003
+ // and later for a filehandle, so the seeding waits for it and then runs
4004
+ // once: expanding a seeded clade sticks, and the record collapses it
4005
+ // again only on reload.
4006
+ let cladesSeeded = false
4007
+ addDisposer(
4008
+ self,
4009
+ autorun(() => {
4010
+ if (
4011
+ cladesSeeded ||
4012
+ !self.dataInitialized ||
4013
+ self.clades.length === 0
4014
+ ) {
4015
+ return
4016
+ }
4017
+ cladesSeeded = true
4018
+ for (const { mark, nodeId } of self.resolvedClades) {
4019
+ if (nodeId === undefined) {
4020
+ continue
4021
+ }
4022
+ if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
4023
+ self.toggleCollapsed(nodeId)
4024
+ } else if (mark === 'focus') {
4025
+ self.setShowOnly(nodeId)
4026
+ }
4027
+ }
4028
+ }),
4029
+ )
4030
+
4031
+ // the matchMedia query is pinned to the current device pixel ratio, so
4032
+ // each change re-registers against the new one
3050
4033
  if (
3051
4034
  typeof window !== 'undefined' &&
3052
4035
  typeof window.matchMedia === 'function'
@@ -3070,25 +4053,17 @@ function stateModelFactory() {
3070
4053
  * Fetch a filehandle whenever it changes, and hand the text to
3071
4054
  * `onLoad`.
3072
4055
  *
3073
- * Every loader carries a generation guard: the filehandle can change
3074
- * mid-fetch (a second file picked while the first is still in flight),
3075
- * and the slower earlier request must not clobber the data, status, or
3076
- * loading flag belonging to the newer one. A superseded or cleared
3077
- * request is also aborted, rather than left downloading a file nothing
3078
- * is waiting for, and it gives back the status line it was writing --
3079
- * a reset() mid-download used to leave "Downloading file" and a Cancel
3080
- * button behind on the import form.
4056
+ * A generation guard keeps a slower superseded request from overwriting
4057
+ * the newer one's data, status or loading flag. A superseded or cleared
4058
+ * request is aborted and clears its status line.
3081
4059
  *
3082
- * `clearFilehandle` serves two purposes for the loaders that pass it.
3083
- * A local file has no URL to refetch from, so the handle is dropped
3084
- * once its bytes are in the model; and a fetch the user cancels drops
3085
- * it too, returning the view to the import form rather than leaving a
3086
- * stuck spinner.
4060
+ * `clearFilehandle` runs after a local file loads, since a blob has no
4061
+ * URL to refetch, and when the user cancels, which returns the view to
4062
+ * the import form.
3087
4063
  *
3088
- * `what` names the layer in a failure message. An optional layer
3089
- * (annotations, row metadata) that fails to load is a warning: it is
3090
- * not worth replacing an alignment the reader is looking at with an
3091
- * error screen over a decorative file.
4064
+ * `what` names the layer in a failure message. An `optional` layer
4065
+ * (annotations, row metadata) that fails adds a warning instead of an
4066
+ * error.
3092
4067
  */
3093
4068
  const loadOnFilehandleChange = ({
3094
4069
  what,
@@ -3186,8 +4161,7 @@ function stateModelFactory() {
3186
4161
  },
3187
4162
  })
3188
4163
 
3189
- // treeMetadata is decorative and has no import-form step of its own, so
3190
- // it keeps no loading flag and nothing to return to on cancel
4164
+ // treeMetadata has no import-form step, so no loading flag or cancel
3191
4165
  loadOnFilehandleChange({
3192
4166
  what: 'row metadata',
3193
4167
  optional: true,
@@ -3197,11 +4171,8 @@ function stateModelFactory() {
3197
4171
  },
3198
4172
  })
3199
4173
 
3200
- // autorun parses inline gff text from data.gff. Text that goes away
3201
- // takes its annotations with it -- setData with a new alignment and no
3202
- // gff used to leave the previous file's annotations drawn over it --
3203
- // while annotations a host set directly are left alone, which is why
3204
- // this tracks what it applied instead of reading the current list
4174
+ // parses data.gff into annotations. Clearing the text clears only the
4175
+ // annotations this autorun applied, not ones a host set directly
3205
4176
  let appliedGFF = false
3206
4177
  addDisposer(
3207
4178
  self,
@@ -3212,8 +4183,6 @@ function stateModelFactory() {
3212
4183
  self.applyGFFText(gffText)
3213
4184
  appliedGFF = true
3214
4185
  } catch (e) {
3215
- // a malformed overlay is not worth replacing the alignment
3216
- // with an error screen
3217
4186
  console.error(e)
3218
4187
  self.addWarning(`The annotations did not parse: ${e}`)
3219
4188
  }
@@ -3224,9 +4193,7 @@ function stateModelFactory() {
3224
4193
  }),
3225
4194
  )
3226
4195
 
3227
- // gffFilehandle carries overlay annotations. It loads into data.gff and
3228
- // the autorun above parses it, so there is one parse path whether the
3229
- // text arrived from a file or from a snapshot
4196
+ // gffFilehandle loads into data.gff, which the autorun above parses
3230
4197
  loadOnFilehandleChange({
3231
4198
  what: 'annotations',
3232
4199
  optional: true,
@@ -3253,19 +4220,12 @@ function stateModelFactory() {
3253
4220
  },
3254
4221
  })
3255
4222
 
3256
- // Keep the parse chain warm: reading self.columns transitively holds
3257
- // self.MSA (parseMSA) computed alive, so it is parsed once per data
3258
- // change rather than re-parsed on every non-reactive access. Do not
3259
- // remove.
4223
+ // Keep computeds alive that are read outside reactions, so they are not
4224
+ // recomputed on every access: self.columns (and through it the
4225
+ // parseMSA result), and the column statistics that dynamic color
4226
+ // schemes and the mousemove-driven hover tooltip read.
3260
4227
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
3261
4228
  // xref problem https://github.com/GMOD/react-msaview/issues/75
3262
- //
3263
- // The column statistics are held for the same reason whenever something
3264
- // reads them off the reactive path: dynamic color schemes, and the hover
3265
- // tooltip, which is read from a mousemove handler. Without this the
3266
- // tooltip's cost depends on whether some visible track happens to be
3267
- // observing them, so closing the conservation track would silently turn
3268
- // every mouse move into a full-alignment recount.
3269
4229
  addDisposer(
3270
4230
  self,
3271
4231
  autorun(() => {
@@ -3287,10 +4247,9 @@ function stateModelFactory() {
3287
4247
  }),
3288
4248
  )
3289
4249
 
3290
- // autorun: when autoTreeAreaWidth is set and no tree is drawn, shrink the
3291
- // tree area to fit the row labels rather than padding it to the fixed
3292
- // default. Gated on noTree/!drawTree so it never fights the treeWidth sync
3293
- // below (treeAreaWidth here depends only on labelsWidth, not treeWidth).
4250
+ // with autoTreeAreaWidth and no tree drawn, size the tree area to the
4251
+ // labels. Gated on noTree/!drawTree so it does not loop with the
4252
+ // treeWidth autorun below
3294
4253
  addDisposer(
3295
4254
  self,
3296
4255
  autorun(() => {
@@ -3299,19 +4258,17 @@ function stateModelFactory() {
3299
4258
  (self.noTree || !self.drawTree) &&
3300
4259
  self.labelsWidth
3301
4260
  ) {
3302
- self.setTreeAreaWidth(self.labelsWidth + self.marginLeft + 12)
4261
+ self.setTreeAreaWidth(
4262
+ self.labelsWidth + self.marginLeft + 12 + self.cladeGutterWidth,
4263
+ )
3303
4264
  }
3304
4265
  }),
3305
4266
  )
3306
4267
 
3307
- // treeWidth trails the tree area, less whatever the labels take out of
3308
- // it. A width that arrived with the snapshot is left alone until the
3309
- // tree area itself moves: a host that opens a view with a narrow tree
3310
- // beside wide labels -- jbrowse-plugin-msaview does, at treeWidth 100
3311
- // in a 200px area -- had its value overwritten on the first frame, and
3312
- // a restored session came back re-derived rather than as it was left.
3313
- // A getter cannot do this: labelsWidth is measured off the leaves,
3314
- // which are laid out against treeWidth.
4268
+ // treeWidth follows the tree area less the labels. A non-default
4269
+ // snapshot treeWidth (jbrowse-plugin-msaview opens at 100 in a 200px
4270
+ // area) is kept until the tree area changes. Not a getter, because
4271
+ // labelsWidth is measured off leaves laid out against treeWidth.
3315
4272
  let pinnedAreaWidth =
3316
4273
  self.treeWidth === defaultTreeWidth ? undefined : self.treeAreaWidth
3317
4274
  addDisposer(
@@ -3324,16 +4281,22 @@ function stateModelFactory() {
3324
4281
  }
3325
4282
  pinnedAreaWidth = undefined
3326
4283
  self.setTreeWidth(
3327
- Math.max(50, areaWidth - labelsWidth - 10 - self.marginLeft),
4284
+ Math.max(
4285
+ 50,
4286
+ areaWidth -
4287
+ labelsWidth -
4288
+ 10 -
4289
+ self.marginLeft -
4290
+ self.cladeGutterWidth,
4291
+ ),
3328
4292
  )
3329
4293
  }),
3330
4294
  )
3331
4295
  },
3332
4296
  }))
3333
4297
  .postProcessSnapshot(({ data, columnTracks, ...rest }) => ({
3334
- // per-property defaults are stripped by the stripDefault helper; the only thing
3335
- // it can't express is this cross-field rule: drop inline tree/msa/metadata
3336
- // when a sibling filehandle can refetch them, keeping sessions/URLs small
4298
+ // stripDefault handles per-property defaults; this drops inline documents
4299
+ // whose filehandle can refetch them
3337
4300
  ...rest,
3338
4301
  ...smallColumnTracks(columnTracks),
3339
4302
  data: {