react-msaview 8.2.0 → 8.4.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 (255) hide show
  1. package/bundle/index.js +77 -300
  2. package/bundle/index.js.map +4 -4
  3. package/dist/blockFasta.d.ts +6 -0
  4. package/dist/blockFasta.js +12 -0
  5. package/dist/blockFasta.js.map +1 -0
  6. package/dist/colorSchemes.d.ts +7 -0
  7. package/dist/colorSchemes.js +15 -0
  8. package/dist/colorSchemes.js.map +1 -1
  9. package/dist/columnCounts.d.ts +4 -3
  10. package/dist/columnCounts.js +11 -6
  11. package/dist/columnCounts.js.map +1 -1
  12. package/dist/components/CopyButton.d.ts +1 -1
  13. package/dist/components/CopyButton.js +1 -1
  14. package/dist/components/CopyButton.js.map +1 -1
  15. package/dist/components/DragHandle.js +14 -2
  16. package/dist/components/DragHandle.js.map +1 -1
  17. package/dist/components/Loading.js +10 -11
  18. package/dist/components/Loading.js.map +1 -1
  19. package/dist/components/MSAView.js +15 -9
  20. package/dist/components/MSAView.js.map +1 -1
  21. package/dist/components/MSAViewer.d.ts +39 -7
  22. package/dist/components/MSAViewer.js +72 -22
  23. package/dist/components/MSAViewer.js.map +1 -1
  24. package/dist/components/MetadataTable.d.ts +5 -0
  25. package/dist/components/MetadataTable.js +29 -0
  26. package/dist/components/MetadataTable.js.map +1 -0
  27. package/dist/components/SequenceTextArea.d.ts +11 -0
  28. package/dist/components/SequenceTextArea.js +40 -8
  29. package/dist/components/SequenceTextArea.js.map +1 -1
  30. package/dist/components/StatusMessage.d.ts +12 -0
  31. package/dist/components/StatusMessage.js +43 -0
  32. package/dist/components/StatusMessage.js.map +1 -0
  33. package/dist/components/Track.js +13 -4
  34. package/dist/components/Track.js.map +1 -1
  35. package/dist/components/VerticalScrollbar.js +6 -1
  36. package/dist/components/VerticalScrollbar.js.map +1 -1
  37. package/dist/components/dialogs/ExportSVGDialog.d.ts +3 -2
  38. package/dist/components/dialogs/ExportSVGDialog.js +4 -2
  39. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  40. package/dist/components/dialogs/FeatureDialog.js +15 -10
  41. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  42. package/dist/components/dialogs/MetadataDialog.js +5 -6
  43. package/dist/components/dialogs/MetadataDialog.js.map +1 -1
  44. package/dist/components/getVisibleLeaves.d.ts +1 -1
  45. package/dist/components/header/ColorSchemeMenu.d.ts +6 -0
  46. package/dist/components/header/ColorSchemeMenu.js +23 -5
  47. package/dist/components/header/ColorSchemeMenu.js.map +1 -1
  48. package/dist/components/header/FileMenu.js +9 -0
  49. package/dist/components/header/FileMenu.js.map +1 -1
  50. package/dist/components/header/GappynessSlider.js +15 -3
  51. package/dist/components/header/GappynessSlider.js.map +1 -1
  52. package/dist/components/header/{HeaderStatusArea.d.ts → GoToBox.d.ts} +2 -2
  53. package/dist/components/header/GoToBox.js +21 -0
  54. package/dist/components/header/GoToBox.js.map +1 -0
  55. package/dist/components/header/Header.js +8 -7
  56. package/dist/components/header/Header.js.map +1 -1
  57. package/dist/components/header/SelectionMenu.d.ts +12 -0
  58. package/dist/components/header/SelectionMenu.js +72 -0
  59. package/dist/components/header/SelectionMenu.js.map +1 -0
  60. package/dist/components/header/goTo.d.ts +20 -0
  61. package/dist/components/header/goTo.js +69 -0
  62. package/dist/components/header/goTo.js.map +1 -0
  63. package/dist/components/header/settingsMenuItems.js +57 -1
  64. package/dist/components/header/settingsMenuItems.js.map +1 -1
  65. package/dist/components/import/ImportForm.js +10 -4
  66. package/dist/components/import/ImportForm.js.map +1 -1
  67. package/dist/components/import/ImportFormExamples.js +19 -27
  68. package/dist/components/import/ImportFormExamples.js.map +1 -1
  69. package/dist/components/minimap/Minimap.js +6 -1
  70. package/dist/components/minimap/Minimap.js.map +1 -1
  71. package/dist/components/msa/AnnotationLegend.js +11 -5
  72. package/dist/components/msa/AnnotationLegend.js.map +1 -1
  73. package/dist/components/msa/MSACanvas.js +17 -1
  74. package/dist/components/msa/MSACanvas.js.map +1 -1
  75. package/dist/components/msa/MSACanvasBlock.js +6 -6
  76. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  77. package/dist/components/msa/msaKeys.d.ts +5 -0
  78. package/dist/components/msa/msaKeys.js +41 -0
  79. package/dist/components/msa/msaKeys.js.map +1 -0
  80. package/dist/components/msa/msaRaster.js +1 -0
  81. package/dist/components/msa/msaRaster.js.map +1 -1
  82. package/dist/components/msa/renderHighlights.d.ts +14 -3
  83. package/dist/components/msa/renderHighlights.js +29 -4
  84. package/dist/components/msa/renderHighlights.js.map +1 -1
  85. package/dist/components/msa/tileColor.js +9 -8
  86. package/dist/components/msa/tileColor.js.map +1 -1
  87. package/dist/components/msa/useMsaBlockMouse.d.ts +2 -4
  88. package/dist/components/msa/useMsaBlockMouse.js +34 -14
  89. package/dist/components/msa/useMsaBlockMouse.js.map +1 -1
  90. package/dist/components/overlayColors.d.ts +2 -0
  91. package/dist/components/overlayColors.js +3 -0
  92. package/dist/components/overlayColors.js.map +1 -1
  93. package/dist/components/rowpanels/RowPanelBlock.js +10 -14
  94. package/dist/components/rowpanels/RowPanelBlock.js.map +1 -1
  95. package/dist/components/selectionDrag.d.ts +17 -0
  96. package/dist/components/selectionDrag.js +30 -0
  97. package/dist/components/selectionDrag.js.map +1 -0
  98. package/dist/components/tracks/drawTracks.d.ts +1 -0
  99. package/dist/components/tracks/drawTracks.js +14 -2
  100. package/dist/components/tracks/drawTracks.js.map +1 -1
  101. package/dist/components/tracks/useTrackHover.d.ts +4 -6
  102. package/dist/components/tracks/useTrackHover.js +30 -10
  103. package/dist/components/tracks/useTrackHover.js.map +1 -1
  104. package/dist/components/tree/ScaleBarSVG.d.ts +14 -0
  105. package/dist/components/tree/ScaleBarSVG.js +11 -0
  106. package/dist/components/tree/ScaleBarSVG.js.map +1 -0
  107. package/dist/components/tree/TreeCanvas.js +8 -26
  108. package/dist/components/tree/TreeCanvas.js.map +1 -1
  109. package/dist/components/tree/TreeCanvasBlock.js +11 -20
  110. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  111. package/dist/components/tree/{TreeBranchMenu.d.ts → TreeMenu.d.ts} +5 -4
  112. package/dist/components/tree/TreeMenu.js +48 -0
  113. package/dist/components/tree/TreeMenu.js.map +1 -0
  114. package/dist/components/tree/TreeRuler.js +3 -3
  115. package/dist/components/tree/TreeRuler.js.map +1 -1
  116. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js +9 -8
  117. package/dist/components/tree/dialogs/TreeNodeInfoDialog.js.map +1 -1
  118. package/dist/components/tree/renderTreeCanvas.js +19 -9
  119. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  120. package/dist/components/tree/renderTreeMouseover.d.ts +5 -0
  121. package/dist/components/tree/renderTreeMouseover.js +20 -0
  122. package/dist/components/tree/renderTreeMouseover.js.map +1 -0
  123. package/dist/components/tree/useTreeHover.d.ts +3 -8
  124. package/dist/components/tree/useTreeHover.js +8 -10
  125. package/dist/components/tree/useTreeHover.js.map +1 -1
  126. package/dist/components/useHoverAnchor.d.ts +19 -0
  127. package/dist/components/useHoverAnchor.js +18 -0
  128. package/dist/components/useHoverAnchor.js.map +1 -0
  129. package/dist/constants.d.ts +3 -0
  130. package/dist/constants.js +12 -3
  131. package/dist/constants.js.map +1 -1
  132. package/dist/element.d.ts +7 -3
  133. package/dist/element.js +32 -4
  134. package/dist/element.js.map +1 -1
  135. package/dist/expandSpec.js +3 -3
  136. package/dist/expandSpec.js.map +1 -1
  137. package/dist/exportFileName.d.ts +3 -3
  138. package/dist/exportFileName.js +4 -4
  139. package/dist/exportFileName.js.map +1 -1
  140. package/dist/featuresLayer.d.ts +8 -0
  141. package/dist/featuresLayer.js +42 -0
  142. package/dist/featuresLayer.js.map +1 -0
  143. package/dist/hierarchy.d.ts +51 -0
  144. package/dist/hierarchy.js +125 -0
  145. package/dist/hierarchy.js.map +1 -1
  146. package/dist/index.d.ts +2 -2
  147. package/dist/model/DataModel.d.ts +1 -1
  148. package/dist/model/DataModel.js +3 -3
  149. package/dist/model/DataModel.js.map +1 -1
  150. package/dist/model/msaModel.d.ts +14 -1
  151. package/dist/model/msaModel.js +17 -1
  152. package/dist/model/msaModel.js.map +1 -1
  153. package/dist/model/treeModel.d.ts +12 -0
  154. package/dist/model/treeModel.js +14 -1
  155. package/dist/model/treeModel.js.map +1 -1
  156. package/dist/model.d.ts +285 -53
  157. package/dist/model.js +693 -231
  158. package/dist/model.js.map +1 -1
  159. package/dist/renderToSvg.js +2 -4
  160. package/dist/renderToSvg.js.map +1 -1
  161. package/dist/rerootTree.d.ts +33 -0
  162. package/dist/rerootTree.js +144 -0
  163. package/dist/rerootTree.js.map +1 -0
  164. package/dist/svgTestUtil.d.ts +172 -26
  165. package/dist/types.d.ts +75 -12
  166. package/dist/useMsaSvgFigure.d.ts +2 -1
  167. package/dist/useMsaSvgFigure.js +11 -4
  168. package/dist/useMsaSvgFigure.js.map +1 -1
  169. package/dist/version.d.ts +1 -1
  170. package/dist/version.js +1 -1
  171. package/dist/writeNewick.d.ts +8 -0
  172. package/dist/writeNewick.js +34 -0
  173. package/dist/writeNewick.js.map +1 -0
  174. package/package.json +3 -3
  175. package/src/blockFasta.ts +18 -0
  176. package/src/colorSchemes.ts +20 -0
  177. package/src/columnCounts.ts +14 -6
  178. package/src/components/CopyButton.tsx +2 -2
  179. package/src/components/DragHandle.tsx +21 -1
  180. package/src/components/Loading.tsx +14 -30
  181. package/src/components/MSAView.tsx +16 -9
  182. package/src/components/MSAViewer.tsx +121 -26
  183. package/src/components/MetadataTable.tsx +55 -0
  184. package/src/components/SequenceTextArea.tsx +54 -8
  185. package/src/components/StatusMessage.tsx +70 -0
  186. package/src/components/Track.tsx +21 -4
  187. package/src/components/VerticalScrollbar.tsx +7 -0
  188. package/src/components/dialogs/ExportSVGDialog.tsx +5 -2
  189. package/src/components/dialogs/FeatureDialog.tsx +59 -38
  190. package/src/components/dialogs/MetadataDialog.tsx +9 -8
  191. package/src/components/header/ColorSchemeMenu.tsx +31 -12
  192. package/src/components/header/FileMenu.tsx +9 -0
  193. package/src/components/header/GappynessSlider.tsx +17 -4
  194. package/src/components/header/GoToBox.tsx +50 -0
  195. package/src/components/header/Header.tsx +11 -8
  196. package/src/components/header/SelectionMenu.tsx +117 -0
  197. package/src/components/header/goTo.ts +75 -0
  198. package/src/components/header/settingsMenuItems.ts +66 -0
  199. package/src/components/import/ImportForm.tsx +14 -7
  200. package/src/components/import/ImportFormExamples.tsx +20 -28
  201. package/src/components/minimap/Minimap.tsx +7 -0
  202. package/src/components/msa/AnnotationLegend.tsx +12 -6
  203. package/src/components/msa/MSACanvas.tsx +19 -1
  204. package/src/components/msa/MSACanvasBlock.tsx +9 -11
  205. package/src/components/msa/msaKeys.ts +45 -0
  206. package/src/components/msa/msaRaster.ts +1 -0
  207. package/src/components/msa/renderHighlights.ts +46 -4
  208. package/src/components/msa/tileColor.ts +9 -8
  209. package/src/components/msa/useMsaBlockMouse.ts +44 -31
  210. package/src/components/overlayColors.ts +4 -0
  211. package/src/components/rowpanels/RowPanelBlock.tsx +16 -23
  212. package/src/components/selectionDrag.ts +42 -0
  213. package/src/components/tracks/drawTracks.ts +15 -2
  214. package/src/components/tracks/useTrackHover.ts +32 -10
  215. package/src/components/tree/ScaleBarSVG.tsx +30 -0
  216. package/src/components/tree/TreeCanvas.tsx +9 -40
  217. package/src/components/tree/TreeCanvasBlock.tsx +19 -39
  218. package/src/components/tree/TreeMenu.tsx +129 -0
  219. package/src/components/tree/TreeRuler.tsx +7 -13
  220. package/src/components/tree/dialogs/TreeNodeInfoDialog.tsx +14 -16
  221. package/src/components/tree/renderTreeCanvas.ts +20 -8
  222. package/src/components/tree/renderTreeMouseover.ts +41 -0
  223. package/src/components/tree/useTreeHover.ts +8 -17
  224. package/src/components/useHoverAnchor.ts +31 -0
  225. package/src/constants.ts +14 -3
  226. package/src/element.ts +45 -5
  227. package/src/expandSpec.ts +3 -3
  228. package/src/exportFileName.ts +4 -3
  229. package/src/featuresLayer.ts +48 -0
  230. package/src/hierarchy.ts +151 -4
  231. package/src/index.ts +8 -1
  232. package/src/model/DataModel.ts +3 -3
  233. package/src/model/msaModel.ts +19 -1
  234. package/src/model/treeModel.ts +22 -0
  235. package/src/model.ts +841 -272
  236. package/src/renderToSvg.tsx +6 -9
  237. package/src/rerootTree.ts +172 -0
  238. package/src/types.ts +92 -12
  239. package/src/useMsaSvgFigure.ts +11 -4
  240. package/src/version.ts +1 -1
  241. package/src/writeNewick.ts +38 -0
  242. package/dist/components/PortalTooltip.d.ts +0 -11
  243. package/dist/components/PortalTooltip.js +0 -29
  244. package/dist/components/PortalTooltip.js.map +0 -1
  245. package/dist/components/header/HeaderStatusArea.js +0 -21
  246. package/dist/components/header/HeaderStatusArea.js.map +0 -1
  247. package/dist/components/tree/TreeBranchMenu.js +0 -29
  248. package/dist/components/tree/TreeBranchMenu.js.map +0 -1
  249. package/dist/components/tree/TreeNodeMenu.d.ts +0 -13
  250. package/dist/components/tree/TreeNodeMenu.js +0 -41
  251. package/dist/components/tree/TreeNodeMenu.js.map +0 -1
  252. package/src/components/PortalTooltip.tsx +0 -47
  253. package/src/components/header/HeaderStatusArea.tsx +0 -32
  254. package/src/components/tree/TreeBranchMenu.tsx +0 -68
  255. package/src/components/tree/TreeNodeMenu.tsx +0 -95
