react-msaview 8.1.0 → 8.3.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 (195) hide show
  1. package/bundle/index.js +104 -117
  2. package/bundle/index.js.map +4 -4
  3. package/dist/columnCounts.d.ts +4 -3
  4. package/dist/columnCounts.js +11 -6
  5. package/dist/columnCounts.js.map +1 -1
  6. package/dist/components/CopyButton.d.ts +1 -1
  7. package/dist/components/CopyButton.js +1 -1
  8. package/dist/components/CopyButton.js.map +1 -1
  9. package/dist/components/DragHandle.js +14 -2
  10. package/dist/components/DragHandle.js.map +1 -1
  11. package/dist/components/Loading.js +10 -11
  12. package/dist/components/Loading.js.map +1 -1
  13. package/dist/components/MSAViewer.d.ts +12 -1
  14. package/dist/components/MSAViewer.js +12 -1
  15. package/dist/components/MSAViewer.js.map +1 -1
  16. package/dist/components/SequenceTextArea.d.ts +11 -0
  17. package/dist/components/SequenceTextArea.js +40 -8
  18. package/dist/components/SequenceTextArea.js.map +1 -1
  19. package/dist/components/StatusMessage.d.ts +12 -0
  20. package/dist/components/StatusMessage.js +43 -0
  21. package/dist/components/StatusMessage.js.map +1 -0
  22. package/dist/components/Track.js +3 -3
  23. package/dist/components/Track.js.map +1 -1
  24. package/dist/components/VerticalScrollbar.js +6 -1
  25. package/dist/components/VerticalScrollbar.js.map +1 -1
  26. package/dist/components/dialogs/ExportSVGDialog.d.ts +3 -2
  27. package/dist/components/dialogs/ExportSVGDialog.js +4 -2
  28. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  29. package/dist/components/dialogs/FeatureDialog.js +15 -10
  30. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  31. package/dist/components/getVisibleLeaves.d.ts +1 -1
  32. package/dist/components/header/FileMenu.js +9 -0
  33. package/dist/components/header/FileMenu.js.map +1 -1
  34. package/dist/components/header/GappynessSlider.js +15 -3
  35. package/dist/components/header/GappynessSlider.js.map +1 -1
  36. package/dist/components/header/Header.js +6 -7
  37. package/dist/components/header/Header.js.map +1 -1
  38. package/dist/components/header/{HeaderStatusArea.d.ts → RowSearch.d.ts} +2 -2
  39. package/dist/components/header/RowSearch.js +15 -0
  40. package/dist/components/header/RowSearch.js.map +1 -0
  41. package/dist/components/header/revealRow.d.ts +3 -0
  42. package/dist/components/header/revealRow.js +15 -0
  43. package/dist/components/header/revealRow.js.map +1 -0
  44. package/dist/components/header/settingsMenuItems.js +57 -1
  45. package/dist/components/header/settingsMenuItems.js.map +1 -1
  46. package/dist/components/import/ImportForm.js +10 -4
  47. package/dist/components/import/ImportForm.js.map +1 -1
  48. package/dist/components/import/ImportFormExamples.js +19 -27
  49. package/dist/components/import/ImportFormExamples.js.map +1 -1
  50. package/dist/components/minimap/Minimap.js +6 -1
  51. package/dist/components/minimap/Minimap.js.map +1 -1
  52. package/dist/components/msa/AnnotationLegend.js +11 -5
  53. package/dist/components/msa/AnnotationLegend.js.map +1 -1
  54. package/dist/components/msa/MSACanvas.js +6 -1
  55. package/dist/components/msa/MSACanvas.js.map +1 -1
  56. package/dist/components/msa/MSACanvasBlock.js +3 -7
  57. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  58. package/dist/components/msa/msaKeys.d.ts +5 -0
  59. package/dist/components/msa/msaKeys.js +35 -0
  60. package/dist/components/msa/msaKeys.js.map +1 -0
  61. package/dist/components/msa/useMsaBlockMouse.d.ts +2 -4
  62. package/dist/components/msa/useMsaBlockMouse.js +18 -5
  63. package/dist/components/msa/useMsaBlockMouse.js.map +1 -1
  64. package/dist/components/rowpanels/RowPanelBlock.js +10 -14
  65. package/dist/components/rowpanels/RowPanelBlock.js.map +1 -1
  66. package/dist/components/tracks/drawTracks.d.ts +1 -0
  67. package/dist/components/tracks/drawTracks.js +14 -2
  68. package/dist/components/tracks/drawTracks.js.map +1 -1
  69. package/dist/components/tracks/useTrackHover.d.ts +1 -5
  70. package/dist/components/tracks/useTrackHover.js +5 -5
  71. package/dist/components/tracks/useTrackHover.js.map +1 -1
  72. package/dist/components/tree/ScaleBarSVG.d.ts +14 -0
  73. package/dist/components/tree/ScaleBarSVG.js +11 -0
  74. package/dist/components/tree/ScaleBarSVG.js.map +1 -0
  75. package/dist/components/tree/TreeCanvas.js +8 -26
  76. package/dist/components/tree/TreeCanvas.js.map +1 -1
  77. package/dist/components/tree/TreeCanvasBlock.js +11 -20
  78. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  79. package/dist/components/tree/{TreeBranchMenu.d.ts → TreeMenu.d.ts} +5 -4
  80. package/dist/components/tree/TreeMenu.js +48 -0
  81. package/dist/components/tree/TreeMenu.js.map +1 -0
  82. package/dist/components/tree/TreeRuler.js +3 -3
  83. package/dist/components/tree/TreeRuler.js.map +1 -1
  84. package/dist/components/tree/renderTreeCanvas.js +19 -9
  85. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  86. package/dist/components/tree/renderTreeMouseover.d.ts +5 -0
  87. package/dist/components/tree/renderTreeMouseover.js +20 -0
  88. package/dist/components/tree/renderTreeMouseover.js.map +1 -0
  89. package/dist/components/tree/useTreeHover.d.ts +3 -8
  90. package/dist/components/tree/useTreeHover.js +8 -10
  91. package/dist/components/tree/useTreeHover.js.map +1 -1
  92. package/dist/components/useHoverAnchor.d.ts +19 -0
  93. package/dist/components/useHoverAnchor.js +18 -0
  94. package/dist/components/useHoverAnchor.js.map +1 -0
  95. package/dist/constants.d.ts +4 -1
  96. package/dist/constants.js +12 -3
  97. package/dist/constants.js.map +1 -1
  98. package/dist/expandSpec.d.ts +46 -0
  99. package/dist/expandSpec.js +127 -0
  100. package/dist/expandSpec.js.map +1 -0
  101. package/dist/exportFileName.d.ts +3 -3
  102. package/dist/exportFileName.js +4 -4
  103. package/dist/exportFileName.js.map +1 -1
  104. package/dist/hierarchy.d.ts +51 -0
  105. package/dist/hierarchy.js +125 -0
  106. package/dist/hierarchy.js.map +1 -1
  107. package/dist/index.d.ts +4 -2
  108. package/dist/index.js +2 -0
  109. package/dist/index.js.map +1 -1
  110. package/dist/model/treeModel.d.ts +12 -0
  111. package/dist/model/treeModel.js +14 -1
  112. package/dist/model/treeModel.js.map +1 -1
  113. package/dist/model.d.ts +188 -47
  114. package/dist/model.js +482 -192
  115. package/dist/model.js.map +1 -1
  116. package/dist/renderToSvg.js +2 -4
  117. package/dist/renderToSvg.js.map +1 -1
  118. package/dist/rerootTree.d.ts +33 -0
  119. package/dist/rerootTree.js +144 -0
  120. package/dist/rerootTree.js.map +1 -0
  121. package/dist/svgTestUtil.d.ts +114 -20
  122. package/dist/types.d.ts +25 -12
  123. package/dist/useMsaSvgFigure.d.ts +2 -1
  124. package/dist/useMsaSvgFigure.js +11 -4
  125. package/dist/useMsaSvgFigure.js.map +1 -1
  126. package/dist/version.d.ts +1 -1
  127. package/dist/version.js +1 -1
  128. package/dist/writeNewick.d.ts +8 -0
  129. package/dist/writeNewick.js +34 -0
  130. package/dist/writeNewick.js.map +1 -0
  131. package/package.json +3 -3
  132. package/src/columnCounts.ts +14 -6
  133. package/src/components/CopyButton.tsx +2 -2
  134. package/src/components/DragHandle.tsx +21 -1
  135. package/src/components/Loading.tsx +14 -30
  136. package/src/components/MSAViewer.tsx +26 -0
  137. package/src/components/SequenceTextArea.tsx +54 -8
  138. package/src/components/StatusMessage.tsx +70 -0
  139. package/src/components/Track.tsx +8 -4
  140. package/src/components/VerticalScrollbar.tsx +7 -0
  141. package/src/components/dialogs/ExportSVGDialog.tsx +5 -2
  142. package/src/components/dialogs/FeatureDialog.tsx +59 -38
  143. package/src/components/header/FileMenu.tsx +9 -0
  144. package/src/components/header/GappynessSlider.tsx +17 -4
  145. package/src/components/header/Header.tsx +7 -8
  146. package/src/components/header/RowSearch.tsx +34 -0
  147. package/src/components/header/revealRow.ts +17 -0
  148. package/src/components/header/settingsMenuItems.ts +66 -0
  149. package/src/components/import/ImportForm.tsx +14 -7
  150. package/src/components/import/ImportFormExamples.tsx +20 -28
  151. package/src/components/minimap/Minimap.tsx +7 -0
  152. package/src/components/msa/AnnotationLegend.tsx +12 -6
  153. package/src/components/msa/MSACanvas.tsx +8 -0
  154. package/src/components/msa/MSACanvasBlock.tsx +5 -11
  155. package/src/components/msa/msaKeys.ts +39 -0
  156. package/src/components/msa/useMsaBlockMouse.ts +23 -7
  157. package/src/components/rowpanels/RowPanelBlock.tsx +16 -23
  158. package/src/components/tracks/drawTracks.ts +15 -2
  159. package/src/components/tracks/useTrackHover.ts +5 -5
  160. package/src/components/tree/ScaleBarSVG.tsx +30 -0
  161. package/src/components/tree/TreeCanvas.tsx +9 -40
  162. package/src/components/tree/TreeCanvasBlock.tsx +19 -39
  163. package/src/components/tree/TreeMenu.tsx +129 -0
  164. package/src/components/tree/TreeRuler.tsx +7 -13
  165. package/src/components/tree/renderTreeCanvas.ts +20 -8
  166. package/src/components/tree/renderTreeMouseover.ts +41 -0
  167. package/src/components/tree/useTreeHover.ts +8 -17
  168. package/src/components/useHoverAnchor.ts +31 -0
  169. package/src/constants.ts +14 -3
  170. package/src/expandSpec.ts +196 -0
  171. package/src/exportFileName.ts +4 -3
  172. package/src/hierarchy.ts +151 -4
  173. package/src/index.ts +11 -1
  174. package/src/model/treeModel.ts +22 -0
  175. package/src/model.ts +588 -221
  176. package/src/renderToSvg.tsx +6 -9
  177. package/src/rerootTree.ts +172 -0
  178. package/src/types.ts +34 -12
  179. package/src/useMsaSvgFigure.ts +11 -4
  180. package/src/version.ts +1 -1
  181. package/src/writeNewick.ts +38 -0
  182. package/dist/components/PortalTooltip.d.ts +0 -11
  183. package/dist/components/PortalTooltip.js +0 -29
  184. package/dist/components/PortalTooltip.js.map +0 -1
  185. package/dist/components/header/HeaderStatusArea.js +0 -21
  186. package/dist/components/header/HeaderStatusArea.js.map +0 -1
  187. package/dist/components/tree/TreeBranchMenu.js +0 -29
  188. package/dist/components/tree/TreeBranchMenu.js.map +0 -1
  189. package/dist/components/tree/TreeNodeMenu.d.ts +0 -13
  190. package/dist/components/tree/TreeNodeMenu.js +0 -41
  191. package/dist/components/tree/TreeNodeMenu.js.map +0 -1
  192. package/src/components/PortalTooltip.tsx +0 -47
  193. package/src/components/header/HeaderStatusArea.tsx +0 -32
  194. package/src/components/tree/TreeBranchMenu.tsx +0 -68
  195. 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,
@@ -74,10 +80,10 @@ import {
74
80
  calcDepthToLeaf,
75
81
  clusterLayout,
76
82
  collapse,
77
- collapsedSubtreeMaxLength,
78
83
  find,
79
84
  forEachDescendant,
80
85
  hierarchy,
86
+ layoutTree,
81
87
  leafIndex,
82
88
  leaves,
83
89
  maxLength,
@@ -95,6 +101,7 @@ import { TreeModelF } from './model/treeModel.ts'
95
101
  import { calculateNeighborJoiningTree } from './neighborJoining.ts'
96
102
  import { parseAsn1 } from './parseAsn1.ts'
97
103
  import { calculatePropertyConservation } from './propertyConservation.ts'
104
+ import { midpointRoot, rerootTree } from './rerootTree.ts'
98
105
  import {
99
106
  globalColToVisibleCol,
100
107
  seqPosOfGlobalCol,
@@ -114,12 +121,14 @@ import {
114
121
  withAlpha,
115
122
  } from './util.ts'
116
123
  import { saveAs } from './vendor/fileSaver.ts'
124
+ import { writeNewick } from './writeNewick.ts'
117
125
  import { parseWuss } from './wuss.ts'
118
126
 
119
127
  import type { ColumnStats } from './columnStats.ts'
120
128
  import type { ScrollZoomAxis } from './constants.ts'
121
129
  import type { HierarchyNode } from './hierarchy.ts'
122
130
  import type { ExportSvgOptions } from './renderToSvg.tsx'
131
+ import type { TreeNode } from './rerootTree.ts'
123
132
  import type { ScaleSpec } from './scales.ts'
124
133
  import type {
125
134
  Annotation,
@@ -127,6 +136,7 @@ import type {
127
136
  BasicTrack,
128
137
  Cell,
129
138
  Clade,
139
+ CladeMark,
130
140
  ResidueMappingProblem,
131
141
  ColumnTrackSpec,
132
142
  DomainBand,
@@ -136,14 +146,17 @@ import type {
136
146
  Legend,
137
147
  LegendEntry,
138
148
  NodeWithIds,
139
- NodeWithIdsAndLength,
140
149
  Region,
141
150
  ResidueMapping,
142
151
  ResidueSegment,
143
152
  ResolvedClade,
144
153
  ResolvedEncoding,
154
+ ResolvedFeaturePanel,
145
155
  ResolvedHighlight,
156
+ TreeOrder,
157
+ TreeRoot,
146
158
  ResolvedRowPanel,
159
+ ResolvedStripPanel,
147
160
  RowFeaturesSpec,
148
161
  RowPanelSpan,
149
162
  RowPanelSpec,
@@ -202,14 +215,39 @@ const UNSCALED_FEATURE = '#d9d9d9'
202
215
  const laneOverlap = 0.1
203
216
 
204
217
  // 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) {
218
+ // cell square, and a gene neighborhood's width for a features panel. The row
219
+ // height is read only when used, so a panel with a width of its own does not
220
+ // recompute on a vertical zoom
221
+ function rowPanelWidth(panel: RowPanelSpec, view: { rowHeight: number }) {
207
222
  return (
208
223
  panel.width ??
209
- (panel.kind === 'features' ? defaultFeaturePanelWidth : rowHeight)
224
+ (panel.kind === 'features' ? defaultFeaturePanelWidth : view.rowHeight)
210
225
  )
211
226
  }
212
227
 
228
+ // the color each row takes from a field of the row table under a scale
229
+ function colorByRow(
230
+ rowData: Record<string, Record<string, string> | undefined>,
231
+ field: string,
232
+ colorOf: (value: string) => string | undefined,
233
+ ) {
234
+ const colors = new Map<string, string>()
235
+ for (const [name, row] of Object.entries(rowData)) {
236
+ const value = row?.[field]
237
+ const color = value === undefined ? undefined : colorOf(value)
238
+ if (color) {
239
+ colors.set(name, color)
240
+ }
241
+ }
242
+ return colors
243
+ }
244
+
245
+ type StripPanelScale = Omit<ResolvedStripPanel, 'id' | 'width' | 'offsetX'>
246
+ type FeaturePanelScale = Omit<
247
+ ResolvedFeaturePanel,
248
+ 'id' | 'width' | 'offsetX' | 'spans'
249
+ >
250
+
213
251
  // the fill and outline of every feature in a list: its own GFF `color=` first,
214
252
  // then the scale an encoding resolves over one of its fields, then the
215
253
  // accession palette
@@ -402,10 +440,21 @@ function resolveFeatureScale(
402
440
  }
403
441
  }
404
442
 
443
+ const columnTrackBytes = new WeakMap<ColumnTrackSpec, number>()
444
+
445
+ function columnTrackSize(track: ColumnTrackSpec) {
446
+ let bytes = columnTrackBytes.get(track)
447
+ if (bytes === undefined) {
448
+ bytes = JSON.stringify(track).length
449
+ columnTrackBytes.set(track, bytes)
450
+ }
451
+ return bytes
452
+ }
453
+
405
454
  // a data track over this size stays in the live model but leaves the snapshot,
406
455
  // the same rule DataModel applies to an inline document
407
456
  function columnTrackSizes(tracks?: readonly ColumnTrackSpec[]) {
408
- return (tracks ?? []).map(t => JSON.stringify(t).length)
457
+ return (tracks ?? []).map(columnTrackSize)
409
458
  }
410
459
 
411
460
  function smallColumnTracks(tracks?: ColumnTrackSpec[]) {
@@ -441,6 +490,7 @@ export const preservedOnReset = new Set([
441
490
  'treeAreaWidth',
442
491
  'treeWidth',
443
492
  'showBranchLen',
493
+ 'treeOrder',
444
494
  'drawTree',
445
495
  'drawNodeBubbles',
446
496
  'drawNodeLabels',
@@ -467,6 +517,9 @@ function trackIsOff(
467
517
  return turnedOffTracks.get(id) ?? (defaultOff || defaultOffTracks.has(id))
468
518
  }
469
519
 
520
+ // the marks that set tree state from a node rather than drawing
521
+ const seedingMarks = new Set<CladeMark>(['collapse', 'focus', 'rotate'])
522
+
470
523
  // shared empty results, so observers don't see a fresh [] as a change
471
524
  const noDomains: Annotation[] = []
472
525
  const noClades: ResolvedClade[] = []
@@ -519,8 +572,7 @@ function cladeRows(
519
572
  * Every clade that resolves, with its fill color settled, against the leaf
520
573
  * order `rowNamesSet` gives. The tree panel resolves against the displayed
521
574
  * 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.
575
+ * `range` record names no node, so the seeding marks, which need one, drop it.
524
576
  */
525
577
  function resolveClades(
526
578
  clades: Clade[],
@@ -530,7 +582,7 @@ function resolveClades(
530
582
  const index = leafIndex(root)
531
583
  return clades.flatMap(clade => {
532
584
  const resolved = cladeRows(clade, root, index, rowNamesSet)
533
- const seeding = clade.mark === 'collapse' || clade.mark === 'focus'
585
+ const seeding = seedingMarks.has(clade.mark)
534
586
  return resolved && !(seeding && resolved.nodeId === undefined)
535
587
  ? [
536
588
  {
@@ -548,20 +600,50 @@ function resolveClades(
548
600
  })
549
601
  }
550
602
 
603
+ const childOrders: Record<
604
+ TreeOrder,
605
+ ((a: HierarchyNode, b: HierarchyNode) => number) | undefined
606
+ > = {
607
+ input: undefined,
608
+ branchLength: (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1),
609
+ ladderize: (a, b) => a.value! - b.value!,
610
+ ladderizeReverse: (a, b) => b.value! - a.value!,
611
+ }
612
+
551
613
  /**
552
614
  * 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.
615
+ * put in `order` and each `rotated` node's reversed, `showOnly` taken as the
616
+ * new root, and each `collapsed` clade folded. The overview builds the same
617
+ * tree without the focus, so it shows the focused subtree inside the whole.
556
618
  */
557
619
  function buildTreeRoot(
558
620
  tree: NodeWithIds,
559
- collapsed: readonly string[],
560
- showOnly?: string,
621
+ {
622
+ collapsed,
623
+ rotated,
624
+ order,
625
+ showOnly,
626
+ }: {
627
+ collapsed: readonly string[]
628
+ rotated: readonly string[]
629
+ order: TreeOrder
630
+ showOnly?: string
631
+ },
561
632
  ) {
562
633
  let hier = hierarchy(tree, d => d.children)
563
634
  hierarchySum(hier, d => (d.children.length > 0 ? 0 : 1))
564
- sort(hier, (a, b) => (a.data.length ?? 1) - (b.data.length ?? 1))
635
+ const compare = childOrders[order]
636
+ if (compare) {
637
+ sort(hier, compare)
638
+ }
639
+ if (rotated.length > 0) {
640
+ const flip = new Set(rotated)
641
+ forEachDescendant(hier, n => {
642
+ if (flip.has(n.data.id)) {
643
+ n.children?.reverse()
644
+ }
645
+ })
646
+ }
565
647
 
566
648
  if (showOnly) {
567
649
  const res = find(hier, n => n.data.id === showOnly)
@@ -586,6 +668,53 @@ function buildTreeRoot(
586
668
  return hier
587
669
  }
588
670
 
671
+ /**
672
+ * The node whose branch an outgroup roots the tree on: the outgroup's most
673
+ * recent common ancestor, or the rest of the tips' where that ancestor is the
674
+ * root, since the outgroup then straddles the root and the branch above the
675
+ * other side is the same branch of the unrooted tree.
676
+ */
677
+ function outgroupNode(root: HierarchyNode, outgroup: string[]) {
678
+ const index = leafIndex(root)
679
+ const node = mrca(root, outgroup, index)
680
+ if (node !== root) {
681
+ return node
682
+ }
683
+ const out = new Set(outgroup)
684
+ const rest = leaves(root)
685
+ .map(n => n.data.name)
686
+ .filter(name => !out.has(name))
687
+ const other = mrca(root, rest, index)
688
+ return other === root ? undefined : other
689
+ }
690
+
691
+ /**
692
+ * `tree` rerooted as `treeRoot` asks, with fresh path ids. The ids of the tree
693
+ * as written become names on unnamed nodes, and a rerooting would carry them
694
+ * along as labels, so they come off first.
695
+ */
696
+ function rootTree(tree: NodeWithIds, treeRoot: TreeRoot) {
697
+ const plain = new Map<NodeWithIds, TreeNode>()
698
+ for (const node of preorder(tree).reverse()) {
699
+ plain.set(node, {
700
+ name: node.name === node.id ? undefined : node.name,
701
+ length: node.length,
702
+ children: node.children.map(c => plain.get(c)!),
703
+ })
704
+ }
705
+ const input = plain.get(tree)!
706
+ if (treeRoot === 'midpoint') {
707
+ return generateNodeIds(midpointRoot(input))
708
+ }
709
+ const target = outgroupNode(
710
+ hierarchy(tree, d => d.children),
711
+ treeRoot.outgroup,
712
+ )
713
+ return target
714
+ ? generateNodeIds(rerootTree(input, plain.get(target.data)!))
715
+ : tree
716
+ }
717
+
589
718
  // the inclusive tip indices a node covers, from the row-space extent
590
719
  // clusterLayout writes to xMin/xMax as tip centers
591
720
  function tipRange(node: HierarchyNode): [number, number] {
@@ -796,6 +925,22 @@ function stateModelFactory() {
796
925
  */
797
926
  collapsed: stripDefault(types.array(types.string), []),
798
927
 
928
+ /**
929
+ * #property
930
+ * tree nodes whose children draw in reverse order, which is ggtree's
931
+ * `rotate`: the same tree, with the clades on either side of the node
932
+ * swapped
933
+ */
934
+ rotated: stripDefault(types.array(types.string), []),
935
+
936
+ /**
937
+ * #property
938
+ * where the tree is rooted: `midpoint` roots it halfway along the
939
+ * longest path between two tips, `{outgroup}` on the branch above
940
+ * the tips named, and undefined keeps the file's root
941
+ */
942
+ treeRoot: types.frozen<TreeRoot | undefined>(),
943
+
799
944
  /**
800
945
  * #property
801
946
  * focus on particular subtree
@@ -877,12 +1022,21 @@ function stateModelFactory() {
877
1022
  * Persists in the snapshot and the URL.
878
1023
  */
879
1024
  highlights: stripDefault(types.array(types.frozen<Highlight>()), []),
1025
+ /**
1026
+ * #property
1027
+ * where the view opens, in `highlights` coordinates: `{row, start,
1028
+ * end}` zooms onto residues of that row, `{start, end}` onto alignment
1029
+ * columns. Applied once the alignment and any tree file have loaded,
1030
+ * then cleared, so a reloaded session keeps the reader's own scroll.
1031
+ */
1032
+ region: types.frozen<Region | undefined>(),
880
1033
  /**
881
1034
  * #property
882
1035
  * clades of the tree with a mark drawn over them. `mrca` names tips
883
1036
  * whose common ancestor is the clade, or `range` its first and last
884
1037
  * tip in display order, and `tips` is the leaf count the producer
885
- * measured. `mark` is `highlight`, `bracket`, `collapse` or `focus`.
1038
+ * measured. `mark` is `highlight`, `bracket`, `collapse`, `focus` or
1039
+ * `rotate`.
886
1040
  * See docs/layers.md
887
1041
  */
888
1042
  clades: stripDefault(types.array(types.frozen<Clade>()), []),
@@ -1015,9 +1169,16 @@ function stateModelFactory() {
1015
1169
  * #volatile
1016
1170
  * non-fatal load problems: an optional layer that failed to load, an
1017
1171
  * overlay that failed to parse. `error` replaces the view and is for the
1018
- * alignment itself
1172
+ * alignment itself. `warnings` adds the ones derived from the data
1173
+ */
1174
+ loadWarnings: [] as string[],
1175
+
1176
+ /**
1177
+ * #volatile
1178
+ * derived warnings the user dismissed, which stay hidden while the data
1179
+ * behind them holds
1019
1180
  */
1020
- warnings: [] as string[],
1181
+ dismissedWarnings: [] as string[],
1021
1182
 
1022
1183
  /**
1023
1184
  * #volatile
@@ -1111,18 +1272,11 @@ function stateModelFactory() {
1111
1272
  * that failed to parse. A message already on the list is not added again
1112
1273
  */
1113
1274
  addWarning(warning: string) {
1114
- if (!self.warnings.includes(warning)) {
1115
- self.warnings = [...self.warnings, warning]
1275
+ if (!self.loadWarnings.includes(warning)) {
1276
+ self.loadWarnings = [...self.loadWarnings, warning]
1116
1277
  }
1117
1278
  },
1118
1279
 
1119
- /**
1120
- * #action
1121
- */
1122
- clearWarnings() {
1123
- self.warnings = []
1124
- },
1125
-
1126
1280
  /**
1127
1281
  * #action
1128
1282
  * declare that this host restores the loaded documents itself, which
@@ -1156,6 +1310,12 @@ function stateModelFactory() {
1156
1310
  setHighlightedColumns(columns?: number[]) {
1157
1311
  self.highlightedColumns = columns
1158
1312
  },
1313
+ /**
1314
+ * #action
1315
+ */
1316
+ setRegion(region?: Region) {
1317
+ self.region = region
1318
+ },
1159
1319
  /**
1160
1320
  * #action
1161
1321
  */
@@ -1239,8 +1399,9 @@ function stateModelFactory() {
1239
1399
  /**
1240
1400
  * #action
1241
1401
  * switch to another alignment of a multi-alignment file (Stockholm).
1242
- * Clears the collapsed node ids, the subtree in focus, the reference row
1243
- * and the scroll position, which all refer to the previous alignment
1402
+ * Clears the collapsed and rotated node ids, the subtree in focus, the
1403
+ * reference row and the scroll position, which all refer to the
1404
+ * previous alignment
1244
1405
  */
1245
1406
  setCurrentAlignment(n: number) {
1246
1407
  if (n === self.currentAlignment) {
@@ -1249,6 +1410,7 @@ function stateModelFactory() {
1249
1410
  transaction(() => {
1250
1411
  self.currentAlignment = n
1251
1412
  self.collapsed.clear()
1413
+ self.rotated.clear()
1252
1414
  this.setShowOnly(undefined)
1253
1415
  this.drawRelativeTo(undefined)
1254
1416
  self.scrollX = 0
@@ -1268,6 +1430,42 @@ function stateModelFactory() {
1268
1430
  }
1269
1431
  },
1270
1432
 
1433
+ /**
1434
+ * #action
1435
+ * reverse the order of the given node's children, or restore it
1436
+ */
1437
+ toggleRotated(node: string) {
1438
+ if (self.rotated.includes(node)) {
1439
+ self.rotated.remove(node)
1440
+ } else {
1441
+ self.rotated.push(node)
1442
+ }
1443
+ },
1444
+
1445
+ /**
1446
+ * #action
1447
+ */
1448
+ clearRotated() {
1449
+ self.rotated.clear()
1450
+ },
1451
+
1452
+ /**
1453
+ * #action
1454
+ * reroot the tree. Clears `collapsed`, `rotated` and `showOnly`, whose
1455
+ * path-derived node ids name other nodes in the rerooted tree
1456
+ */
1457
+ setTreeRoot(treeRoot?: TreeRoot) {
1458
+ if (compareStructural(self.treeRoot, treeRoot)) {
1459
+ return
1460
+ }
1461
+ transaction(() => {
1462
+ self.treeRoot = treeRoot
1463
+ self.collapsed.clear()
1464
+ self.rotated.clear()
1465
+ this.setShowOnly(undefined)
1466
+ })
1467
+ },
1468
+
1271
1469
  /**
1272
1470
  * #action
1273
1471
  * show only the subtree rooted at the given node id (pass undefined to
@@ -1444,12 +1642,13 @@ function stateModelFactory() {
1444
1642
  * from disk or pasted in becomes inline text, and DataModel drops an
1445
1643
  * inline document past `maxInlineSnapshotBytes`.
1446
1644
  *
1447
- * The header lists these, and the standalone app stops rewriting the
1448
- * address bar while the list is non-empty, so a copied link does not
1449
- * open an empty viewer unannounced. A document fetched from a URL never
1450
- * appears here, since the snapshot keeps its filehandle.
1645
+ * The header lists these, and the standalone app drops `?data=` from
1646
+ * the address bar while the list is non-empty or the encoded link would
1647
+ * pass 8,000 characters, so a copied link never opens an empty viewer
1648
+ * unannounced. A document fetched from a URL never appears here, since
1649
+ * the snapshot keeps its filehandle.
1451
1650
  *
1452
- * Empty when `hostRestoresData` is true.
1651
+ * Empty when `hostCarriesData` is true.
1453
1652
  */
1454
1653
  get unshareableData(): UnshareableData[] {
1455
1654
  if (self.hostRestoresData) {
@@ -1546,8 +1745,9 @@ function stateModelFactory() {
1546
1745
 
1547
1746
  /**
1548
1747
  * #getter
1748
+ * the tree the file or the alignment gives, before `treeRoot`
1549
1749
  */
1550
- get tree(): NodeWithIds {
1750
+ get inputTree(): NodeWithIds {
1551
1751
  const text = self.data.tree
1552
1752
  return text
1553
1753
  ? generateNodeIds(parseTreeText(text))
@@ -1559,6 +1759,19 @@ function stateModelFactory() {
1559
1759
  })
1560
1760
  },
1561
1761
 
1762
+ /**
1763
+ * #getter
1764
+ * the tree as the file or the alignment gives it, rerooted where
1765
+ * `treeRoot` asks. Every node id, and so `collapsed`, `rotated` and
1766
+ * `showOnly`, refers to this tree.
1767
+ */
1768
+ get tree(): NodeWithIds {
1769
+ const tree = this.inputTree
1770
+ return self.treeRoot && !tree.noTree
1771
+ ? rootTree(tree, self.treeRoot)
1772
+ : tree
1773
+ },
1774
+
1562
1775
  /**
1563
1776
  * #getter
1564
1777
  * Returns the list of row (sequence) names in display order.
@@ -1580,8 +1793,8 @@ function stateModelFactory() {
1580
1793
  * `clades` resolved to the rows each one covers. The tip names resolve
1581
1794
  * against `tree` rather than `root`, so a clade whose ancestor the user
1582
1795
  * collapsed keeps its rows. One leaf pass over the tree serves every
1583
- * clade. A `range` record names no node, so `collapse` and `focus`, which
1584
- * need one, drop it.
1796
+ * clade. A `range` record names no node, so `collapse`, `focus` and
1797
+ * `rotate`, which need one, drop it.
1585
1798
  */
1586
1799
  get resolvedClades(): ResolvedClade[] {
1587
1800
  if (self.clades.length === 0) {
@@ -1639,7 +1852,27 @@ function stateModelFactory() {
1639
1852
  * #getter
1640
1853
  */
1641
1854
  get root() {
1642
- return buildTreeRoot(this.tree, self.collapsed, self.showOnly)
1855
+ return buildTreeRoot(this.tree, {
1856
+ collapsed: self.collapsed,
1857
+ rotated: self.rotated,
1858
+ order: self.treeOrder,
1859
+ showOnly: self.showOnly,
1860
+ })
1861
+ },
1862
+
1863
+ /**
1864
+ * #getter
1865
+ * the tree as Newick in the order and with the root it is drawn, with
1866
+ * every clade, collapsed or out of focus
1867
+ */
1868
+ get treeNewick() {
1869
+ return writeNewick(
1870
+ buildTreeRoot(this.tree, {
1871
+ collapsed: [],
1872
+ rotated: self.rotated,
1873
+ order: self.treeOrder,
1874
+ }),
1875
+ )
1643
1876
  },
1644
1877
 
1645
1878
  /**
@@ -1663,7 +1896,11 @@ function stateModelFactory() {
1663
1896
  if (!self.showTreeOverview) {
1664
1897
  return undefined
1665
1898
  }
1666
- const root = buildTreeRoot(this.tree, self.collapsed)
1899
+ const root = buildTreeRoot(this.tree, {
1900
+ collapsed: self.collapsed,
1901
+ rotated: self.rotated,
1902
+ order: self.treeOrder,
1903
+ })
1667
1904
  const numTips = leaves(root).length
1668
1905
  clusterLayout(root, numTips, 1)
1669
1906
  const rootLen = Math.max(root.data.length || 0, 0)
@@ -1745,7 +1982,7 @@ function stateModelFactory() {
1745
1982
  * alignment, the sum of each record's width
1746
1983
  */
1747
1984
  get rowPanelsWidth() {
1748
- return sum(self.rowPanels.map(p => rowPanelWidth(p, self.rowHeight)))
1985
+ return sum(self.rowPanels.map(p => rowPanelWidth(p, self)))
1749
1986
  },
1750
1987
  /**
1751
1988
  * #getter
@@ -1792,9 +2029,10 @@ function stateModelFactory() {
1792
2029
  return []
1793
2030
  }
1794
2031
  // ragged input (a3m, hand-edited fasta) can have rows shorter than the
1795
- // alignment; the widest row defines the column count and a row that
1796
- // stops early counts as gapped for the remainder
1797
- const numCols = strs.reduce((max, str) => Math.max(max, str.length), 0)
2032
+ // alignment; the widest row of the whole alignment, collapsed rows
2033
+ // included, defines the column count as it does for numColumns, and a
2034
+ // row that stops early counts as gapped for the remainder
2035
+ const numCols = this.MSA?.getWidth() ?? 0
1798
2036
  const numRows = strs.length
1799
2037
  const threshold = Math.ceil((realAllowedGappyness / 100) * numRows)
1800
2038
  const blankCounts = new Uint32Array(numCols)
@@ -1925,7 +2163,7 @@ function stateModelFactory() {
1925
2163
  * #getter
1926
2164
  */
1927
2165
  get colStats() {
1928
- return columnCountsFromRows(this.columns2d)
2166
+ return columnCountsFromRows(this.columns2d, this.numColumns)
1929
2167
  },
1930
2168
 
1931
2169
  /**
@@ -2024,40 +2262,42 @@ function stateModelFactory() {
2024
2262
  },
2025
2263
  /**
2026
2264
  * #getter
2027
- * generates a new tree that is clustered with x,y positions
2028
- */
2029
- get hierarchy(): HierarchyNode<NodeWithIdsAndLength> {
2030
- const r = this.root
2031
- clusterLayout(r, this.totalHeight, self.treeWidth)
2032
- const max = this.rootToTipLength
2033
- const k = max ? self.treeWidth / max : 0
2034
- // the displayed root starts at x=0, so subtract its length here; `root`
2035
- // returns the cached parse, which must not be mutated
2036
- setBrLength(r, -Math.max(r.data.length || 0, 0), k)
2037
- // for each collapsed clade, record the pixel x-position of its farthest
2038
- // tip so the renderer can draw a triangle spanning the branch-length
2039
- // extent of the hidden subtree
2040
- forEachDescendant(r, node => {
2041
- if (node._children) {
2042
- node.collapsedTipXFar =
2043
- (node.len ?? 0) + collapsedSubtreeMaxLength(node) * k
2044
- }
2265
+ * `root` laid out on new nodes in row and branch-length units, read in
2266
+ * pixels through `rowHeight` and `treeWidth`. A zoom leaves it and every
2267
+ * node in it as they are, and an observer reading a node's `x` or `len`
2268
+ * observes the size it scales by.
2269
+ */
2270
+ get treeLayout() {
2271
+ return layoutTree(this.root, {
2272
+ get rowHeight() {
2273
+ return self.rowHeight
2274
+ },
2275
+ get treeWidth() {
2276
+ return self.treeWidth
2277
+ },
2045
2278
  })
2046
- return r as HierarchyNode<NodeWithIdsAndLength>
2279
+ },
2280
+
2281
+ /**
2282
+ * #getter
2283
+ * the laid-out tree, with pixel positions on every node
2284
+ */
2285
+ get hierarchy(): HierarchyNode<NodeWithIds> {
2286
+ return this.treeLayout.root
2047
2287
  },
2048
2288
 
2049
2289
  /**
2050
2290
  * #getter
2051
2291
  */
2052
2292
  get totalHeight() {
2053
- return leaves(this.root).length * self.rowHeight
2293
+ return this.leaves.length * self.rowHeight
2054
2294
  },
2055
2295
 
2056
2296
  /**
2057
2297
  * #getter
2058
2298
  */
2059
- get leaves() {
2060
- return leaves(this.hierarchy)
2299
+ get leaves(): HierarchyNode<NodeWithIds>[] {
2300
+ return this.treeLayout.leaves
2061
2301
  },
2062
2302
 
2063
2303
  /**
@@ -2066,8 +2306,7 @@ function stateModelFactory() {
2066
2306
  * the tree's own units
2067
2307
  */
2068
2308
  get rootToTipLength() {
2069
- const r = this.root
2070
- return maxLength(r) - Math.max(r.data.length || 0, 0)
2309
+ return this.treeLayout.rootToTipLength
2071
2310
  },
2072
2311
 
2073
2312
  /**
@@ -2094,7 +2333,7 @@ function stateModelFactory() {
2094
2333
  * max topological depth to a tip, used to scale cladogram x-positions
2095
2334
  */
2096
2335
  get maxDepthToLeaf() {
2097
- return calcDepthToLeaf(this.hierarchy)
2336
+ return this.treeLayout.root.depthToLeaf
2098
2337
  },
2099
2338
 
2100
2339
  /**
@@ -2329,13 +2568,6 @@ function stateModelFactory() {
2329
2568
  return self.MSA?.secondaryStructureConsensus
2330
2569
  },
2331
2570
 
2332
- /**
2333
- * #getter
2334
- */
2335
- get seqConsensus() {
2336
- return self.MSA?.seqConsensus
2337
- },
2338
-
2339
2571
  /**
2340
2572
  * #getter
2341
2573
  * the base pairs of the consensus secondary structure, as arcs, in
@@ -2480,11 +2712,13 @@ function stateModelFactory() {
2480
2712
 
2481
2713
  /**
2482
2714
  * #method
2483
- * Return a row-specific letter at a visible column, or undefined if gap.
2715
+ * Return the character a row holds at a visible column: a residue, or
2716
+ * the gap character (`-` or `.`) on a gap. Undefined for a row name the
2717
+ * alignment does not have or a column past the row's end.
2484
2718
  *
2485
2719
  * @param rowName - The name of the row
2486
2720
  * @param visibleCol - The visible column index (what the user sees on screen)
2487
- * @returns The letter at that position, or undefined if it's a gap
2721
+ * @returns The character at that position
2488
2722
  */
2489
2723
  visibleColToRowLetter(rowName: string, visibleCol: number) {
2490
2724
  return self.rowMap.get(rowName)?.[
@@ -2582,6 +2816,22 @@ function stateModelFactory() {
2582
2816
  return col ?? (seqPos === 0 ? 0 : seq.length)
2583
2817
  },
2584
2818
 
2819
+ /**
2820
+ * #method
2821
+ * the global column of a span's last residue, clamping a position past
2822
+ * the end of the row to its last residue. Undefined for a row name the
2823
+ * alignment does not have, or a row with no residues.
2824
+ *
2825
+ * @param rowName - The name of the row
2826
+ * @param seqPos - The sequence position (0-based, ungapped)
2827
+ */
2828
+ seqEndToGlobalCol(rowName: string, seqPos: number) {
2829
+ const index = self.seqPosIndex(rowName)
2830
+ return index && index.length > 0
2831
+ ? index[Math.min(seqPos, index.length - 1)]
2832
+ : undefined
2833
+ },
2834
+
2585
2835
  /**
2586
2836
  * #method
2587
2837
  * Convert a sequence position (ungapped) directly to a visible column index.
@@ -2611,10 +2861,10 @@ function stateModelFactory() {
2611
2861
  const start = Math.max(1, Math.floor(rawStart))
2612
2862
  const end = Math.ceil(rawEnd)
2613
2863
  let startGlobal = start - 1
2614
- let endGlobal = end - 1
2864
+ let endGlobal = Math.min(end, self.MSA?.getWidth() ?? end) - 1
2615
2865
  if (row !== undefined) {
2616
2866
  const rowStart = this.seqPosToGlobalCol(row, start - 1)
2617
- const rowEnd = this.seqPosToGlobalCol(row, end - 1)
2867
+ const rowEnd = this.seqEndToGlobalCol(row, end - 1)
2618
2868
  if (rowStart === undefined || rowEnd === undefined) {
2619
2869
  return undefined
2620
2870
  }
@@ -2908,6 +3158,28 @@ function stateModelFactory() {
2908
3158
  },
2909
3159
  }))
2910
3160
  .views(self => ({
3161
+ /**
3162
+ * #getter
3163
+ */
3164
+ get verticalScrollbarWidth() {
3165
+ return self.showVerticalScrollbar ? 20 : 0
3166
+ },
3167
+ /**
3168
+ * #getter
3169
+ * width of the alignment canvas: the msa area less the vertical
3170
+ * scrollbar. showHorizontalScrollbar must not read it, since that feeds
3171
+ * msaAreaHeight -> showVerticalScrollbar and would form a cycle
3172
+ */
3173
+ get msaCanvasWidth() {
3174
+ return self.msaAreaWidth - this.verticalScrollbarWidth
3175
+ },
3176
+ /**
3177
+ * #getter
3178
+ * most-negative allowed scrollX, which keeps the last column in view
3179
+ */
3180
+ get maxScrollX() {
3181
+ return Math.min(-self.totalWidth + this.msaCanvasWidth, 0)
3182
+ },
2911
3183
  /**
2912
3184
  * #getter
2913
3185
  */
@@ -2925,9 +3197,29 @@ function stateModelFactory() {
2925
3197
  },
2926
3198
  /**
2927
3199
  * #getter
3200
+ * warnings that follow from the loaded data and clear when it changes. A
3201
+ * GFF whose first column names rows of some other alignment parses
3202
+ * without error and draws nothing
3203
+ */
3204
+ get dataWarnings(): string[] {
3205
+ const { annotations, rowNamesSet } = self
3206
+ return self.dataInitialized &&
3207
+ annotations.length > 0 &&
3208
+ !annotations.some(a => rowNamesSet.has(a.id))
3209
+ ? [
3210
+ `0 of ${annotations.length} annotations name a row in this alignment`,
3211
+ ]
3212
+ : []
3213
+ },
3214
+ /**
3215
+ * #getter
3216
+ * the load warnings and the undismissed data warnings, for the header
2928
3217
  */
2929
- get maxScrollX() {
2930
- return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
3218
+ get warnings(): string[] {
3219
+ return [
3220
+ ...self.loadWarnings,
3221
+ ...this.dataWarnings.filter(w => !self.dismissedWarnings.includes(w)),
3222
+ ]
2931
3223
  },
2932
3224
  /**
2933
3225
  * #getter
@@ -2954,6 +3246,14 @@ function stateModelFactory() {
2954
3246
  self.drawMsaLetters = arg
2955
3247
  },
2956
3248
 
3249
+ /**
3250
+ * #action
3251
+ */
3252
+ clearWarnings() {
3253
+ self.loadWarnings = []
3254
+ self.dismissedWarnings = self.dataWarnings
3255
+ },
3256
+
2957
3257
  /**
2958
3258
  * #action
2959
3259
  */
@@ -3014,13 +3314,43 @@ function stateModelFactory() {
3014
3314
 
3015
3315
  /**
3016
3316
  * #action
3017
- * swap in a different tree over the same alignment. Clears `collapsed`
3018
- * and `showOnly`, since path-derived node ids (node-0-0-1) from the old
3317
+ * root the tree on the branch above the given node. The node's tips are
3318
+ * a split of the unrooted tree, and the side of it that is a clade in
3319
+ * the tree as written stands for it as an outgroup, by its first and
3320
+ * last tip, so the root survives the path ids changing under it
3321
+ */
3322
+ rerootAt(nodeId: string) {
3323
+ const shown = find(
3324
+ hierarchy(self.tree, d => d.children),
3325
+ n => n.data.id === nodeId,
3326
+ )
3327
+ if (!shown?.parent) {
3328
+ return
3329
+ }
3330
+ const node = outgroupNode(
3331
+ hierarchy(self.inputTree, d => d.children),
3332
+ leaves(shown).map(n => n.data.name),
3333
+ )
3334
+ if (node) {
3335
+ const tips = leaves(node)
3336
+ self.setTreeRoot({
3337
+ outgroup: [
3338
+ ...new Set([tips[0]!.data.name, tips.at(-1)!.data.name]),
3339
+ ],
3340
+ })
3341
+ }
3342
+ },
3343
+
3344
+ /**
3345
+ * #action
3346
+ * swap in a different tree over the same alignment. Clears `collapsed`,
3347
+ * `rotated` and `showOnly`, since path-derived node ids (node-0-0-1) from the old
3019
3348
  * tree would match unrelated nodes in the new one.
3020
3349
  */
3021
3350
  replaceTree(newick: string) {
3022
3351
  transaction(() => {
3023
3352
  self.collapsed.clear()
3353
+ self.rotated.clear()
3024
3354
  self.setShowOnly(undefined)
3025
3355
  self.setTree(newick)
3026
3356
  })
@@ -3034,52 +3364,53 @@ function stateModelFactory() {
3034
3364
  self.setColWidth(defaultColWidth)
3035
3365
  self.setRowHeight(defaultRowHeight)
3036
3366
  },
3367
+ /**
3368
+ * #action
3369
+ * zoom by `scaleFactor` about the center of the alignment area
3370
+ */
3371
+ zoomAtCenter(scaleFactor: number, axis: ScrollZoomAxis = 'both') {
3372
+ this.zoomToPos(
3373
+ scaleFactor,
3374
+ self.msaCanvasWidth / 2,
3375
+ self.msaAreaHeight / 2,
3376
+ axis,
3377
+ )
3378
+ },
3037
3379
  /**
3038
3380
  * #action
3039
3381
  */
3040
3382
  zoomOutHorizontal() {
3041
- self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
3042
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
3383
+ this.zoomAtCenter(0.75, 'horizontal')
3043
3384
  },
3044
3385
  /**
3045
3386
  * #action
3046
3387
  */
3047
3388
  zoomInHorizontal() {
3048
- self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
3049
- self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
3389
+ this.zoomAtCenter(1.5, 'horizontal')
3050
3390
  },
3051
3391
  /**
3052
3392
  * #action
3053
3393
  */
3054
3394
  zoomInVertical() {
3055
- self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5))
3395
+ this.zoomAtCenter(1.5, 'vertical')
3056
3396
  },
3057
3397
  /**
3058
3398
  * #action
3059
3399
  */
3060
3400
  zoomOutVertical() {
3061
- self.rowHeight = Math.max(
3062
- minRowHeight,
3063
- Math.floor(self.rowHeight * 0.75),
3064
- )
3401
+ this.zoomAtCenter(0.75, 'vertical')
3065
3402
  },
3066
3403
  /**
3067
3404
  * #action
3068
3405
  */
3069
3406
  zoomIn() {
3070
- transaction(() => {
3071
- this.zoomInHorizontal()
3072
- this.zoomInVertical()
3073
- })
3407
+ this.zoomAtCenter(1.5)
3074
3408
  },
3075
3409
  /**
3076
3410
  * #action
3077
3411
  */
3078
3412
  zoomOut() {
3079
- transaction(() => {
3080
- this.zoomOutHorizontal()
3081
- this.zoomOutVertical()
3082
- })
3413
+ this.zoomAtCenter(0.75)
3083
3414
  },
3084
3415
  /**
3085
3416
  * #action
@@ -3242,25 +3573,11 @@ function stateModelFactory() {
3242
3573
  },
3243
3574
  }))
3244
3575
  .views(self => ({
3245
- /**
3246
- * #getter
3247
- */
3248
- get verticalScrollbarWidth() {
3249
- return self.showVerticalScrollbar ? 20 : 0
3250
- },
3251
- /**
3252
- * #getter
3253
- * width of the alignment canvas: the msa area less the vertical
3254
- * scrollbar. showHorizontalScrollbar must not read it, since that feeds
3255
- * msaAreaHeight -> showVerticalScrollbar and would form a cycle
3256
- */
3257
- get msaCanvasWidth() {
3258
- return self.msaAreaWidth - this.verticalScrollbarWidth
3259
- },
3260
3576
  /**
3261
3577
  * #method
3262
3578
  * the cell at a visible column and row index, in the coordinates a host
3263
- * writes highlights in
3579
+ * writes highlights in. On a gap `residue` is undefined and `letter` is
3580
+ * the gap character
3264
3581
  */
3265
3582
  cellAt(visibleCol: number, rowIndex?: number): Cell {
3266
3583
  const column = self.visibleColToGlobalCol(visibleCol)
@@ -3308,7 +3625,7 @@ function stateModelFactory() {
3308
3625
  }
3309
3626
  const { xStart, xEnd } = visibleColRange({
3310
3627
  offsetX: -scrollX,
3311
- blockWidth: this.msaCanvasWidth,
3628
+ blockWidth: self.msaCanvasWidth,
3312
3629
  colWidth,
3313
3630
  })
3314
3631
  const last = Math.max(0, Math.min(xEnd, numColumns) - 1)
@@ -3471,7 +3788,7 @@ function stateModelFactory() {
3471
3788
  add(field, legend)
3472
3789
  }
3473
3790
  }
3474
- for (const { legendTitle, legend } of this.resolvedRowPanels) {
3791
+ for (const { legendTitle, legend } of this.rowPanelScales) {
3475
3792
  add(legendTitle, legend)
3476
3793
  }
3477
3794
  return [...byKey.values()]
@@ -3519,7 +3836,7 @@ function stateModelFactory() {
3519
3836
  // column moves to the neighboring boundary. Bands with no visible
3520
3837
  // columns, or naming a missing row, are dropped.
3521
3838
  const start = self.seqPosToGlobalCol(name, annotation.start - 1)
3522
- const end = self.seqPosToGlobalCol(name, annotation.end - 1)
3839
+ const end = self.seqEndToGlobalCol(name, annotation.end - 1)
3523
3840
  if (start === undefined || end === undefined) {
3524
3841
  return undefined
3525
3842
  }
@@ -3621,6 +3938,26 @@ function stateModelFactory() {
3621
3938
  return runs
3622
3939
  },
3623
3940
 
3941
+ /**
3942
+ * #method
3943
+ * a highlight label with `{residue}` and `{position}` filled in from the
3944
+ * row's letter at `start`, which is how the `175` shorthand draws "R175"
3945
+ */
3946
+ fillHighlightLabel(label: string, row?: string, start?: number) {
3947
+ if (!label.includes('{')) {
3948
+ return label
3949
+ }
3950
+ const col =
3951
+ row && start !== undefined
3952
+ ? self.seqPosIndex(row)?.[start - 1]
3953
+ : undefined
3954
+ const residue =
3955
+ row && col !== undefined ? (self.MSA?.getRow(row)[col] ?? '') : ''
3956
+ return label
3957
+ .replaceAll('{residue}', residue)
3958
+ .replaceAll('{position}', start === undefined ? '' : String(start))
3959
+ },
3960
+
3624
3961
  /**
3625
3962
  * #getter
3626
3963
  * `highlights` projected onto what is on screen: residue spans go
@@ -3636,7 +3973,10 @@ function stateModelFactory() {
3636
3973
  ...Object.values(transientHighlights).flat(),
3637
3974
  ]
3638
3975
  return all.flatMap(({ row, rows, start, end, label, color }) => {
3639
- const base = { label, color }
3976
+ const base = {
3977
+ label: label && this.fillHighlightLabel(label, row, start),
3978
+ color,
3979
+ }
3640
3980
  if (rows) {
3641
3981
  const rowIndices = rows
3642
3982
  .map(name => rowNamesSet.get(name))
@@ -3703,45 +4043,30 @@ function stateModelFactory() {
3703
4043
 
3704
4044
  /**
3705
4045
  * #getter
3706
- * each row panel with its scale resolved against the values its field
3707
- * takes across the row table, giving the color per row name, the pixel
3708
- * column it draws in, and the entries its legend lists. Resolved once
3709
- * per change of that table or the panels, never per block per frame.
4046
+ * each row panel's scale resolved against the values its field takes,
4047
+ * giving the colors, labels and legend entries. Reads no cell size, so
4048
+ * a zoom reuses it and the legends built from it.
3710
4049
  */
3711
- get resolvedRowPanels(): ResolvedRowPanel[] {
3712
- const rows = Object.entries(self.rowData)
4050
+ get rowPanelScales(): (StripPanelScale | FeaturePanelScale)[] {
4051
+ const { rowData } = self
3713
4052
  const { fillPalette, featureFillEncoding, featureLabels } = this
3714
4053
  const annotations = self.filteredAnnotations
3715
- let offsetX = 0
3716
- return self.rowPanels.map((panel, index) => {
3717
- const width = rowPanelWidth(panel, self.rowHeight)
3718
- const base = { id: `rowpanel-${index}`, width, offsetX }
3719
- offsetX += width
4054
+ return self.rowPanels.map(panel => {
3720
4055
  if (panel.kind === 'features') {
3721
4056
  const color = panel.encoding?.color
3722
4057
  const encoding = color
3723
4058
  ? resolveFeatureScale(color.field, color.scale, annotations)
3724
4059
  : featureFillEncoding
3725
4060
  const label = panel.encoding?.label
3726
- const align = panel.transform?.find(t => t.type === 'align')
3727
4061
  return {
3728
- ...base,
3729
4062
  kind: panel.kind,
3730
4063
  x: panel.x,
3731
4064
  header: panel.header ?? '',
3732
4065
  field: encoding?.field,
3733
4066
  legendTitle: encoding?.field,
3734
- spans: featurePanelSpans({
3735
- panel,
3736
- width,
3737
- colWidth: self.colWidth,
3738
- domainBands: this.domainBands,
3739
- annotationsByRow: self.annotationsByRow,
3740
- shifts: align
3741
- ? this.featureAlignShifts.get(align.on)
3742
- : undefined,
3743
- }),
3744
- colors: featureColorMap(annotations, encoding, fillPalette),
4067
+ colors: color
4068
+ ? featureColorMap(annotations, encoding, fillPalette)
4069
+ : this.featureColors,
3745
4070
  labels: label
3746
4071
  ? featureLabelMap(annotations, label)
3747
4072
  : featureLabels,
@@ -3756,28 +4081,56 @@ function stateModelFactory() {
3756
4081
  }
3757
4082
  const { colorOf, legend } = resolveScale(
3758
4083
  panel.scale,
3759
- rows.map(([, row]) => row?.[panel.field]).filter(notEmpty),
4084
+ Object.values(rowData)
4085
+ .map(row => row?.[panel.field])
4086
+ .filter(notEmpty),
3760
4087
  )
3761
- const colors = new Map<string, string>()
3762
- for (const [name, row] of rows) {
3763
- const value = row?.[panel.field]
3764
- const color = value === undefined ? undefined : colorOf(value)
3765
- if (color) {
3766
- colors.set(name, color)
3767
- }
3768
- }
3769
4088
  return {
3770
- ...base,
3771
4089
  kind: panel.kind,
3772
4090
  field: panel.field,
3773
4091
  header: panel.header ?? panel.field,
3774
4092
  legendTitle: panel.legend ?? panel.field,
3775
- colors,
4093
+ colors: colorByRow(rowData, panel.field, colorOf),
3776
4094
  legend,
3777
4095
  }
3778
4096
  })
3779
4097
  },
3780
4098
 
4099
+ /**
4100
+ * #getter
4101
+ * each row panel with its scale from `rowPanelScales` and its geometry:
4102
+ * the pixel column it draws in and, for a features panel, the spans per
4103
+ * row. Resolved once per change of those inputs, never per block per
4104
+ * frame.
4105
+ */
4106
+ get resolvedRowPanels(): ResolvedRowPanel[] {
4107
+ const scales = this.rowPanelScales
4108
+ let offsetX = 0
4109
+ return self.rowPanels.map((panel, index) => {
4110
+ const width = rowPanelWidth(panel, self)
4111
+ const base = { id: `rowpanel-${index}`, width, offsetX }
4112
+ offsetX += width
4113
+ if (panel.kind === 'features') {
4114
+ const align = panel.transform?.find(t => t.type === 'align')
4115
+ return {
4116
+ ...base,
4117
+ ...(scales[index] as FeaturePanelScale),
4118
+ spans: featurePanelSpans({
4119
+ panel,
4120
+ width,
4121
+ colWidth: self.colWidth,
4122
+ domainBands: this.domainBands,
4123
+ annotationsByRow: self.annotationsByRow,
4124
+ shifts: align
4125
+ ? this.featureAlignShifts.get(align.on)
4126
+ : undefined,
4127
+ }),
4128
+ }
4129
+ }
4130
+ return { ...base, ...(scales[index] as StripPanelScale) }
4131
+ })
4132
+ },
4133
+
3781
4134
  /**
3782
4135
  * #getter
3783
4136
  * the shift an `align` transform gives each row, keyed by the feature
@@ -3820,19 +4173,9 @@ function stateModelFactory() {
3820
4173
  const encoding = this.resolvedEncodings.find(
3821
4174
  e => e.channel === 'tipLabel',
3822
4175
  )
3823
- if (!encoding) {
3824
- return undefined
3825
- }
3826
- const colors = new Map<string, string>()
3827
- for (const [name, row] of Object.entries(self.rowData)) {
3828
- const value = row?.[encoding.field]
3829
- const color =
3830
- value === undefined ? undefined : encoding.colorOf(value)
3831
- if (color) {
3832
- colors.set(name, color)
3833
- }
3834
- }
3835
- return colors
4176
+ return encoding
4177
+ ? colorByRow(self.rowData, encoding.field, encoding.colorOf)
4178
+ : undefined
3836
4179
  },
3837
4180
 
3838
4181
  /**
@@ -3848,14 +4191,14 @@ function stateModelFactory() {
3848
4191
  if (!encoding) {
3849
4192
  return undefined
3850
4193
  }
4194
+ const colors = colorByRow(
4195
+ self.rowData,
4196
+ encoding.field,
4197
+ encoding.colorOf,
4198
+ )
3851
4199
  return self.rowNames.map(name => {
3852
- const value = self.rowDataOf(name)?.[encoding.field]
3853
- const color =
3854
- value === undefined ? undefined : encoding.colorOf(value)
3855
- if (!color) {
3856
- return undefined
3857
- }
3858
- return withAlpha(color, rowTintAlpha)
4200
+ const color = colors.get(name)
4201
+ return color && withAlpha(color, rowTintAlpha)
3859
4202
  })
3860
4203
  },
3861
4204
 
@@ -3937,7 +4280,8 @@ function stateModelFactory() {
3937
4280
  */
3938
4281
  reset() {
3939
4282
  self.resetCount++
3940
- self.clearWarnings()
4283
+ self.loadWarnings = []
4284
+ self.dismissedWarnings = []
3941
4285
  self.setStatus(undefined)
3942
4286
  applySnapshot(
3943
4287
  self,
@@ -3964,6 +4308,21 @@ function stateModelFactory() {
3964
4308
  const blob = new Blob([html], { type: 'image/svg+xml' })
3965
4309
  saveAs(blob, exportFileName(self.msaFilehandle, 'svg'))
3966
4310
  },
4311
+ /**
4312
+ * #action
4313
+ * save `treeNewick` as a file
4314
+ */
4315
+ exportNewick() {
4316
+ const blob = new Blob([self.treeNewick], { type: 'text/plain' })
4317
+ saveAs(
4318
+ blob,
4319
+ exportFileName(
4320
+ self.treeFilehandle ?? self.msaFilehandle,
4321
+ 'nwk',
4322
+ 'tree',
4323
+ ),
4324
+ )
4325
+ },
3967
4326
  /**
3968
4327
  * #action
3969
4328
  * show or hide an annotation type. Only hidden types are recorded; see
@@ -4047,36 +4406,62 @@ function stateModelFactory() {
4047
4406
  self.setHighlightedColumns(self.highlightColumns)
4048
4407
  }
4049
4408
 
4050
- // The `collapse` and `focus` clade marks seed the collapsed list and
4051
- // the subtree in focus, which the tree, `hideGapsEffective` and the
4052
- // alignment all read. The tree arrives with the model for inline data
4053
- // and later for a filehandle, so the seeding waits for it and then runs
4054
- // once: expanding a seeded clade sticks, and the record collapses it
4055
- // again only on reload. `dataInitialized` is true once the MSA alone
4056
- // has loaded, when the tree is still the flat stub, so a tree
4057
- // filehandle holds the seeding until its text lands.
4058
- let cladesSeeded = false
4409
+ // The `collapse`, `focus` and `rotate` clade marks seed the collapsed
4410
+ // list, the subtree in focus and the rotated nodes, which the tree,
4411
+ // `hideGapsEffective` and the alignment all read. The tree arrives with
4412
+ // the model for inline data and later for a filehandle, so the seeding
4413
+ // waits for it and then runs once per data load, per change of
4414
+ // `clades` and per reroot, which clears the node ids the seeding
4415
+ // wrote: expanding a seeded clade sticks until one of those changes.
4416
+ // `dataInitialized` is true once the MSA alone has loaded, when the
4417
+ // tree is still the flat stub, so a tree filehandle holds the seeding
4418
+ // until its text lands.
4419
+ addDisposer(
4420
+ self,
4421
+ reaction(
4422
+ () =>
4423
+ self.dataInitialized && !(self.treeFilehandle && !self.data.tree)
4424
+ ? { clades: getSnapshot(self.clades), treeRoot: self.treeRoot }
4425
+ : undefined,
4426
+ seed => {
4427
+ if (!seed?.clades.length) {
4428
+ return
4429
+ }
4430
+ for (const { mark, nodeId } of self.resolvedClades) {
4431
+ if (nodeId === undefined) {
4432
+ continue
4433
+ }
4434
+ if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
4435
+ self.toggleCollapsed(nodeId)
4436
+ } else if (mark === 'focus') {
4437
+ self.setShowOnly(nodeId)
4438
+ } else if (
4439
+ mark === 'rotate' &&
4440
+ !self.rotated.includes(nodeId)
4441
+ ) {
4442
+ self.toggleRotated(nodeId)
4443
+ }
4444
+ }
4445
+ },
4446
+ { fireImmediately: true, equals: compareStructural },
4447
+ ),
4448
+ )
4449
+
4450
+ // zoomToRegion needs a width and resolves residues through the visible
4451
+ // columns, so this waits for both, and for a tree file whose collapsed
4452
+ // clades would hide columns and move the target after the zoom
4059
4453
  addDisposer(
4060
4454
  self,
4061
4455
  autorun(() => {
4456
+ const { region } = self
4062
4457
  if (
4063
- cladesSeeded ||
4064
- !self.dataInitialized ||
4065
- (self.treeFilehandle && !self.data.tree) ||
4066
- self.clades.length === 0
4458
+ region &&
4459
+ self.viewInitialized &&
4460
+ self.numColumns > 0 &&
4461
+ !(self.treeFilehandle && !self.data.tree)
4067
4462
  ) {
4068
- return
4069
- }
4070
- cladesSeeded = true
4071
- for (const { mark, nodeId } of self.resolvedClades) {
4072
- if (nodeId === undefined) {
4073
- continue
4074
- }
4075
- if (mark === 'collapse' && !self.collapsed.includes(nodeId)) {
4076
- self.toggleCollapsed(nodeId)
4077
- } else if (mark === 'focus') {
4078
- self.setShowOnly(nodeId)
4079
- }
4463
+ this.zoomToRegion(region)
4464
+ self.setRegion(undefined)
4080
4465
  }
4081
4466
  }),
4082
4467
  )
@@ -4246,24 +4631,6 @@ function stateModelFactory() {
4246
4631
  }),
4247
4632
  )
4248
4633
 
4249
- // a GFF whose first column names rows of some other alignment parses
4250
- // without error and draws nothing
4251
- addDisposer(
4252
- self,
4253
- autorun(() => {
4254
- const { annotations, rowNamesSet } = self
4255
- if (
4256
- annotations.length > 0 &&
4257
- rowNamesSet.size > 0 &&
4258
- !annotations.some(a => rowNamesSet.has(a.id))
4259
- ) {
4260
- self.addWarning(
4261
- `0 of ${annotations.length} annotations name a row in this alignment`,
4262
- )
4263
- }
4264
- }),
4265
- )
4266
-
4267
4634
  // gffFilehandle loads into data.gff, which the autorun above parses
4268
4635
  loadOnFilehandleChange({
4269
4636
  what: 'annotations',