package/src/model.ts CHANGED
@@ -9,7 +9,13 @@ import {
9
9
  isAlive,
10
10
  types,
11
11
  } from '@jbrowse/mobx-state-tree'
12
- import { autorun, computed, transaction } from 'mobx'
12
+ import {
13
+ autorun,
14
+ compareStructural,
15
+ computed,
16
+ reaction,
17
+ transaction,
18
+ } from 'mobx'
13
19
  import {
14
20
  generateNodeIds,
15
21
  gffToAnnotations,
@@ -20,9 +26,10 @@ import {
20
26
  parseNewick,
21
27
  } from 'msa-parsers'
22
28
 
29
+ import { blockFasta } from './blockFasta.ts'
23
30
  import { calculateBlocks } from './calculateBlocks.ts'
24
31
  import { clustalXColumnColors } from './clustalX.ts'
25
- import colorSchemes from './colorSchemes.ts'
32
+ import colorSchemes, { letterColorTable } from './colorSchemes.ts'
26
33
  import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.ts'
27
34
  import { columnStats } from './columnStats.ts'
28
35
  import { packDomainLanes } from './components/msa/packDomainLanes.ts'
@@ -52,7 +59,7 @@ import {
52
59
  defaultTreeWidth,
53
60
  labelReferenceFontSize,
54
61
  maxCellSize,
55
- maxInlineSnapshotBytes,
62
+ maxInlineSnapshotBytes as defaultMaxInlineSnapshotBytes,
56
63
  maxNeighborJoiningRows,
57
64
  minColWidth,
58
65
  minLetterColWidth,
@@ -68,16 +75,17 @@ import {
68
75
  import { createPaletteMap } from './createPaletteMap.ts'
69
76
  import { exportFileName } from './exportFileName.ts'
70
77
  import { featureField, featureName } from './featureFields.ts'
78
+ import { featuresToAnnotations } from './featuresLayer.ts'
71
79
  import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
72
80
  import { flatToTree } from './flatToTree.ts'
73
81
  import {
74
82
  calcDepthToLeaf,
75
83
  clusterLayout,
76
84
  collapse,
77
- collapsedSubtreeMaxLength,
78
85
  find,
79
86
  forEachDescendant,
80
87
  hierarchy,
88
+ layoutTree,
81
89
  leafIndex,
82
90
  leaves,
83
91
  maxLength,
@@ -95,6 +103,7 @@ import { TreeModelF } from './model/treeModel.ts'
95
103
  import { calculateNeighborJoiningTree } from './neighborJoining.ts'
96
104
  import { parseAsn1 } from './parseAsn1.ts'
97
105
  import { calculatePropertyConservation } from './propertyConservation.ts'
106
+ import { midpointRoot, rerootTree } from './rerootTree.ts'
98
107
  import {
99
108
  globalColToVisibleCol,
100
109
  seqPosOfGlobalCol,
@@ -114,12 +123,14 @@ import {
114
123
  withAlpha,
115
124
  } from './util.ts'
116
125
  import { saveAs } from './vendor/fileSaver.ts'
126
+ import { writeNewick } from './writeNewick.ts'
117
127
  import { parseWuss } from './wuss.ts'
118
128
 
119
129
  import type { ColumnStats } from './columnStats.ts'
120
130
  import type { ScrollZoomAxis } from './constants.ts'
121
131
  import type { HierarchyNode } from './hierarchy.ts'
122
132
  import type { ExportSvgOptions } from './renderToSvg.tsx'
133
+ import type { TreeNode } from './rerootTree.ts'
123
134
  import type { ScaleSpec } from './scales.ts'
124
135
  import type {
125
136
  Annotation,
@@ -127,25 +138,33 @@ import type {
127
138
  BasicTrack,
128
139
  Cell,
129
140
  Clade,
141
+ CladeMark,
130
142
  ResidueMappingProblem,
131
143
  ColumnTrackSpec,
132
144
  DomainBand,
133
145
  Encoding,
134
146
  EncodingChannel,
147
+ Feature,
135
148
  Highlight,
136
149
  Legend,
137
150
  LegendEntry,
151
+ MsaSelection,
138
152
  NodeWithIds,
139
- NodeWithIdsAndLength,
140
153
  Region,
141
154
  ResidueMapping,
142
155
  ResidueSegment,
143
156
  ResolvedClade,
144
157
  ResolvedEncoding,
158
+ ResolvedFeaturePanel,
145
159
  ResolvedHighlight,
160
+ ResolvedSelection,
161
+ TreeOrder,
162
+ TreeRoot,
146
163
  ResolvedRowPanel,
164
+ ResolvedStripPanel,
147
165
  RowFeaturesSpec,
148
166
  RowPanelSpan,
167
+ RowDataInput,
149
168
  RowPanelSpec,
150
169
  RowResidue,
151
170
  StructureResidue,
@@ -202,14 +221,56 @@ const UNSCALED_FEATURE = '#d9d9d9'
202
221
  const laneOverlap = 0.1
203
222
 
204
223
  // 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) {
224
+ // cell square, and a gene neighborhood's width for a features panel. The row
225
+ // height is read only when used, so a panel with a width of its own does not
226
+ // recompute on a vertical zoom
227
+ function rowPanelWidth(panel: RowPanelSpec, view: { rowHeight: number }) {
207
228
  return (
208
229
  panel.width ??
209
- (panel.kind === 'features' ? defaultFeaturePanelWidth : rowHeight)
230
+ (panel.kind === 'features' ? defaultFeaturePanelWidth : view.rowHeight)
210
231
  )
211
232
  }
212
233
 
234
+ // one row of the row table as the channels read it. A number or a boolean
235
+ // reads as its string, the way a GFF value does, and null or an object drops
236
+ function stringFields(row: unknown) {
237
+ if (typeof row !== 'object' || row === null) {
238
+ return undefined
239
+ }
240
+ const fields: Record<string, string> = {}
241
+ for (const [field, value] of Object.entries(row)) {
242
+ if (typeof value === 'string') {
243
+ fields[field] = value
244
+ } else if (typeof value === 'number' || typeof value === 'boolean') {
245
+ fields[field] = String(value)
246
+ }
247
+ }
248
+ return fields
249
+ }
250
+
251
+ // the color each row takes from a field of the row table under a scale
252
+ function colorByRow(
253
+ rowData: Record<string, Record<string, string> | undefined>,
254
+ field: string,
255
+ colorOf: (value: string) => string | undefined,
256
+ ) {
257
+ const colors = new Map<string, string>()
258
+ for (const [name, row] of Object.entries(rowData)) {
259
+ const value = row?.[field]
260
+ const color = value === undefined ? undefined : colorOf(value)
261
+ if (color) {
262
+ colors.set(name, color)
263
+ }
264
+ }
265
+ return colors
266
+ }
267
+
268
+ type StripPanelScale = Omit<ResolvedStripPanel, 'id' | 'width' | 'offsetX'>
269
+ type FeaturePanelScale = Omit<
270
+ ResolvedFeaturePanel,
271
+ 'id' | 'width' | 'offsetX' | 'spans'
272
+ >
273
+
213
274
  // the fill and outline of every feature in a list: its own GFF `color=` first,
214
275
  // then the scale an encoding resolves over one of its fields, then the
215
276
  // accession palette
@@ -387,28 +448,47 @@ function featurePanelSpans({
387
448
  )
388
449
  }
389
450
 
390
- // a scale over the values a field takes across the features drawn
451
+ // a scale over the values a field takes across every feature loaded, so hiding
452
+ // a type in the filter dialog leaves the rest in their colors, and a legend of
453
+ // the values the features drawn carry
391
454
  function resolveFeatureScale(
392
455
  field: string,
393
456
  scale: ScaleSpec | undefined,
394
457
  annotations: Annotation[],
458
+ drawn: Annotation[],
395
459
  ) {
460
+ const valuesOf = (list: Annotation[]) =>
461
+ list.map(a => featureField(a, field)).filter(notEmpty)
462
+ const resolved = resolveScale(scale, valuesOf(annotations))
463
+ const shown = new Set(valuesOf(drawn))
396
464
  return {
397
465
  field,
398
- ...resolveScale(
399
- scale,
400
- annotations.map(a => featureField(a, field)).filter(notEmpty),
401
- ),
466
+ ...resolved,
467
+ legend: resolved.legend.filter(entry => shown.has(entry.id)),
468
+ }
469
+ }
470
+
471
+ const columnTrackBytes = new WeakMap<ColumnTrackSpec, number>()
472
+
473
+ function columnTrackSize(track: ColumnTrackSpec) {
474
+ let bytes = columnTrackBytes.get(track)
475
+ if (bytes === undefined) {
476
+ bytes = JSON.stringify(track).length
477
+ columnTrackBytes.set(track, bytes)
402
478
  }
479
+ return bytes
403
480
  }
404
481
 
405
482
  // a data track over this size stays in the live model but leaves the snapshot,
406
483
  // the same rule DataModel applies to an inline document
407
484
  function columnTrackSizes(tracks?: readonly ColumnTrackSpec[]) {
408
- return (tracks ?? []).map(t => JSON.stringify(t).length)
485
+ return (tracks ?? []).map(columnTrackSize)
409
486
  }
410
487
 
411
- function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
488
+ function smallColumnTracks(
489
+ tracks: ColumnTrackSpec[] | undefined,
490
+ maxInlineSnapshotBytes: number,
491
+ ) {
412
492
  const sizes = columnTrackSizes(tracks)
413
493
  const kept = tracks?.filter((_t, i) => sizes[i]! <= maxInlineSnapshotBytes)
414
494
  return kept?.length ? { columnTracks: kept } : {}
@@ -435,12 +515,14 @@ export const preservedOnReset = new Set([
435
515
  'scrollZoomAxis',
436
516
  'bgColor',
437
517
  'colorSchemeName',
518
+ 'customColorScheme',
438
519
  'showColumnStats',
439
520
  'drawLabels',
440
521
  'labelsAlignRight',
441
522
  'treeAreaWidth',
442
523
  'treeWidth',
443
524
  'showBranchLen',
525
+ 'treeOrder',
444
526
  'drawTree',
445
527
  'drawNodeBubbles',
446
528
  'drawNodeLabels',
@@ -467,6 +549,9 @@ function trackIsOff(
467
549
  return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id))
468
550
  }
469
551
 
552
+ // the marks that set tree state from a node rather than drawing
553
+ const seedingMarks = new Set<CladeMark>(['collapse', 'focus', 'rotate'])
554
+
470
555
  // shared empty results, so observers don't see a fresh [] as a change
471
556
  const noDomains: Annotation[] = []
472
557
  const noClades: ResolvedClade[] = []
@@ -519,8 +604,7 @@ function cladeRows(
519
604
  * Every clade that resolves, with its fill color settled, against the leaf
520
605
  * order `rowNamesSet` gives. The tree panel resolves against the displayed
521
606
  * rows and the overview against its own, so both take the tree they draw. A
522
- * `range` record names no node, so `collapse` and `focus`, which need one,
523
- * drop it.
607
+ * `range` record names no node, so the seeding marks, which need one, drop it.
524
608
  */
525
609
  function resolveClades(
526
610
  clades: Clade[],
@@ -530,7 +614,7 @@ function resolveClades(
530
614
  const index = leafIndex(root)
531
615
  return clades.flatMap(clade => {
532
616
  const resolved = cladeRows(clade, root, index, rowNamesSet)
533
- const seeding = clade.mark === 'collapse' || clade.mark === 'focus'
617
+ const seeding = seedingMarks.has(clade.mark)
534
618
  return resolved && !(seeding && resolved.nodeId === undefined)
535
619
  ? [
536
620
  {
@@ -548,20 +632,50 @@ function resolveClades(
548
632
  })
549
633
  }
550
634
 
635
+ const childOrders: Record<
636
+ TreeOrder,
637
+ ((a: HierarchyNode, b: HierarchyNode) => number) | undefined
638
+ > = {
639
+ input: undefined,
640
+ branchLength: (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1),
641
+ ladderize: (a, b) => a.value! - b.value!,
642
+ ladderizeReverse: (a, b) => b.value! - a.value!,
643
+ }
644
+
551
645
  /**
552
646
  * The tree with the display transforms applied: leaf counts summed, children
553
- * sorted by branch length, `showOnly` taken as the new root, and each
554
- * `collapsed` clade folded. The overview builds the same tree without the
555
- * focus, so it shows the focused subtree inside the whole.
647
+ * put in `order` and each `rotated` node's reversed, `showOnly` taken as the
648
+ * new root, and each `collapsed` clade folded. The overview builds the same
649
+ * tree without the focus, so it shows the focused subtree inside the whole.
556
650
  */
557
651
  function buildTreeRoot(
558
652
  tree: NodeWithIds,
559
- collapsed: readonly string[],
560
- showOnly?: string,
653
+ {
654
+ collapsed,
655
+ rotated,
656
+ order,
657
+ showOnly,
658
+ }: {
659
+ collapsed: readonly string[]
660
+ rotated: readonly string[]
661
+ order: TreeOrder
662
+ showOnly?: string
663
+ },
561
664
  ) {
562
665
  let hier = hierarchy(tree, d => d.children)
563
666
  hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1))
564
- sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1))
667
+ const compare = childOrders[order]
668
+ if (compare) {
669
+ sort(hier, compare)
670
+ }
671
+ if (rotated.length > 0) {
672
+ const flip = new Set(rotated)
673
+ forEachDescendant(hier, n => {
674
+ if (flip.has(n.data.id)) {
675
+ n.children?.reverse()
676
+ }
677
+ })
678
+ }
565
679
 
566
680
  if (showOnly) {
567
681
  const res = find(hier, n => n.data.id === showOnly)
@@ -586,6 +700,53 @@ function buildTreeRoot(
586
700
  return hier
587
701
  }
588
702
 
703
+ /**
704
+ * The node whose branch an outgroup roots the tree on: the outgroup's most
705
+ * recent common ancestor, or the rest of the tips' where that ancestor is the
706
+ * root, since the outgroup then straddles the root and the branch above the
707
+ * other side is the same branch of the unrooted tree.
708
+ */
709
+ function outgroupNode(root: HierarchyNode, outgroup: string[]) {
710
+ const index = leafIndex(root)
711
+ const node = mrca(root, outgroup, index)
712
+ if (node !== root) {
713
+ return node
714
+ }
715
+ const out = new Set(outgroup)
716
+ const rest = leaves(root)
717
+ .map(n => n.data.name)
718
+ .filter(name => !out.has(name))
719
+ const other = mrca(root, rest, index)
720
+ return other === root ? undefined : other
721
+ }
722
+
723
+ /**
724
+ * `tree` rerooted as `treeRoot` asks, with fresh path ids. The ids of the tree
725
+ * as written become names on unnamed nodes, and a rerooting would carry them
726
+ * along as labels, so they come off first.
727
+ */
728
+ function rootTree(tree: NodeWithIds, treeRoot: TreeRoot) {
729
+ const plain = new Map<NodeWithIds, TreeNode>()
730
+ for (const node of preorder(tree).reverse()) {
731
+ plain.set(node, {
732
+ name: node.name === node.id ? undefined : node.name,
733
+ length: node.length,
734
+ children: node.children.map(c => plain.get(c)!),
735
+ })
736
+ }
737
+ const input = plain.get(tree)!
738
+ if (treeRoot === 'midpoint') {
739
+ return generateNodeIds(midpointRoot(input))
740
+ }
741
+ const target = outgroupNode(
742
+ hierarchy(tree, d => d.children),
743
+ treeRoot.outgroup,
744
+ )
745
+ return target
746
+ ? generateNodeIds(rerootTree(input, plain.get(target.data)!))
747
+ : tree
748
+ }
749
+
589
750
  // the inclusive tip indices a node covers, from the row-space extent
590
751
  // clusterLayout writes to xMin/xMax as tip centers
591
752
  function tipRange(node: HierarchyNode): [number, number] {
@@ -623,6 +784,20 @@ function inRanges(ranges: [number, number][] | undefined, position: number) {
623
784
  return !!ranges?.some(([start, end]) => position >= start && position <= end)
624
785
  }
625
786
 
787
+ // sorted integers as inclusive [first, last] runs of consecutive values
788
+ function consecutiveRuns(sorted: number[]) {
789
+ const runs: [number, number][] = []
790
+ for (const n of sorted) {
791
+ const last = runs.at(-1)
792
+ if (last && n <= last[1] + 1) {
793
+ last[1] = Math.max(last[1], n)
794
+ } else {
795
+ runs.push([n, n])
796
+ }
797
+ }
798
+ return runs
799
+ }
800
+
626
801
  // the value every one of a node's children reports, or undefined where one of
627
802
  // them has no value or they disagree
628
803
  function sharedValue(values: (string | undefined)[]) {
@@ -665,8 +840,15 @@ function preorder(tree: NodeWithIds) {
665
840
  * const root = RootModel.create({})
666
841
  * root.view.setData({ msa: '>seq1\nACGT\n>seq2\nACGT' })
667
842
  * ```
843
+ *
844
+ * `maxInlineSnapshotBytes` is the largest pasted or local document, and the
845
+ * largest data track, the snapshot keeps; a larger one stays in the live model
846
+ * and `unshareableData` names it. The standalone app, whose links carry the
847
+ * snapshot in the URL fragment, raises it.
668
848
  */
669
- function stateModelFactory() {
849
+ function stateModelFactory({
850
+ maxInlineSnapshotBytes = defaultMaxInlineSnapshotBytes,
851
+ }: { maxInlineSnapshotBytes?: number } = {}) {
670
852
  return types
671
853
  .compose(
672
854
  DialogQueueSessionMixin(),
@@ -796,6 +978,22 @@ function stateModelFactory() {
796
978
  */
797
979
  collapsed: stripDefault(types.array(types.string), []),
798
980
 
981
+ /**
982
+ * #property
983
+ * tree nodes whose children draw in reverse order, which is ggtree's
984
+ * `rotate`: the same tree, with the clades on either side of the node
985
+ * swapped
986
+ */
987
+ rotated: stripDefault(types.array(types.string), []),
988
+
989
+ /**
990
+ * #property
991
+ * where the tree is rooted: `midpoint` roots it halfway along the
992
+ * longest path between two tips, `{outgroup}` on the branch above
993
+ * the tips named, and undefined keeps the file's root
994
+ */
995
+ treeRoot: types.frozen<TreeRoot | undefined>(),
996
+
799
997
  /**
800
998
  * #property
801
999
  * focus on particular subtree
@@ -846,7 +1044,7 @@ function stateModelFactory() {
846
1044
  * data from the loaded tree/msa/treeMetadata, generally loaded by
847
1045
  * autorun
848
1046
  */
849
- data: types.optional(DataModelF(), {}),
1047
+ data: types.optional(DataModelF(maxInlineSnapshotBytes), {}),
850
1048
 
851
1049
  /**
852
1050
  * #property
@@ -877,6 +1075,21 @@ function stateModelFactory() {
877
1075
  * Persists in the snapshot and the URL.
878
1076
  */
879
1077
  highlights: stripDefault(types.array(types.frozen<Highlight>()), []),
1078
+ /**
1079
+ * #property
1080
+ * features on the rows as data, `{row, start, end, name, ...}` in
1081
+ * residues of each row, drawn with the GFF's annotations. Persists in
1082
+ * the snapshot and the URL.
1083
+ */
1084
+ features: stripDefault(types.array(types.frozen<Feature>()), []),
1085
+ /**
1086
+ * #property
1087
+ * the block the reader selected: `{start, end}` columns of the file,
1088
+ * 1-based inclusive like a column highlight, and `rows` by name, every
1089
+ * row where absent. Undefined until something is selected, so a view
1090
+ * without one adds nothing to the shared URL.
1091
+ */
1092
+ selection: types.frozen<MsaSelection | undefined>(),
880
1093
  /**
881
1094
  * #property
882
1095
  * where the view opens, in `highlights` coordinates: `{row, start,
@@ -890,7 +1103,8 @@ function stateModelFactory() {
890
1103
  * clades of the tree with a mark drawn over them. `mrca` names tips
891
1104
  * whose common ancestor is the clade, or `range` its first and last
892
1105
  * tip in display order, and `tips` is the leaf count the producer
893
- * measured. `mark` is `highlight`, `bracket`, `collapse` or `focus`.
1106
+ * measured. `mark` is `highlight`, `bracket`, `collapse`, `focus` or
1107
+ * `rotate`.
894
1108
  * See docs/layers.md
895
1109
  */
896
1110
  clades: stripDefault(types.array(types.frozen<Clade>()), []),
@@ -1023,9 +1237,16 @@ function stateModelFactory() {
1023
1237
  * #volatile
1024
1238
  * non-fatal load problems: an optional layer that failed to load, an
1025
1239
  * overlay that failed to parse. `error` replaces the view and is for the
1026
- * alignment itself
1240
+ * alignment itself. `warnings` adds the ones derived from the data
1027
1241
  */
1028
- warnings: [] as string[],
1242
+ loadWarnings: [] as string[],
1243
+
1244
+ /**
1245
+ * #volatile
1246
+ * derived warnings the user dismissed, which stay hidden while the data
1247
+ * behind them holds
1248
+ */
1249
+ dismissedWarnings: [] as string[],
1029
1250
 
1030
1251
  /**
1031
1252
  * #volatile
@@ -1119,18 +1340,11 @@ function stateModelFactory() {
1119
1340
  * that failed to parse. A message already on the list is not added again
1120
1341
  */
1121
1342
  addWarning(warning: string) {
1122
- if (!self.warnings.includes(warning)) {
1123
- self.warnings = [...self.warnings, warning]
1343
+ if (!self.loadWarnings.includes(warning)) {
1344
+ self.loadWarnings = [...self.loadWarnings, warning]
1124
1345
  }
1125
1346
  },
1126
1347
 
1127
- /**
1128
- * #action
1129
- */
1130
- clearWarnings() {
1131
- self.warnings = []
1132
- },
1133
-
1134
1348
  /**
1135
1349
  * #action
1136
1350
  * declare that this host restores the loaded documents itself, which
@@ -1176,6 +1390,33 @@ function stateModelFactory() {
1176
1390
  setHighlights(highlights: Highlight[]) {
1177
1391
  self.highlights.replace(highlights)
1178
1392
  },
1393
+ /**
1394
+ * #action
1395
+ */
1396
+ setFeatures(features: Feature[]) {
1397
+ self.features.replace(features)
1398
+ },
1399
+ /**
1400
+ * #action
1401
+ * select a block, in `selection` coordinates. `start` and `end` may come
1402
+ * in either order.
1403
+ */
1404
+ setSelection(selection?: MsaSelection) {
1405
+ const next = selection && {
1406
+ start: Math.min(selection.start, selection.end),
1407
+ end: Math.max(selection.start, selection.end),
1408
+ ...(selection.rows ? { rows: selection.rows } : {}),
1409
+ }
1410
+ if (!compareStructural(next, self.selection)) {
1411
+ self.selection = next
1412
+ }
1413
+ },
1414
+ /**
1415
+ * #action
1416
+ */
1417
+ clearSelection() {
1418
+ this.setSelection(undefined)
1419
+ },
1179
1420
 
1180
1421
  /**
1181
1422
  * #action
@@ -1253,8 +1494,9 @@ function stateModelFactory() {
1253
1494
  /**
1254
1495
  * #action
1255
1496
  * switch to another alignment of a multi-alignment file (Stockholm).
1256
- * Clears the collapsed node ids, the subtree in focus, the reference row
1257
- * and the scroll position, which all refer to the previous alignment
1497
+ * Clears the collapsed and rotated node ids, the subtree in focus, the
1498
+ * reference row and the scroll position, which all refer to the
1499
+ * previous alignment
1258
1500
  */
1259
1501
  setCurrentAlignment(n: number) {
1260
1502
  if (n === self.currentAlignment) {
@@ -1263,6 +1505,7 @@ function stateModelFactory() {
1263
1505
  transaction(() => {
1264
1506
  self.currentAlignment = n
1265
1507
  self.collapsed.clear()
1508
+ self.rotated.clear()
1266
1509
  this.setShowOnly(undefined)
1267
1510
  this.drawRelativeTo(undefined)
1268
1511
  self.scrollX = 0
@@ -1282,6 +1525,42 @@ function stateModelFactory() {
1282
1525
  }
1283
1526
  },
1284
1527
 
1528
+ /**
1529
+ * #action
1530
+ * reverse the order of the given node's children, or restore it
1531
+ */
1532
+ toggleRotated(node: string) {
1533
+ if (self.rotated.includes(node)) {
1534
+ self.rotated.remove(node)
1535
+ } else {
1536
+ self.rotated.push(node)
1537
+ }
1538
+ },
1539
+
1540
+ /**
1541
+ * #action
1542
+ */
1543
+ clearRotated() {
1544
+ self.rotated.clear()
1545
+ },
1546
+
1547
+ /**
1548
+ * #action
1549
+ * reroot the tree. Clears `collapsed`, `rotated` and `showOnly`, whose
1550
+ * path-derived node ids name other nodes in the rerooted tree
1551
+ */
1552
+ setTreeRoot(treeRoot?: TreeRoot) {
1553
+ if (compareStructural(self.treeRoot, treeRoot)) {
1554
+ return
1555
+ }
1556
+ transaction(() => {
1557
+ self.treeRoot = treeRoot
1558
+ self.collapsed.clear()
1559
+ self.rotated.clear()
1560
+ this.setShowOnly(undefined)
1561
+ })
1562
+ },
1563
+
1285
1564
  /**
1286
1565
  * #action
1287
1566
  * show only the subtree rooted at the given node id (pass undefined to
@@ -1359,9 +1638,9 @@ function stateModelFactory() {
1359
1638
  * store the GFF text in the snapshot like the alignment and tree. The
1360
1639
  * parsed annotations are volatile and a blob filehandle is cleared once
1361
1640
  * read, so the text is the only persisted copy. An autorun parses it
1362
- * into annotations.
1641
+ * into annotations, and clears the ones it drew when the text goes.
1363
1642
  */
1364
- setGFF(result: string) {
1643
+ setGFF(result?: string) {
1365
1644
  self.data.setGFF(result)
1366
1645
  },
1367
1646
  }))
@@ -1383,11 +1662,21 @@ function stateModelFactory() {
1383
1662
  get realAllowedGappyness() {
1384
1663
  return this.hideGapsEffective ? self.allowedGappyness : 100
1385
1664
  },
1665
+ /**
1666
+ * #getter
1667
+ * the annotations the overlay, the legend and the encodings draw: the
1668
+ * GFF's, then the `features` layer's
1669
+ */
1670
+ get allAnnotations(): Annotation[] {
1671
+ return self.features.length > 0
1672
+ ? [...self.annotations, ...featuresToAnnotations(self.features)]
1673
+ : self.annotations
1674
+ },
1386
1675
  /**
1387
1676
  * #getter
1388
1677
  */
1389
1678
  get actuallyShowDomains() {
1390
- return self.showDomains && self.annotations.length > 0
1679
+ return self.showDomains && this.allAnnotations.length > 0
1391
1680
  },
1392
1681
  /**
1393
1682
  * #getter
@@ -1421,12 +1710,26 @@ function stateModelFactory() {
1421
1710
  * #getter
1422
1711
  */
1423
1712
  get colorScheme() {
1713
+ const { customColorScheme, colorSchemeName } = self
1424
1714
  // colorSchemeName is a free string (menus, snapshots, URL params), so a
1425
1715
  // stale name falls back to the default
1426
- return (
1427
- colorSchemes[self.colorSchemeName] ??
1428
- colorSchemes[defaultColorSchemeName]!
1429
- )
1716
+ return customColorScheme
1717
+ ? letterColorTable(customColorScheme)
1718
+ : (colorSchemes[colorSchemeName] ??
1719
+ colorSchemes[defaultColorSchemeName]!)
1720
+ },
1721
+
1722
+ /**
1723
+ * #getter
1724
+ * the scheme coloring each cell from its column's statistics, or
1725
+ * undefined while the cells take a fixed color per letter, from the
1726
+ * built-in table or from `customColorScheme`
1727
+ */
1728
+ get dynamicColorSchemeName() {
1729
+ const { customColorScheme, colorSchemeName } = self
1730
+ return !customColorScheme && colorSchemeName.includes('dynamic')
1731
+ ? colorSchemeName
1732
+ : undefined
1430
1733
  },
1431
1734
 
1432
1735
  /**
@@ -1449,7 +1752,7 @@ function stateModelFactory() {
1449
1752
  return !!this.tree.noTree
1450
1753
  },
1451
1754
  get noDomains() {
1452
- return self.annotations.length === 0
1755
+ return self.allAnnotations.length === 0
1453
1756
  },
1454
1757
 
1455
1758
  /**
@@ -1458,12 +1761,13 @@ function stateModelFactory() {
1458
1761
  * from disk or pasted in becomes inline text, and DataModel drops an
1459
1762
  * inline document past `maxInlineSnapshotBytes`.
1460
1763
  *
1461
- * The header lists these, and the standalone app stops rewriting the
1462
- * address bar while the list is non-empty, so a copied link does not
1463
- * open an empty viewer unannounced. A document fetched from a URL never
1464
- * appears here, since the snapshot keeps its filehandle.
1764
+ * The header lists these, and the standalone app drops `#data=` from
1765
+ * the address bar while the list is non-empty or the encoded link would
1766
+ * pass its `maxLinkLength`, so a copied link never opens an empty viewer
1767
+ * unannounced. A document fetched from a URL never appears here, since
1768
+ * the snapshot keeps its filehandle.
1465
1769
  *
1466
- * Empty when `hostRestoresData` is true.
1770
+ * Empty when `hostCarriesData` is true.
1467
1771
  */
1468
1772
  get unshareableData(): UnshareableData[] {
1469
1773
  if (self.hostRestoresData) {
@@ -1502,7 +1806,8 @@ function stateModelFactory() {
1502
1806
  * `data.treeMetadata`, the name that travels in existing links, so the
1503
1807
  * inline size limit and `treeMetadataFilehandle` cover it. labelWidthMap
1504
1808
  * reads it on every layout, so a malformed user-supplied file returns {}
1505
- * instead of throwing out of rendering.
1809
+ * instead of throwing out of rendering, and every field is a string by
1810
+ * the time a scale sorts it.
1506
1811
  */
1507
1812
  get rowData(): Record<string, Record<string, string> | undefined> {
1508
1813
  const text = self.data.treeMetadata
@@ -1512,7 +1817,12 @@ function stateModelFactory() {
1512
1817
  try {
1513
1818
  const parsed: unknown = JSON.parse(text)
1514
1819
  return typeof parsed === 'object' && parsed !== null
1515
- ? (parsed as Record<string, Record<string, string> | undefined>)
1820
+ ? Object.fromEntries(
1821
+ Object.entries(parsed).map(([name, row]) => [
1822
+ name,
1823
+ stringFields(row),
1824
+ ]),
1825
+ )
1516
1826
  : {}
1517
1827
  } catch (e) {
1518
1828
  console.error('failed to parse rowData', e)
@@ -1560,8 +1870,9 @@ function stateModelFactory() {
1560
1870
 
1561
1871
  /**
1562
1872
  * #getter
1873
+ * the tree the file or the alignment gives, before `treeRoot`
1563
1874
  */
1564
- get tree(): NodeWithIds {
1875
+ get inputTree(): NodeWithIds {
1565
1876
  const text = self.data.tree
1566
1877
  return text
1567
1878
  ? generateNodeIds(parseTreeText(text))
@@ -1573,6 +1884,19 @@ function stateModelFactory() {
1573
1884
  })
1574
1885
  },
1575
1886
 
1887
+ /**
1888
+ * #getter
1889
+ * the tree as the file or the alignment gives it, rerooted where
1890
+ * `treeRoot` asks. Every node id, and so `collapsed`, `rotated` and
1891
+ * `showOnly`, refers to this tree.
1892
+ */
1893
+ get tree(): NodeWithIds {
1894
+ const tree = this.inputTree
1895
+ return self.treeRoot && !tree.noTree
1896
+ ? rootTree(tree, self.treeRoot)
1897
+ : tree
1898
+ },
1899
+
1576
1900
  /**
1577
1901
  * #getter
1578
1902
  * Returns the list of row (sequence) names in display order.
@@ -1594,8 +1918,8 @@ function stateModelFactory() {
1594
1918
  * `clades` resolved to the rows each one covers. The tip names resolve
1595
1919
  * against `tree` rather than `root`, so a clade whose ancestor the user
1596
1920
  * collapsed keeps its rows. One leaf pass over the tree serves every
1597
- * clade. A `range` record names no node, so `collapse` and `focus`, which
1598
- * need one, drop it.
1921
+ * clade. A `range` record names no node, so `collapse`, `focus` and
1922
+ * `rotate`, which need one, drop it.
1599
1923
  */
1600
1924
  get resolvedClades(): ResolvedClade[] {
1601
1925
  if (self.clades.length === 0) {
@@ -1653,7 +1977,27 @@ function stateModelFactory() {
1653
1977
  * #getter
1654
1978
  */
1655
1979
  get root() {
1656
- return buildTreeRoot(this.tree, self.collapsed, self.showOnly)
1980
+ return buildTreeRoot(this.tree, {
1981
+ collapsed: self.collapsed,
1982
+ rotated: self.rotated,
1983
+ order: self.treeOrder,
1984
+ showOnly: self.showOnly,
1985
+ })
1986
+ },
1987
+
1988
+ /**
1989
+ * #getter
1990
+ * the tree as Newick in the order and with the root it is drawn, with
1991
+ * every clade, collapsed or out of focus
1992
+ */
1993
+ get treeNewick() {
1994
+ return writeNewick(
1995
+ buildTreeRoot(this.tree, {
1996
+ collapsed: [],
1997
+ rotated: self.rotated,
1998
+ order: self.treeOrder,
1999
+ }),
2000
+ )
1657
2001
  },
1658
2002
 
1659
2003
  /**
@@ -1677,7 +2021,11 @@ function stateModelFactory() {
1677
2021
  if (!self.showTreeOverview) {
1678
2022
  return undefined
1679
2023
  }
1680
- const root = buildTreeRoot(this.tree, self.collapsed)
2024
+ const root = buildTreeRoot(this.tree, {
2025
+ collapsed: self.collapsed,
2026
+ rotated: self.rotated,
2027
+ order: self.treeOrder,
2028
+ })
1681
2029
  const numTips = leaves(root).length
1682
2030
  clusterLayout(root, numTips, 1)
1683
2031
  const rootLen = Math.max(root.data.length || 0, 0)
@@ -1759,7 +2107,7 @@ function stateModelFactory() {
1759
2107
  * alignment, the sum of each record's width
1760
2108
  */
1761
2109
  get rowPanelsWidth() {
1762
- return sum(self.rowPanels.map(p => rowPanelWidth(p, self.rowHeight)))
2110
+ return sum(self.rowPanels.map(p => rowPanelWidth(p, self)))
1763
2111
  },
1764
2112
  /**
1765
2113
  * #getter
@@ -1806,9 +2154,10 @@ function stateModelFactory() {
1806
2154
  return []
1807
2155
  }
1808
2156
  // ragged input (a3m, hand-edited fasta) can have rows shorter than the
1809
- // alignment; the widest row defines the column count and a row that
1810
- // stops early counts as gapped for the remainder
1811
- const numCols = strs.reduce((max, str) => Math.max(max, str.length), 0)
2157
+ // alignment; the widest row of the whole alignment, collapsed rows
2158
+ // included, defines the column count as it does for numColumns, and a
2159
+ // row that stops early counts as gapped for the remainder
2160
+ const numCols = this.MSA?.getWidth() ?? 0
1812
2161
  const numRows = strs.length
1813
2162
  const threshold = Math.ceil((realAllowedGappyness / 100) * numRows)
1814
2163
  const blankCounts = new Uint32Array(numCols)
@@ -1939,7 +2288,7 @@ function stateModelFactory() {
1939
2288
  * #getter
1940
2289
  */
1941
2290
  get colStats() {
1942
- return columnCountsFromRows(this.columns2d)
2291
+ return columnCountsFromRows(this.columns2d, this.numColumns)
1943
2292
  },
1944
2293
 
1945
2294
  /**
@@ -2038,40 +2387,42 @@ function stateModelFactory() {
2038
2387
  },
2039
2388
  /**
2040
2389
  * #getter
2041
- * generates a new tree that is clustered with x,y positions
2042
- */
2043
- get hierarchy(): HierarchyNode<NodeWithIdsAndLength> {
2044
- const r = this.root
2045
- clusterLayout(r, this.totalHeight, self.treeWidth)
2046
- const max = this.rootToTipLength
2047
- const k = max ? self.treeWidth / max : 0
2048
- // the displayed root starts at x=0, so subtract its length here; `root`
2049
- // returns the cached parse, which must not be mutated
2050
- setBrLength(r, -Math.max(r.data.length || 0, 0), k)
2051
- // for each collapsed clade, record the pixel x-position of its farthest
2052
- // tip so the renderer can draw a triangle spanning the branch-length
2053
- // extent of the hidden subtree
2054
- forEachDescendant(r, node => {
2055
- if (node._children) {
2056
- node.collapsedTipXFar =
2057
- (node.len ?? 0) + collapsedSubtreeMaxLength(node) * k
2058
- }
2390
+ * `root` laid out on new nodes in row and branch-length units, read in
2391
+ * pixels through `rowHeight` and `treeWidth`. A zoom leaves it and every
2392
+ * node in it as they are, and an observer reading a node's `x` or `len`
2393
+ * observes the size it scales by.
2394
+ */
2395
+ get treeLayout() {
2396
+ return layoutTree(this.root, {
2397
+ get rowHeight() {
2398
+ return self.rowHeight
2399
+ },
2400
+ get treeWidth() {
2401
+ return self.treeWidth
2402
+ },
2059
2403
  })
2060
- return r as HierarchyNode<NodeWithIdsAndLength>
2404
+ },
2405
+
2406
+ /**
2407
+ * #getter
2408
+ * the laid-out tree, with pixel positions on every node
2409
+ */
2410
+ get hierarchy(): HierarchyNode<NodeWithIds> {
2411
+ return this.treeLayout.root
2061
2412
  },
2062
2413
 
2063
2414
  /**
2064
2415
  * #getter
2065
2416
  */
2066
2417
  get totalHeight() {
2067
- return leaves(this.root).length * self.rowHeight
2418
+ return this.leaves.length * self.rowHeight
2068
2419
  },
2069
2420
 
2070
2421
  /**
2071
2422
  * #getter
2072
2423
  */
2073
- get leaves() {
2074
- return leaves(this.hierarchy)
2424
+ get leaves(): HierarchyNode<NodeWithIds>[] {
2425
+ return this.treeLayout.leaves
2075
2426
  },
2076
2427
 
2077
2428
  /**
@@ -2080,8 +2431,7 @@ function stateModelFactory() {
2080
2431
  * the tree's own units
2081
2432
  */
2082
2433
  get rootToTipLength() {
2083
- const r = this.root
2084
- return maxLength(r) - Math.max(r.data.length || 0, 0)
2434
+ return this.treeLayout.rootToTipLength
2085
2435
  },
2086
2436
 
2087
2437
  /**
@@ -2108,7 +2458,7 @@ function stateModelFactory() {
2108
2458
  * max topological depth to a tip, used to scale cladogram x-positions
2109
2459
  */
2110
2460
  get maxDepthToLeaf() {
2111
- return calcDepthToLeaf(this.hierarchy)
2461
+ return this.treeLayout.root.depthToLeaf
2112
2462
  },
2113
2463
 
2114
2464
  /**
@@ -2343,13 +2693,6 @@ function stateModelFactory() {
2343
2693
  return self.MSA?.secondaryStructureConsensus
2344
2694
  },
2345
2695
 
2346
- /**
2347
- * #getter
2348
- */
2349
- get seqConsensus() {
2350
- return self.MSA?.seqConsensus
2351
- },
2352
-
2353
2696
  /**
2354
2697
  * #getter
2355
2698
  * the base pairs of the consensus secondary structure, as arcs, in
@@ -2494,11 +2837,13 @@ function stateModelFactory() {
2494
2837
 
2495
2838
  /**
2496
2839
  * #method
2497
- * Return a row-specific letter at a visible column, or undefined if gap.
2840
+ * Return the character a row holds at a visible column: a residue, or
2841
+ * the gap character (`-` or `.`) on a gap. Undefined for a row name the
2842
+ * alignment does not have or a column past the row's end.
2498
2843
  *
2499
2844
  * @param rowName - The name of the row
2500
2845
  * @param visibleCol - The visible column index (what the user sees on screen)
2501
- * @returns The letter at that position, or undefined if it's a gap
2846
+ * @returns The character at that position
2502
2847
  */
2503
2848
  visibleColToRowLetter(rowName: string, visibleCol: number) {
2504
2849
  return self.rowMap.get(rowName)?.[
@@ -2596,6 +2941,22 @@ function stateModelFactory() {
2596
2941
  return col ?? (seqPos === 0 ? 0 : seq.length)
2597
2942
  },
2598
2943
 
2944
+ /**
2945
+ * #method
2946
+ * the global column of a span's last residue, clamping a position past
2947
+ * the end of the row to its last residue. Undefined for a row name the
2948
+ * alignment does not have, or a row with no residues.
2949
+ *
2950
+ * @param rowName - The name of the row
2951
+ * @param seqPos - The sequence position (0-based, ungapped)
2952
+ */
2953
+ seqEndToGlobalCol(rowName: string, seqPos: number) {
2954
+ const index = self.seqPosIndex(rowName)
2955
+ return index && index.length > 0
2956
+ ? index[Math.min(seqPos, index.length - 1)]
2957
+ : undefined
2958
+ },
2959
+
2599
2960
  /**
2600
2961
  * #method
2601
2962
  * Convert a sequence position (ungapped) directly to a visible column index.
@@ -2625,10 +2986,10 @@ function stateModelFactory() {
2625
2986
  const start = Math.max(1, Math.floor(rawStart))
2626
2987
  const end = Math.ceil(rawEnd)
2627
2988
  let startGlobal = start - 1
2628
- let endGlobal = end - 1
2989
+ let endGlobal = Math.min(end, self.MSA?.getWidth() ?? end) - 1
2629
2990
  if (row !== undefined) {
2630
2991
  const rowStart = this.seqPosToGlobalCol(row, start - 1)
2631
- const rowEnd = this.seqPosToGlobalCol(row, end - 1)
2992
+ const rowEnd = this.seqEndToGlobalCol(row, end - 1)
2632
2993
  if (rowStart === undefined || rowEnd === undefined) {
2633
2994
  return undefined
2634
2995
  }
@@ -2865,7 +3226,7 @@ function stateModelFactory() {
2865
3226
  // first occurrence wins; only name, description and segment start are
2866
3227
  // read from it
2867
3228
  const types = new Map<string, Annotation>()
2868
- for (const annot of self.annotations) {
3229
+ for (const annot of self.allAnnotations) {
2869
3230
  if (!types.has(annot.accession)) {
2870
3231
  types.set(annot.accession, annot)
2871
3232
  }
@@ -2873,7 +3234,7 @@ function stateModelFactory() {
2873
3234
  return types
2874
3235
  },
2875
3236
  get filteredAnnotations() {
2876
- return self.annotations.filter(
3237
+ return self.allAnnotations.filter(
2877
3238
  r => !self.turnedOffFeatures.get(r.accession),
2878
3239
  )
2879
3240
  },
@@ -2922,6 +3283,28 @@ function stateModelFactory() {
2922
3283
  },
2923
3284
  }))
2924
3285
  .views(self => ({
3286
+ /**
3287
+ * #getter
3288
+ */
3289
+ get verticalScrollbarWidth() {
3290
+ return self.showVerticalScrollbar ? 20 : 0
3291
+ },
3292
+ /**
3293
+ * #getter
3294
+ * width of the alignment canvas: the msa area less the vertical
3295
+ * scrollbar. showHorizontalScrollbar must not read it, since that feeds
3296
+ * msaAreaHeight -> showVerticalScrollbar and would form a cycle
3297
+ */
3298
+ get msaCanvasWidth() {
3299
+ return self.msaAreaWidth - this.verticalScrollbarWidth
3300
+ },
3301
+ /**
3302
+ * #getter
3303
+ * most-negative allowed scrollX, which keeps the last column in view
3304
+ */
3305
+ get maxScrollX() {
3306
+ return Math.min(-self.totalWidth + this.msaCanvasWidth, 0)
3307
+ },
2925
3308
  /**
2926
3309
  * #getter
2927
3310
  */
@@ -2939,9 +3322,29 @@ function stateModelFactory() {
2939
3322
  },
2940
3323
  /**
2941
3324
  * #getter
3325
+ * warnings that follow from the loaded data and clear when it changes. A
3326
+ * GFF whose first column names rows of some other alignment parses
3327
+ * without error and draws nothing
3328
+ */
3329
+ get dataWarnings(): string[] {
3330
+ const { annotations, rowNamesSet } = self
3331
+ return self.dataInitialized &&
3332
+ annotations.length > 0 &&
3333
+ !annotations.some(a => rowNamesSet.has(a.id))
3334
+ ? [
3335
+ `0 of ${annotations.length} annotations name a row in this alignment`,
3336
+ ]
3337
+ : []
3338
+ },
3339
+ /**
3340
+ * #getter
3341
+ * the load warnings and the undismissed data warnings, for the header
2942
3342
  */
2943
- get maxScrollX() {
2944
- return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
3343
+ get warnings(): string[] {
3344
+ return [
3345
+ ...self.loadWarnings,
3346
+ ...this.dataWarnings.filter(w => !self.dismissedWarnings.includes(w)),
3347
+ ]
2945
3348
  },
2946
3349
  /**
2947
3350
  * #getter
@@ -2968,6 +3371,14 @@ function stateModelFactory() {
2968
3371
  self.drawMsaLetters = arg
2969
3372
  },
2970
3373
 
3374
+ /**
3375
+ * #action
3376
+ */
3377
+ clearWarnings() {
3378
+ self.loadWarnings = []
3379
+ self.dismissedWarnings = self.dataWarnings
3380
+ },
3381
+
2971
3382
  /**
2972
3383
  * #action
2973
3384
  */
@@ -3028,13 +3439,43 @@ function stateModelFactory() {
3028
3439
 
3029
3440
  /**
3030
3441
  * #action
3031
- * swap in a different tree over the same alignment. Clears `collapsed`
3032
- * and `showOnly`, since path-derived node ids (node-0-0-1) from the old
3442
+ * root the tree on the branch above the given node. The node's tips are
3443
+ * a split of the unrooted tree, and the side of it that is a clade in
3444
+ * the tree as written stands for it as an outgroup, by its first and
3445
+ * last tip, so the root survives the path ids changing under it
3446
+ */
3447
+ rerootAt(nodeId: string) {
3448
+ const shown = find(
3449
+ hierarchy(self.tree, d => d.children),
3450
+ n => n.data.id === nodeId,
3451
+ )
3452
+ if (!shown?.parent) {
3453
+ return
3454
+ }
3455
+ const node = outgroupNode(
3456
+ hierarchy(self.inputTree, d => d.children),
3457
+ leaves(shown).map(n => n.data.name),
3458
+ )
3459
+ if (node) {
3460
+ const tips = leaves(node)
3461
+ self.setTreeRoot({
3462
+ outgroup: [
3463
+ ...new Set([tips[0]!.data.name, tips.at(-1)!.data.name]),
3464
+ ],
3465
+ })
3466
+ }
3467
+ },
3468
+
3469
+ /**
3470
+ * #action
3471
+ * swap in a different tree over the same alignment. Clears `collapsed`,
3472
+ * `rotated` and `showOnly`, since path-derived node ids (node-0-0-1) from the old
3033
3473
  * tree would match unrelated nodes in the new one.
3034
3474
  */
3035
3475
  replaceTree(newick: string) {
3036
3476
  transaction(() => {
3037
3477
  self.collapsed.clear()
3478
+ self.rotated.clear()
3038
3479
  self.setShowOnly(undefined)
3039
3480
  self.setTree(newick)
3040
3481
  })
@@ -3048,52 +3489,53 @@ function stateModelFactory() {
3048
3489
  self.setColWidth(defaultColWidth)
3049
3490
  self.setRowHeight(defaultRowHeight)
3050
3491
  },
3492
+ /**
3493
+ * #action
3494
+ * zoom by `scaleFactor` about the center of the alignment area
3495
+ */
3496
+ zoomAtCenter(scaleFactor: number, axis: ScrollZoomAxis = 'both') {
3497
+ this.zoomToPos(
3498
+ scaleFactor,
3499
+ self.msaCanvasWidth / 2,
3500
+ self.msaAreaHeight / 2,
3501
+ axis,
3502
+ )
3503
+ },
3051
3504
  /**
3052
3505
  * #action
3053
3506
  */
3054
3507
  zoomOutHorizontal() {
3055
- self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
3056
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
3508
+ this.zoomAtCenter(0.75, 'horizontal')
3057
3509
  },
3058
3510
  /**
3059
3511
  * #action
3060
3512
  */
3061
3513
  zoomInHorizontal() {
3062
- self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
3063
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
3514
+ this.zoomAtCenter(1.5, 'horizontal')
3064
3515
  },
3065
3516
  /**
3066
3517
  * #action
3067
3518
  */
3068
3519
  zoomInVertical() {
3069
- self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5))
3520
+ this.zoomAtCenter(1.5, 'vertical')
3070
3521
  },
3071
3522
  /**
3072
3523
  * #action
3073
3524
  */
3074
3525
  zoomOutVertical() {
3075
- self.rowHeight = Math.max(
3076
- minRowHeight,
3077
- Math.floor(self.rowHeight * 0.75),
3078
- )
3526
+ this.zoomAtCenter(0.75, 'vertical')
3079
3527
  },
3080
3528
  /**
3081
3529
  * #action
3082
3530
  */
3083
3531
  zoomIn() {
3084
- transaction(() => {
3085
- this.zoomInHorizontal()
3086
- this.zoomInVertical()
3087
- })
3532
+ this.zoomAtCenter(1.5)
3088
3533
  },
3089
3534
  /**
3090
3535
  * #action
3091
3536
  */
3092
3537
  zoomOut() {
3093
- transaction(() => {
3094
- this.zoomOutHorizontal()
3095
- this.zoomOutVertical()
3096
- })
3538
+ this.zoomAtCenter(0.75)
3097
3539
  },
3098
3540
  /**
3099
3541
  * #action
@@ -3214,7 +3656,7 @@ function stateModelFactory() {
3214
3656
  * replace the row table, which the model keeps as the JSON string
3215
3657
  * `data.treeMetadata` (see docs/layers.md)
3216
3658
  */
3217
- setRowData(rowData: Record<string, Record<string, string>>) {
3659
+ setRowData(rowData: RowDataInput) {
3218
3660
  self.data.setTreeMetadata(JSON.stringify(rowData))
3219
3661
  },
3220
3662
  /**
@@ -3256,25 +3698,11 @@ function stateModelFactory() {
3256
3698
  },
3257
3699
  }))
3258
3700
  .views(self => ({
3259
- /**
3260
- * #getter
3261
- */
3262
- get verticalScrollbarWidth() {
3263
- return self.showVerticalScrollbar ? 20 : 0
3264
- },
3265
- /**
3266
- * #getter
3267
- * width of the alignment canvas: the msa area less the vertical
3268
- * scrollbar. showHorizontalScrollbar must not read it, since that feeds
3269
- * msaAreaHeight -> showVerticalScrollbar and would form a cycle
3270
- */
3271
- get msaCanvasWidth() {
3272
- return self.msaAreaWidth - this.verticalScrollbarWidth
3273
- },
3274
3701
  /**
3275
3702
  * #method
3276
3703
  * the cell at a visible column and row index, in the coordinates a host
3277
- * writes highlights in
3704
+ * writes highlights in. On a gap `residue` is undefined and `letter` is
3705
+ * the gap character
3278
3706
  */
3279
3707
  cellAt(visibleCol: number, rowIndex?: number): Cell {
3280
3708
  const column = self.visibleColToGlobalCol(visibleCol)
@@ -3322,7 +3750,7 @@ function stateModelFactory() {
3322
3750
  }
3323
3751
  const { xStart, xEnd } = visibleColRange({
3324
3752
  offsetX: -scrollX,
3325
- blockWidth: this.msaCanvasWidth,
3753
+ blockWidth: self.msaCanvasWidth,
3326
3754
  colWidth,
3327
3755
  })
3328
3756
  const last = Math.max(0, Math.min(xEnd, numColumns) - 1)
@@ -3485,7 +3913,7 @@ function stateModelFactory() {
3485
3913
  add(field, legend)
3486
3914
  }
3487
3915
  }
3488
- for (const { legendTitle, legend } of this.resolvedRowPanels) {
3916
+ for (const { legendTitle, legend } of this.rowPanelScales) {
3489
3917
  add(legendTitle, legend)
3490
3918
  }
3491
3919
  return [...byKey.values()]
@@ -3533,7 +3961,7 @@ function stateModelFactory() {
3533
3961
  // column moves to the neighboring boundary. Bands with no visible
3534
3962
  // columns, or naming a missing row, are dropped.
3535
3963
  const start = self.seqPosToGlobalCol(name, annotation.start - 1)
3536
- const end = self.seqPosToGlobalCol(name, annotation.end - 1)
3964
+ const end = self.seqEndToGlobalCol(name, annotation.end - 1)
3537
3965
  if (start === undefined || end === undefined) {
3538
3966
  return undefined
3539
3967
  }
@@ -3620,19 +4048,9 @@ function stateModelFactory() {
3620
4048
  * on every mouse move.
3621
4049
  */
3622
4050
  get highlightedColumnRuns() {
3623
- const { highlightedColumns } = self
3624
- const runs: { start: number; end: number }[] = []
3625
- for (const col of [...(highlightedColumns ?? [])].sort(
3626
- (a, b) => a - b,
3627
- )) {
3628
- const last = runs.at(-1)
3629
- if (last && col === last.end + 1) {
3630
- last.end = col
3631
- } else {
3632
- runs.push({ start: col, end: col })
3633
- }
3634
- }
3635
- return runs
4051
+ return consecutiveRuns(
4052
+ [...(self.highlightedColumns ?? [])].sort((a, b) => a - b),
4053
+ ).map(([start, end]) => ({ start, end }))
3636
4054
  },
3637
4055
 
3638
4056
  /**
@@ -3688,6 +4106,85 @@ function stateModelFactory() {
3688
4106
  })
3689
4107
  },
3690
4108
 
4109
+ /**
4110
+ * #getter
4111
+ * `selection` projected onto what is on screen, the way
4112
+ * `resolvedHighlights` projects a column span and a row set. A
4113
+ * selection whose columns are all hidden, or whose rows are all
4114
+ * collapsed away or unknown, resolves to undefined.
4115
+ */
4116
+ get resolvedSelection(): ResolvedSelection | undefined {
4117
+ const { selection, rowNamesSet, numRows } = self
4118
+ const span =
4119
+ selection &&
4120
+ self.visibleSpan({ start: selection.start, end: selection.end })
4121
+ if (!selection || !span) {
4122
+ return undefined
4123
+ }
4124
+ const rowRuns = selection.rows
4125
+ ? consecutiveRuns(
4126
+ selection.rows
4127
+ .map(name => rowNamesSet.get(name))
4128
+ .filter(notEmpty)
4129
+ .sort((a, b) => a - b),
4130
+ )
4131
+ : numRows > 0
4132
+ ? [[0, numRows - 1] as [number, number]]
4133
+ : []
4134
+ return rowRuns.length > 0 ? { ...span, rowRuns } : undefined
4135
+ },
4136
+
4137
+ /**
4138
+ * #getter
4139
+ * the columns and rows the selection covers, for the header's readout.
4140
+ * Columns count the file's columns from `start` to `end`, hidden ones
4141
+ * included, as `selectionFasta` copies them.
4142
+ */
4143
+ get selectionSize() {
4144
+ const resolved = this.resolvedSelection
4145
+ const span = this.selectionFileSpan
4146
+ return resolved && span
4147
+ ? {
4148
+ columns: span.end - span.start + 1,
4149
+ rows: sum(resolved.rowRuns.map(([a, b]) => b - a + 1)),
4150
+ }
4151
+ : undefined
4152
+ },
4153
+
4154
+ /**
4155
+ * #getter
4156
+ * `selection`'s columns clamped to the file's
4157
+ */
4158
+ get selectionFileSpan() {
4159
+ const { selection } = self
4160
+ const width = self.MSA?.getWidth() ?? 0
4161
+ const start = Math.max(1, Math.floor(selection?.start ?? 0))
4162
+ const end = Math.min(width, Math.ceil(selection?.end ?? 0))
4163
+ return selection && start <= end ? { start, end } : undefined
4164
+ },
4165
+
4166
+ /**
4167
+ * #getter
4168
+ * the selected block as FASTA: the selected rows on screen, top to
4169
+ * bottom, each with its letters across the file's columns from `start`
4170
+ * to `end`, gaps included
4171
+ */
4172
+ get selectionFasta() {
4173
+ const { rowNames, rowMap } = self
4174
+ const resolved = this.resolvedSelection
4175
+ const span = this.selectionFileSpan
4176
+ if (!resolved || !span) {
4177
+ return ''
4178
+ }
4179
+ const rows = resolved.rowRuns
4180
+ .flatMap(([first, last]) => rowNames.slice(first, last + 1))
4181
+ .flatMap(name => {
4182
+ const seq = rowMap.get(name)
4183
+ return seq === undefined ? [] : [[name, seq] as const]
4184
+ })
4185
+ return blockFasta(rows, span.start, span.end)
4186
+ },
4187
+
3691
4188
  /**
3692
4189
  * #method
3693
4190
  * per-column summary statistics: consensus residue and its identity
@@ -3718,67 +4215,59 @@ function stateModelFactory() {
3718
4215
  /**
3719
4216
  * #getter
3720
4217
  * each encoding with its scale resolved against the values its field
3721
- * takes: a feature channel reads them across the features drawn, every
4218
+ * takes: a feature channel reads them across every feature loaded, every
3722
4219
  * other channel across the row table. Resolved once per change of that
3723
4220
  * table or the encodings, never per row per frame.
3724
4221
  */
3725
4222
  get resolvedEncodings(): ResolvedEncoding[] {
3726
4223
  const rows = Object.values(self.rowData)
3727
- const features = self.filteredAnnotations
3728
4224
  return self.encodings.map(encoding => ({
3729
4225
  ...encoding,
3730
- ...resolveScale(
3731
- encoding.scale,
3732
- featureChannels.has(encoding.channel)
3733
- ? features
3734
- .map(a => featureField(a, encoding.field))
3735
- .filter(notEmpty)
3736
- : rows.map(row => row?.[encoding.field]).filter(notEmpty),
3737
- ),
4226
+ ...(featureChannels.has(encoding.channel)
4227
+ ? resolveFeatureScale(
4228
+ encoding.field,
4229
+ encoding.scale,
4230
+ self.allAnnotations,
4231
+ self.filteredAnnotations,
4232
+ )
4233
+ : resolveScale(
4234
+ encoding.scale,
4235
+ rows.map(row => row?.[encoding.field]).filter(notEmpty),
4236
+ )),
3738
4237
  }))
3739
4238
  },
3740
4239
 
3741
4240
  /**
3742
4241
  * #getter
3743
- * each row panel with its scale resolved against the values its field
3744
- * takes across the row table, giving the color per row name, the pixel
3745
- * column it draws in, and the entries its legend lists. Resolved once
3746
- * per change of that table or the panels, never per block per frame.
4242
+ * each row panel's scale resolved against the values its field takes,
4243
+ * giving the colors, labels and legend entries. Reads no cell size, so
4244
+ * a zoom reuses it and the legends built from it.
3747
4245
  */
3748
- get resolvedRowPanels(): ResolvedRowPanel[] {
3749
- const rows = Object.entries(self.rowData)
4246
+ get rowPanelScales(): (StripPanelScale | FeaturePanelScale)[] {
4247
+ const { rowData } = self
3750
4248
  const { fillPalette, featureFillEncoding, featureLabels } = this
3751
4249
  const annotations = self.filteredAnnotations
3752
- let offsetX = 0
3753
- return self.rowPanels.map((panel, index) => {
3754
- const width = rowPanelWidth(panel, self.rowHeight)
3755
- const base = { id: `rowpanel-${index}`, width, offsetX }
3756
- offsetX += width
4250
+ return self.rowPanels.map(panel => {
3757
4251
  if (panel.kind === 'features') {
3758
4252
  const color = panel.encoding?.color
3759
4253
  const encoding = color
3760
- ? resolveFeatureScale(color.field, color.scale, annotations)
4254
+ ? resolveFeatureScale(
4255
+ color.field,
4256
+ color.scale,
4257
+ self.allAnnotations,
4258
+ annotations,
4259
+ )
3761
4260
  : featureFillEncoding
3762
4261
  const label = panel.encoding?.label
3763
- const align = panel.transform?.find(t => t.type === 'align')
3764
4262
  return {
3765
- ...base,
3766
4263
  kind: panel.kind,
3767
4264
  x: panel.x,
3768
4265
  header: panel.header ?? '',
3769
4266
  field: encoding?.field,
3770
4267
  legendTitle: encoding?.field,
3771
- spans: featurePanelSpans({
3772
- panel,
3773
- width,
3774
- colWidth: self.colWidth,
3775
- domainBands: this.domainBands,
3776
- annotationsByRow: self.annotationsByRow,
3777
- shifts: align
3778
- ? this.featureAlignShifts.get(align.on)
3779
- : undefined,
3780
- }),
3781
- colors: featureColorMap(annotations, encoding, fillPalette),
4268
+ colors: color
4269
+ ? featureColorMap(annotations, encoding, fillPalette)
4270
+ : this.featureColors,
3782
4271
  labels: label
3783
4272
  ? featureLabelMap(annotations, label)
3784
4273
  : featureLabels,
@@ -3793,28 +4282,56 @@ function stateModelFactory() {
3793
4282
  }
3794
4283
  const { colorOf, legend } = resolveScale(
3795
4284
  panel.scale,
3796
- rows.map(([, row]) => row?.[panel.field]).filter(notEmpty),
4285
+ Object.values(rowData)
4286
+ .map(row => row?.[panel.field])
4287
+ .filter(notEmpty),
3797
4288
  )
3798
- const colors = new Map<string, string>()
3799
- for (const [name, row] of rows) {
3800
- const value = row?.[panel.field]
3801
- const color = value === undefined ? undefined : colorOf(value)
3802
- if (color) {
3803
- colors.set(name, color)
3804
- }
3805
- }
3806
4289
  return {
3807
- ...base,
3808
4290
  kind: panel.kind,
3809
4291
  field: panel.field,
3810
4292
  header: panel.header ?? panel.field,
3811
4293
  legendTitle: panel.legend ?? panel.field,
3812
- colors,
4294
+ colors: colorByRow(rowData, panel.field, colorOf),
3813
4295
  legend,
3814
4296
  }
3815
4297
  })
3816
4298
  },
3817
4299
 
4300
+ /**
4301
+ * #getter
4302
+ * each row panel with its scale from `rowPanelScales` and its geometry:
4303
+ * the pixel column it draws in and, for a features panel, the spans per
4304
+ * row. Resolved once per change of those inputs, never per block per
4305
+ * frame.
4306
+ */
4307
+ get resolvedRowPanels(): ResolvedRowPanel[] {
4308
+ const scales = this.rowPanelScales
4309
+ let offsetX = 0
4310
+ return self.rowPanels.map((panel, index) => {
4311
+ const width = rowPanelWidth(panel, self)
4312
+ const base = { id: `rowpanel-${index}`, width, offsetX }
4313
+ offsetX += width
4314
+ if (panel.kind === 'features') {
4315
+ const align = panel.transform?.find(t => t.type === 'align')
4316
+ return {
4317
+ ...base,
4318
+ ...(scales[index] as FeaturePanelScale),
4319
+ spans: featurePanelSpans({
4320
+ panel,
4321
+ width,
4322
+ colWidth: self.colWidth,
4323
+ domainBands: this.domainBands,
4324
+ annotationsByRow: self.annotationsByRow,
4325
+ shifts: align
4326
+ ? this.featureAlignShifts.get(align.on)
4327
+ : undefined,
4328
+ }),
4329
+ }
4330
+ }
4331
+ return { ...base, ...(scales[index] as StripPanelScale) }
4332
+ })
4333
+ },
4334
+
3818
4335
  /**
3819
4336
  * #getter
3820
4337
  * the shift an `align` transform gives each row, keyed by the feature
@@ -3857,19 +4374,9 @@ function stateModelFactory() {
3857
4374
  const encoding = this.resolvedEncodings.find(
3858
4375
  e => e.channel === 'tipLabel',
3859
4376
  )
3860
- if (!encoding) {
3861
- return undefined
3862
- }
3863
- const colors = new Map<string, string>()
3864
- for (const [name, row] of Object.entries(self.rowData)) {
3865
- const value = row?.[encoding.field]
3866
- const color =
3867
- value === undefined ? undefined : encoding.colorOf(value)
3868
- if (color) {
3869
- colors.set(name, color)
3870
- }
3871
- }
3872
- return colors
4377
+ return encoding
4378
+ ? colorByRow(self.rowData, encoding.field, encoding.colorOf)
4379
+ : undefined
3873
4380
  },
3874
4381
 
3875
4382
  /**
@@ -3885,14 +4392,14 @@ function stateModelFactory() {
3885
4392
  if (!encoding) {
3886
4393
  return undefined
3887
4394
  }
4395
+ const colors = colorByRow(
4396
+ self.rowData,
4397
+ encoding.field,
4398
+ encoding.colorOf,
4399
+ )
3888
4400
  return self.rowNames.map(name => {
3889
- const value = self.rowDataOf(name)?.[encoding.field]
3890
- const color =
3891
- value === undefined ? undefined : encoding.colorOf(value)
3892
- if (!color) {
3893
- return undefined
3894
- }
3895
- return withAlpha(color, rowTintAlpha)
4401
+ const color = colors.get(name)
4402
+ return color && withAlpha(color, rowTintAlpha)
3896
4403
  })
3897
4404
  },
3898
4405
 
@@ -3974,7 +4481,8 @@ function stateModelFactory() {
3974
4481
  */
3975
4482
  reset() {
3976
4483
  self.resetCount++
3977
- self.clearWarnings()
4484
+ self.loadWarnings = []
4485
+ self.dismissedWarnings = []
3978
4486
  self.setStatus(undefined)
3979
4487
  applySnapshot(
3980
4488
  self,
@@ -4001,6 +4509,21 @@ function stateModelFactory() {
4001
4509
  const blob = new Blob([html], { type: 'image/svg+xml' })
4002
4510
  saveAs(blob, exportFileName(self.msaFilehandle, 'svg'))
4003
4511
  },
4512
+ /**
4513
+ * #action
4514
+ * save `treeNewick` as a file
4515
+ */
4516
+ exportNewick() {
4517
+ const blob = new Blob([self.treeNewick], { type: 'text/plain' })
4518
+ saveAs(
4519
+ blob,
4520
+ exportFileName(
4521
+ self.treeFilehandle ?? self.msaFilehandle,
4522
+ 'nwk',
4523
+ 'tree',
4524
+ ),
4525
+ )
4526
+ },
4004
4527
  /**
4005
4528
  * #action
4006
4529
  * show or hide an annotation type. Only hidden types are recorded; see
@@ -4064,6 +4587,63 @@ function stateModelFactory() {
4064
4587
  )
4065
4588
  })
4066
4589
  },
4590
+ /**
4591
+ * #action
4592
+ * zoom and scroll so the selected columns fill the alignment's width,
4593
+ * and, where the selection names its rows, so those rows fill its height
4594
+ */
4595
+ zoomToSelection() {
4596
+ const { selection, resolvedSelection } = self
4597
+ if (!selection || !resolvedSelection) {
4598
+ return
4599
+ }
4600
+ transaction(() => {
4601
+ this.zoomToRegion({ start: selection.start, end: selection.end })
4602
+ if (selection.rows && self.viewInitialized) {
4603
+ const { rowRuns } = resolvedSelection
4604
+ const first = rowRuns[0]![0]
4605
+ const last = rowRuns.at(-1)![1]
4606
+ self.rowHeight = clamp(
4607
+ self.msaAreaHeight / (last - first + 1),
4608
+ minRowHeight,
4609
+ maxCellSize,
4610
+ )
4611
+ self.scrollY = clamp(-first * self.rowHeight, self.maxScrollY, 0)
4612
+ }
4613
+ })
4614
+ },
4615
+ /**
4616
+ * #action
4617
+ * select the block between two cells given as visible column and row
4618
+ * indices, the coordinates a drag reports, clamped to the alignment. A
4619
+ * cell with no `row` selects the columns across every row, and so does a
4620
+ * block spanning every row on screen.
4621
+ */
4622
+ selectBlock(
4623
+ anchor: { col: number; row?: number },
4624
+ head: { col: number; row?: number },
4625
+ ) {
4626
+ const { numColumns, numRows, rowNames } = self
4627
+ if (numColumns === 0 || numRows === 0) {
4628
+ return
4629
+ }
4630
+ const ordered = (a: number, b: number, last: number) =>
4631
+ [clamp(a, 0, last), clamp(b, 0, last)].sort((x, y) => x - y) as [
4632
+ number,
4633
+ number,
4634
+ ]
4635
+ const [startCol, endCol] = ordered(anchor.col, head.col, numColumns - 1)
4636
+ const [firstRow, lastRow] =
4637
+ anchor.row === undefined || head.row === undefined
4638
+ ? [0, numRows - 1]
4639
+ : ordered(anchor.row, head.row, numRows - 1)
4640
+ const everyRow = firstRow === 0 && lastRow === numRows - 1
4641
+ self.setSelection({
4642
+ start: self.visibleColToGlobalCol(startCol) + 1,
4643
+ end: self.visibleColToGlobalCol(endCol) + 1,
4644
+ ...(everyRow ? {} : { rows: rowNames.slice(firstRow, lastRow + 1) }),
4645
+ })
4646
+ },
4067
4647
  /**
4068
4648
  * #action
4069
4649
  */
@@ -4084,38 +4664,45 @@ function stateModelFactory() {
4084
4664
  self.setHighlightedColumns(self.highlightColumns)
4085
4665
  }
4086
4666
 
4087
- // The `collapse` and `focus` clade marks seed the collapsed list and
4088
- // the subtree in focus, which the tree, `hideGapsEffective` and the
4089
- // alignment all read. The tree arrives with the model for inline data
4090
- // and later for a filehandle, so the seeding waits for it and then runs
4091
- // once: expanding a seeded clade sticks, and the record collapses it
4092
- // again only on reload. `dataInitialized` is true once the MSA alone
4093
- // has loaded, when the tree is still the flat stub, so a tree
4094
- // filehandle holds the seeding until its text lands.
4095
- let cladesSeeded = false
4667
+ // The `collapse`, `focus` and `rotate` clade marks seed the collapsed
4668
+ // list, the subtree in focus and the rotated nodes, which the tree,
4669
+ // `hideGapsEffective` and the alignment all read. The tree arrives with
4670
+ // the model for inline data and later for a filehandle, so the seeding
4671
+ // waits for it and then runs once per data load, per change of
4672
+ // `clades` and per reroot, which clears the node ids the seeding
4673
+ // wrote: expanding a seeded clade sticks until one of those changes.
4674
+ // `dataInitialized` is true once the MSA alone has loaded, when the
4675
+ // tree is still the flat stub, so a tree filehandle holds the seeding
4676
+ // until its text lands.
4096
4677
  addDisposer(
4097
4678
  self,
4098
- autorun(() => {
4099
- if (
4100
- cladesSeeded ||
4101
- !self.dataInitialized ||
4102
- (self.treeFilehandle && !self.data.tree) ||
4103
- self.clades.length === 0
4104
- ) {
4105
- return
4106
- }
4107
- cladesSeeded = true
4108
- for (const { mark, nodeId } of self.resolvedClades) {
4109
- if (nodeId === undefined) {
4110
- continue
4679
+ reaction(
4680
+ () =>
4681
+ self.dataInitialized && !(self.treeFilehandle && !self.data.tree)
4682
+ ? { clades: getSnapshot(self.clades), treeRoot: self.treeRoot }
4683
+ : undefined,
4684
+ seed => {
4685
+ if (!seed?.clades.length) {
4686
+ return
4111
4687
  }
4112
- if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
4113
- self.toggleCollapsed(nodeId)
4114
- } else if (mark === 'focus') {
4115
- self.setShowOnly(nodeId)
4688
+ for (const { mark, nodeId } of self.resolvedClades) {
4689
+ if (nodeId === undefined) {
4690
+ continue
4691
+ }
4692
+ if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
4693
+ self.toggleCollapsed(nodeId)
4694
+ } else if (mark === 'focus') {
4695
+ self.setShowOnly(nodeId)
4696
+ } else if (
4697
+ mark === 'rotate' &&
4698
+ !self.rotated.includes(nodeId)
4699
+ ) {
4700
+ self.toggleRotated(nodeId)
4701
+ }
4116
4702
  }
4117
- }
4118
- }),
4703
+ },
4704
+ { fireImmediately: true, equals: compareStructural },
4705
+ ),
4119
4706
  )
4120
4707
 
4121
4708
  // zoomToRegion needs a width and resolves residues through the visible
@@ -4302,24 +4889,6 @@ function stateModelFactory() {
4302
4889
  }),
4303
4890
  )
4304
4891
 
4305
- // a GFF whose first column names rows of some other alignment parses
4306
- // without error and draws nothing
4307
- addDisposer(
4308
- self,
4309
- autorun(() => {
4310
- const { annotations, rowNamesSet } = self
4311
- if (
4312
- annotations.length > 0 &&
4313
- rowNamesSet.size > 0 &&
4314
- !annotations.some(a => rowNamesSet.has(a.id))
4315
- ) {
4316
- self.addWarning(
4317
- `0 of ${annotations.length} annotations name a row in this alignment`,
4318
- )
4319
- }
4320
- }),
4321
- )
4322
-
4323
4892
  // gffFilehandle loads into data.gff, which the autorun above parses
4324
4893
  loadOnFilehandleChange({
4325
4894
  what: 'annotations',
@@ -4356,7 +4925,7 @@ function stateModelFactory() {
4356
4925
  addDisposer(
4357
4926
  self,
4358
4927
  autorun(() => {
4359
- if (self.colorSchemeName.includes('dynamic')) {
4928
+ if (self.dynamicColorSchemeName) {
4360
4929
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
4361
4930
  self.colStats
4362
4931
  }
@@ -4425,7 +4994,7 @@ function stateModelFactory() {
4425
4994
  // stripDefault handles per-property defaults; this drops inline documents
4426
4995
  // whose filehandle can refetch them
4427
4996
  ...rest,
4428
- ...smallColumnTracks(columnTracks),
4997
+ ...smallColumnTracks(columnTracks, maxInlineSnapshotBytes),
4429
4998
  data: {
4430
4999
  ...(rest.treeFilehandle ? {} : { tree: data.tree }),
4431
5000
  ...(rest.msaFilehandle ? {} : { msa: data.msa }),