react-msaview 5.2.0 → 5.2.1

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 (141) hide show
  1. package/README.md +41 -8
  2. package/bundle/index.js +17 -17
  3. package/bundle/index.js.LICENSE.txt +1 -1
  4. package/bundle/index.js.map +1 -1
  5. package/dist/components/ConservationTrack.js +2 -13
  6. package/dist/components/ConservationTrack.js.map +1 -1
  7. package/dist/components/MSAView.js +8 -1
  8. package/dist/components/MSAView.js.map +1 -1
  9. package/dist/components/ResizeHandles.d.ts +3 -0
  10. package/dist/components/ResizeHandles.js +60 -105
  11. package/dist/components/ResizeHandles.js.map +1 -1
  12. package/dist/components/TextTrack.js +2 -1
  13. package/dist/components/TextTrack.js.map +1 -1
  14. package/dist/components/Track.js +1 -1
  15. package/dist/components/Track.js.map +1 -1
  16. package/dist/components/VerticalScrollbar.js +2 -2
  17. package/dist/components/VerticalScrollbar.js.map +1 -1
  18. package/dist/components/dialogs/UserProvidedDomainsDialog.js +1 -2
  19. package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +1 -1
  20. package/dist/components/header/ColorSchemeMenu.js +1 -1
  21. package/dist/components/header/ColorSchemeMenu.js.map +1 -1
  22. package/dist/components/header/FileMenu.js +1 -1
  23. package/dist/components/header/FileMenu.js.map +1 -1
  24. package/dist/components/header/HeaderInfoArea.js +28 -8
  25. package/dist/components/header/HeaderInfoArea.js.map +1 -1
  26. package/dist/components/header/MSASettingsMenu.js +1 -1
  27. package/dist/components/header/MSASettingsMenu.js.map +1 -1
  28. package/dist/components/header/TreeSettingsMenu.js +1 -1
  29. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  30. package/dist/components/header/ZoomControls.js +7 -2
  31. package/dist/components/header/ZoomControls.js.map +1 -1
  32. package/dist/components/header/ZoomMenu.js +9 -0
  33. package/dist/components/header/ZoomMenu.js.map +1 -1
  34. package/dist/components/minimap/Minimap.js +6 -12
  35. package/dist/components/minimap/Minimap.js.map +1 -1
  36. package/dist/components/minimap/MinimapSVG.js +7 -13
  37. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  38. package/dist/components/minimap/minimapLayout.d.ts +9 -0
  39. package/dist/components/minimap/minimapLayout.js +15 -0
  40. package/dist/components/minimap/minimapLayout.js.map +1 -0
  41. package/dist/components/msa/MSACanvas.js +6 -1
  42. package/dist/components/msa/MSACanvas.js.map +1 -1
  43. package/dist/components/msa/MSACanvasBlock.js +5 -1
  44. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  45. package/dist/components/msa/MSAMouseoverCanvas.js +2 -2
  46. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  47. package/dist/components/msa/renderMSABlock.js +5 -7
  48. package/dist/components/msa/renderMSABlock.js.map +1 -1
  49. package/dist/components/msa/renderMSAMouseover.js +3 -2
  50. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  51. package/dist/components/tracks/renderTracksSvg.js +2 -1
  52. package/dist/components/tracks/renderTracksSvg.js.map +1 -1
  53. package/dist/components/tree/renderTreeCanvas.js +2 -2
  54. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  55. package/dist/constants.d.ts +4 -0
  56. package/dist/constants.js +7 -0
  57. package/dist/constants.js.map +1 -1
  58. package/dist/flatToTree.js +6 -0
  59. package/dist/flatToTree.js.map +1 -1
  60. package/dist/hierarchy.d.ts +1 -1
  61. package/dist/hierarchy.js +136 -102
  62. package/dist/hierarchy.js.map +1 -1
  63. package/dist/index.d.ts +1 -0
  64. package/dist/index.js +1 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/launchInterProScan.js +22 -9
  67. package/dist/launchInterProScan.js.map +1 -1
  68. package/dist/measureTextCanvas.js +2 -1
  69. package/dist/measureTextCanvas.js.map +1 -1
  70. package/dist/model.d.ts +212 -150
  71. package/dist/model.js +239 -205
  72. package/dist/model.js.map +1 -1
  73. package/dist/parseAsn1.js +8 -2
  74. package/dist/parseAsn1.js.map +1 -1
  75. package/dist/renderToSvg.js +27 -24
  76. package/dist/renderToSvg.js.map +1 -1
  77. package/dist/setFontSize.d.ts +3 -0
  78. package/dist/setFontSize.js +14 -0
  79. package/dist/setFontSize.js.map +1 -0
  80. package/dist/useDevicePixelRatio.d.ts +1 -0
  81. package/dist/useDevicePixelRatio.js +27 -0
  82. package/dist/useDevicePixelRatio.js.map +1 -0
  83. package/dist/useWheelScroll.d.ts +3 -1
  84. package/dist/useWheelScroll.js +64 -22
  85. package/dist/useWheelScroll.js.map +1 -1
  86. package/dist/util.d.ts +4 -0
  87. package/dist/util.js +36 -0
  88. package/dist/util.js.map +1 -1
  89. package/dist/version.d.ts +1 -1
  90. package/dist/version.js +1 -1
  91. package/dist/webpack.js +0 -1
  92. package/dist/webpack.js.map +1 -1
  93. package/dist/wheelZoom.d.ts +4 -0
  94. package/dist/wheelZoom.js +43 -0
  95. package/dist/wheelZoom.js.map +1 -0
  96. package/package.json +5 -5
  97. package/src/components/ConservationTrack.tsx +2 -18
  98. package/src/components/MSAView.tsx +10 -1
  99. package/src/components/ResizeHandles.tsx +101 -113
  100. package/src/components/TextTrack.tsx +2 -1
  101. package/src/components/Track.tsx +1 -1
  102. package/src/components/VerticalScrollbar.tsx +2 -2
  103. package/src/components/dialogs/UserProvidedDomainsDialog.tsx +1 -2
  104. package/src/components/header/ColorSchemeMenu.tsx +1 -0
  105. package/src/components/header/FileMenu.tsx +1 -0
  106. package/src/components/header/HeaderInfoArea.tsx +32 -7
  107. package/src/components/header/MSASettingsMenu.tsx +1 -0
  108. package/src/components/header/TreeSettingsMenu.tsx +1 -0
  109. package/src/components/header/ZoomControls.tsx +14 -1
  110. package/src/components/header/ZoomMenu.tsx +10 -0
  111. package/src/components/minimap/Minimap.tsx +6 -15
  112. package/src/components/minimap/MinimapSVG.tsx +9 -22
  113. package/src/components/minimap/minimapLayout.ts +17 -0
  114. package/src/components/msa/MSACanvas.tsx +9 -0
  115. package/src/components/msa/MSACanvasBlock.tsx +7 -1
  116. package/src/components/msa/MSAMouseoverCanvas.tsx +4 -3
  117. package/src/components/msa/renderMSABlock.ts +6 -9
  118. package/src/components/msa/renderMSAMouseover.ts +3 -1
  119. package/src/components/tracks/renderTracksSvg.ts +2 -1
  120. package/src/components/tree/renderTreeCanvas.ts +2 -2
  121. package/src/constants.ts +8 -0
  122. package/src/flatToTree.test.ts +30 -0
  123. package/src/flatToTree.ts +10 -1
  124. package/src/hierarchy.test.ts +53 -1
  125. package/src/hierarchy.ts +139 -113
  126. package/src/index.ts +1 -0
  127. package/src/launchInterProScan.ts +33 -11
  128. package/src/measureTextCanvas.ts +3 -1
  129. package/src/model.ts +312 -242
  130. package/src/parseAsn1.test.ts +6 -0
  131. package/src/parseAsn1.ts +11 -3
  132. package/src/renderToSvg.tsx +79 -67
  133. package/src/setFontSize.test.ts +33 -0
  134. package/src/setFontSize.ts +20 -0
  135. package/src/useDevicePixelRatio.ts +29 -0
  136. package/src/useWheelScroll.ts +79 -21
  137. package/src/util.ts +35 -0
  138. package/src/version.ts +1 -1
  139. package/src/webpack.ts +0 -1
  140. package/src/wheelZoom.test.ts +46 -0
  141. package/src/wheelZoom.ts +47 -0
package/src/model.ts CHANGED
@@ -40,11 +40,15 @@ import {
40
40
  defaultRowHeight,
41
41
  defaultScrollX,
42
42
  defaultScrollY,
43
+ defaultScrollZoom,
43
44
  defaultShowBranchLen,
44
45
  defaultShowDomains,
45
46
  defaultSubFeatureRows,
46
47
  defaultTreeAreaWidth,
47
48
  defaultTreeWidth,
49
+ maxCellSize,
50
+ minColWidth,
51
+ minRowHeight,
48
52
  } from './constants.ts'
49
53
  import { createPaletteMap } from './createPaletteMap.ts'
50
54
  import { flatToTree } from './flatToTree.ts'
@@ -75,27 +79,34 @@ import {
75
79
  visibleColToSeqPosForRow,
76
80
  } from './rowCoordinateCalculations.ts'
77
81
  import { seqPosToGlobalCol } from './seqPosToGlobalCol.ts'
78
- import { len, skipBlanks } from './util.ts'
82
+ import { computeRowInsertions, len, skipBlanks, transform } from './util.ts'
79
83
  import { saveAs } from './vendor/fileSaver.ts'
80
84
 
81
85
  import type { HierarchyNode } from './hierarchy.ts'
82
86
  import type { InterProScanResults } from './launchInterProScan.ts'
83
- import type {
84
- BasicTrack,
85
- NodeWithIds,
86
- NodeWithIdsAndLength,
87
- TextTrackModel,
88
- } from './types.ts'
87
+ import type { BasicTrack, NodeWithIds, NodeWithIdsAndLength } from './types.ts'
89
88
  import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types'
90
89
  import type { Instance } from '@jbrowse/mobx-state-tree'
91
90
  import type { Theme } from '@mui/material'
92
91
 
92
+ function parseTreeText(text: string) {
93
+ if (text.startsWith('BioTreeContainer')) {
94
+ return flatToTree(parseAsn1(text))
95
+ }
96
+ return parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text)
97
+ }
98
+
93
99
  /**
94
100
  * #stateModel MsaView
95
- * extends
96
- * - [DialogQueueSessionMixin](../dialogqueuesessionmixin)
97
- * - [MSAModel](../msamodel)
98
- * - [Tree](../tree)
101
+ * #example
102
+ * ```js
103
+ * import { MSAModelF } from 'react-msaview'
104
+ * import { types } from '@jbrowse/mobx-state-tree'
105
+ *
106
+ * const RootModel = types.model({ view: types.optional(MSAModelF(), {}) })
107
+ * const root = RootModel.create({})
108
+ * root.view.setData({ msa: '>seq1\nACGT\n>seq2\nACGT' })
109
+ * ```
99
110
  */
100
111
  function stateModelFactory() {
101
112
  return types
@@ -138,6 +149,12 @@ function stateModelFactory() {
138
149
  */
139
150
  drawMsaLetters: defaultDrawMsaLetters,
140
151
 
152
+ /**
153
+ * #property
154
+ * zoom in/out on plain mouse-wheel without holding ctrl
155
+ */
156
+ scrollZoom: defaultScrollZoom,
157
+
141
158
  /**
142
159
  * #property
143
160
  * height of the div containing the view, px
@@ -251,9 +268,11 @@ function stateModelFactory() {
251
268
  /**
252
269
  * #volatile
253
270
  * high resolution scale factor, helps make canvas look better on hi-dpi
254
- * screens
271
+ * screens. derived from the device pixel ratio so canvases are crisp on
272
+ * retina/4k displays and not needlessly oversized on standard ones
255
273
  */
256
- highResScaleFactor: 2,
274
+ highResScaleFactor:
275
+ typeof window === 'undefined' ? 1 : window.devicePixelRatio,
257
276
 
258
277
  /**
259
278
  * #volatile
@@ -382,6 +401,14 @@ function stateModelFactory() {
382
401
  setWidth(arg: number) {
383
402
  self.volatileWidth = arg
384
403
  },
404
+ /**
405
+ * #action
406
+ * high-res scale factor, tracks the device pixel ratio so canvases stay
407
+ * crisp when the window moves between monitors or the browser zooms
408
+ */
409
+ setHighResScaleFactor(arg: number) {
410
+ self.highResScaleFactor = arg
411
+ },
385
412
  /**
386
413
  * #action
387
414
  * set the height of the view in px
@@ -411,29 +438,6 @@ function stateModelFactory() {
411
438
  self.mouseRow = row
412
439
  },
413
440
 
414
- /**
415
- * #action
416
- * set hovered tree node and its descendants
417
- */
418
- setHoveredTreeNode(nodeId?: string) {
419
- if (!nodeId) {
420
- self.hoveredTreeNode = undefined
421
- return
422
- }
423
-
424
- const node = find(
425
- (self as MsaViewModel).hierarchy,
426
- n => n.data.id === nodeId,
427
- )
428
- if (!node) {
429
- self.hoveredTreeNode = undefined
430
- return
431
- }
432
-
433
- const descendantNames = leaves(node).map(leaf => leaf.data.name)
434
-
435
- self.hoveredTreeNode = { nodeId, descendantNames }
436
- },
437
441
  /**
438
442
  * #action
439
443
  * set highlighted columns
@@ -619,14 +623,19 @@ function stateModelFactory() {
619
623
  * #getter
620
624
  */
621
625
  get colorScheme() {
622
- return colorSchemes[self.colorSchemeName]!
626
+ // colorSchemeName is a free string (menus, snapshots, URL params); fall
627
+ // back to the default rather than returning undefined on a stale name
628
+ return (
629
+ colorSchemes[self.colorSchemeName] ??
630
+ colorSchemes[defaultColorSchemeName]!
631
+ )
623
632
  },
624
633
 
625
634
  /**
626
635
  * #getter
627
636
  */
628
637
  get header() {
629
- return (this.MSA?.getHeader() ?? {}) as Record<string, unknown>
638
+ return this.MSA?.getHeader() ?? {}
630
639
  },
631
640
 
632
641
  /**
@@ -674,26 +683,16 @@ function stateModelFactory() {
674
683
  */
675
684
  get tree(): NodeWithIds {
676
685
  const text = self.data.tree
677
-
678
686
  return text
679
- ? generateNodeIds(
680
- text.startsWith('BioTreeContainer')
681
- ? flatToTree(parseAsn1(text))
682
- : parseNewick(
683
- text.startsWith('SEQ') ? parseEmfTree(text).tree : text,
684
- ),
685
- )
686
- : this.MSA?.getTree() || {
687
+ ? generateNodeIds(parseTreeText(text))
688
+ : (this.MSA?.getTree() ?? {
687
689
  noTree: true,
688
690
  children: [],
689
691
  id: 'empty',
690
692
  name: 'empty',
691
- }
693
+ })
692
694
  },
693
695
 
694
- /**
695
- * #getter
696
- */
697
696
  /**
698
697
  * #getter
699
698
  * Returns the list of row (sequence) names in display order.
@@ -706,11 +705,9 @@ function stateModelFactory() {
706
705
  * #getter
707
706
  */
708
707
  get rowNamesSet() {
709
- const map = new Map<string, number>()
710
- this.leaves.forEach((leaf, index) => {
711
- map.set(leaf.data.name, index)
712
- })
713
- return map
708
+ return new Map(
709
+ this.leaves.map((leaf, index) => [leaf.data.name, index] as const),
710
+ )
714
711
  },
715
712
  /**
716
713
  * #getter
@@ -727,23 +724,15 @@ function stateModelFactory() {
727
724
  */
728
725
  get hoveredInsertion() {
729
726
  const { mouseCol, mouseRow } = self
730
- if (mouseCol === undefined || mouseRow === undefined) {
731
- return undefined
732
- }
733
- const rowName = this.leaves[mouseRow]?.data.name
734
- if (!rowName) {
735
- return undefined
736
- }
737
- const insertions = this.insertionPositions.get(rowName)
738
- if (!insertions) {
739
- return undefined
740
- }
741
- const insertion = insertions.find(ins => ins.pos === mouseCol)
742
- if (insertion) {
743
- return {
744
- rowName,
745
- col: mouseCol,
746
- letters: insertion.letters,
727
+ if (mouseCol !== undefined && mouseRow !== undefined) {
728
+ const rowName = this.leaves[mouseRow]?.data.name
729
+ if (rowName) {
730
+ const insertion = this.insertionPositions
731
+ .get(rowName)
732
+ ?.find(ins => ins.pos === mouseCol)
733
+ if (insertion) {
734
+ return { rowName, col: mouseCol, letters: insertion.letters }
735
+ }
747
736
  }
748
737
  }
749
738
  return undefined
@@ -766,15 +755,14 @@ function stateModelFactory() {
766
755
 
767
756
  for (const collapsedId of self.collapsed) {
768
757
  const node = find(hier, n => n.data.id === collapsedId)
769
- if (!node) {
770
- continue
771
- }
772
- if (node.children) {
773
- collapse(node)
774
- } else if (node.parent?.children) {
775
- node.parent.children = node.parent.children.filter(
776
- c => c.data.id !== collapsedId,
777
- )
758
+ if (node) {
759
+ if (node.children) {
760
+ collapse(node)
761
+ } else if (node.parent?.children) {
762
+ node.parent.children = node.parent.children.filter(
763
+ c => c.data.id !== collapsedId,
764
+ )
765
+ }
778
766
  }
779
767
  }
780
768
 
@@ -786,7 +774,7 @@ function stateModelFactory() {
786
774
  * widget width minus the tree area gives the space for the MSA
787
775
  */
788
776
  get msaAreaWidth() {
789
- return self.width - self.treeAreaWidth
777
+ return self.width - self.treeAreaWidth - self.resizeHandleWidth
790
778
  },
791
779
 
792
780
  /**
@@ -841,49 +829,13 @@ function stateModelFactory() {
841
829
  * Returns a map of row name to array of insertions with display position and letters
842
830
  */
843
831
  get insertionPositions() {
844
- const { hideGapsEffective } = self
845
832
  const { blanks, rows } = this
846
- const blanksLen = blanks.length
847
- if (blanksLen === 0 || !hideGapsEffective) {
833
+ if (blanks.length === 0 || !self.hideGapsEffective) {
848
834
  return new Map<string, { pos: number; letters: string }[]>()
849
835
  }
850
836
  const result = new Map<string, { pos: number; letters: string }[]>()
851
837
  for (const [name, seq] of rows) {
852
- const insertions: { pos: number; letters: string }[] = []
853
- let displayPos = 0
854
- let blankIdx = 0
855
- let currentInsertPos = -1
856
- let letterChars: string[] = []
857
- const seqLen = seq.length
858
- for (let i = 0; i < seqLen; i++) {
859
- if (blankIdx < blanksLen && blanks[blankIdx] === i) {
860
- // bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
861
- const code = seq.charCodeAt(i)
862
- if (!((code - 45) >>> 0 <= 1)) {
863
- if (currentInsertPos === displayPos) {
864
- letterChars.push(seq[i]!)
865
- } else {
866
- if (letterChars.length > 0) {
867
- insertions.push({
868
- pos: currentInsertPos,
869
- letters: letterChars.join(''),
870
- })
871
- }
872
- currentInsertPos = displayPos
873
- letterChars = [seq[i]!]
874
- }
875
- }
876
- blankIdx++
877
- } else {
878
- displayPos++
879
- }
880
- }
881
- if (letterChars.length > 0) {
882
- insertions.push({
883
- pos: currentInsertPos,
884
- letters: letterChars.join(''),
885
- })
886
- }
838
+ const insertions = computeRowInsertions(blanks, seq)
887
839
  if (insertions.length > 0) {
888
840
  result.set(name, insertions)
889
841
  }
@@ -916,10 +868,9 @@ function stateModelFactory() {
916
868
  * #getter
917
869
  */
918
870
  get columns() {
871
+ const columns2d = this.columns2d
919
872
  return new Map(
920
- this.rows.map(
921
- (row, index) => [row[0], this.columns2d[index]!] as const,
922
- ),
873
+ this.rows.map((row, index) => [row[0], columns2d[index]!] as const),
923
874
  )
924
875
  },
925
876
  /**
@@ -927,14 +878,12 @@ function stateModelFactory() {
927
878
  */
928
879
  get columns2d() {
929
880
  const { hideGapsEffective } = self
930
- return this.rows
931
- .map(r => r[1])
932
- .map(str =>
933
- (hideGapsEffective
934
- ? skipBlanks(this.blanks, str)
935
- : str
936
- ).toUpperCase(),
937
- )
881
+ return this.rows.map(([, str]) =>
882
+ (hideGapsEffective
883
+ ? skipBlanks(this.blanks, str)
884
+ : str
885
+ ).toUpperCase(),
886
+ )
938
887
  },
939
888
  /**
940
889
  * #getter
@@ -946,7 +895,7 @@ function stateModelFactory() {
946
895
  * #getter
947
896
  */
948
897
  get colStats() {
949
- const r = [] as Record<string, number>[]
898
+ const r: Record<string, number>[] = []
950
899
  const columns = this.columns2d
951
900
  for (const column of columns) {
952
901
  for (let j = 0; j < column.length; j++) {
@@ -962,13 +911,7 @@ function stateModelFactory() {
962
911
  * #getter
963
912
  */
964
913
  get colStatsSums() {
965
- return this.colStats.map(col => {
966
- let s = 0
967
- for (const k in col) {
968
- s += col[k]!
969
- }
970
- return s
971
- })
914
+ return this.colStats.map(col => sum(Object.values(col)))
972
915
  },
973
916
 
974
917
  /**
@@ -985,17 +928,13 @@ function stateModelFactory() {
985
928
  }
986
929
  }
987
930
  }
931
+ // isDna already excludes U (not in the DNA set) and isRna excludes T,
932
+ // so the set membership alone disambiguates the two
988
933
  const dna = new Set(['A', 'C', 'G', 'T', 'N'])
989
934
  const rna = new Set(['A', 'C', 'G', 'U', 'N'])
990
- const isDna = [...letters].every(l => dna.has(l))
991
- const isRna = [...letters].every(l => rna.has(l))
992
- if (isDna && !letters.has('U')) {
993
- return 'dna'
994
- }
995
- if (isRna && !letters.has('T')) {
996
- return 'rna'
997
- }
998
- return 'amino'
935
+ const isDna = letters.size > 0 && [...letters].every(l => dna.has(l))
936
+ const isRna = letters.size > 0 && [...letters].every(l => rna.has(l))
937
+ return isDna ? 'dna' : isRna ? 'rna' : 'amino'
999
938
  },
1000
939
 
1001
940
  /**
@@ -1009,8 +948,7 @@ function stateModelFactory() {
1009
948
  const total = colStatsSums[i]!
1010
949
  let maxCount = 0
1011
950
  let letter = ''
1012
- for (const key in stats) {
1013
- const val = stats[key]!
951
+ for (const [key, val] of Object.entries(stats)) {
1014
952
  if (val > maxCount && key !== '-' && key !== '.') {
1015
953
  maxCount = val
1016
954
  letter = key
@@ -1064,12 +1002,9 @@ function stateModelFactory() {
1064
1002
  }
1065
1003
 
1066
1004
  let entropy = 0
1067
- for (const letter of Object.keys(stats)) {
1068
- if (letter === '-' || letter === '.') {
1069
- continue
1070
- }
1071
- const freq = stats[letter]! / nonGapTotal
1072
- if (freq > 0) {
1005
+ for (const [letter, count] of Object.entries(stats)) {
1006
+ if (letter !== '-' && letter !== '.') {
1007
+ const freq = count / nonGapTotal
1073
1008
  entropy -= freq * Math.log2(freq)
1074
1009
  }
1075
1010
  }
@@ -1087,7 +1022,8 @@ function stateModelFactory() {
1087
1022
  const r = this.root
1088
1023
  clusterLayout(r, this.totalHeight, self.treeWidth)
1089
1024
  r.data.length = 0
1090
- setBrLength(r, 0, self.treeWidth / maxLength(r))
1025
+ const max = maxLength(r)
1026
+ setBrLength(r, 0, max ? self.treeWidth / max : 0)
1091
1027
  return r as HierarchyNode<NodeWithIdsAndLength>
1092
1028
  },
1093
1029
 
@@ -1150,7 +1086,7 @@ function stateModelFactory() {
1150
1086
  * #getter
1151
1087
  */
1152
1088
  get dataInitialized() {
1153
- return (self.data.msa || self.data.tree) && !self.error
1089
+ return (self.data.msa !== '' || self.data.tree !== '') && !self.error
1154
1090
  },
1155
1091
  /**
1156
1092
  * #getter
@@ -1197,6 +1133,19 @@ function stateModelFactory() {
1197
1133
  get maxScrollX() {
1198
1134
  return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
1199
1135
  },
1136
+ /**
1137
+ * #getter
1138
+ * most-negative allowed scrollY, keeping the last row in view rather than
1139
+ * letting the whole alignment scroll off the top. visible MSA height is
1140
+ * computed inline here because msaAreaHeight is defined later in the chain.
1141
+ */
1142
+ get maxScrollY() {
1143
+ const visibleHeight =
1144
+ self.height -
1145
+ self.headerHeight -
1146
+ (self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0)
1147
+ return Math.min(-self.totalHeight + visibleHeight, 0)
1148
+ },
1200
1149
  /**
1201
1150
  * #getter
1202
1151
  */
@@ -1222,6 +1171,31 @@ function stateModelFactory() {
1222
1171
  self.drawMsaLetters = arg
1223
1172
  },
1224
1173
 
1174
+ /**
1175
+ * #action
1176
+ */
1177
+ setScrollZoom(arg: boolean) {
1178
+ self.scrollZoom = arg
1179
+ },
1180
+
1181
+ /**
1182
+ * #action
1183
+ * set hovered tree node and its descendants
1184
+ */
1185
+ setHoveredTreeNode(nodeId?: string) {
1186
+ if (nodeId) {
1187
+ const node = find(self.hierarchy, n => n.data.id === nodeId)
1188
+ self.hoveredTreeNode = node
1189
+ ? {
1190
+ nodeId,
1191
+ descendantNames: leaves(node).map(leaf => leaf.data.name),
1192
+ }
1193
+ : undefined
1194
+ } else {
1195
+ self.hoveredTreeNode = undefined
1196
+ }
1197
+ },
1198
+
1225
1199
  /**
1226
1200
  * #action
1227
1201
  * Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
@@ -1245,35 +1219,41 @@ function stateModelFactory() {
1245
1219
  * #action
1246
1220
  */
1247
1221
  zoomOutHorizontal() {
1248
- self.colWidth = Math.max(1, Math.floor(self.colWidth * 0.75))
1222
+ self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
1249
1223
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1250
1224
  },
1251
1225
  /**
1252
1226
  * #action
1253
1227
  */
1254
1228
  zoomInHorizontal() {
1255
- self.colWidth = Math.ceil(self.colWidth * 1.5)
1229
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
1256
1230
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1257
1231
  },
1258
1232
  /**
1259
1233
  * #action
1260
1234
  */
1261
1235
  zoomInVertical() {
1262
- self.rowHeight = Math.ceil(self.rowHeight * 1.5)
1236
+ self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5))
1263
1237
  },
1264
1238
  /**
1265
1239
  * #action
1266
1240
  */
1267
1241
  zoomOutVertical() {
1268
- self.rowHeight = Math.max(1.5, Math.floor(self.rowHeight * 0.75))
1242
+ self.rowHeight = Math.max(
1243
+ minRowHeight,
1244
+ Math.floor(self.rowHeight * 0.75),
1245
+ )
1269
1246
  },
1270
1247
  /**
1271
1248
  * #action
1272
1249
  */
1273
1250
  zoomIn() {
1274
1251
  transaction(() => {
1275
- self.colWidth = Math.ceil(self.colWidth * 1.5)
1276
- self.rowHeight = Math.ceil(self.rowHeight * 1.5)
1252
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
1253
+ self.rowHeight = Math.min(
1254
+ maxCellSize,
1255
+ Math.ceil(self.rowHeight * 1.5),
1256
+ )
1277
1257
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1278
1258
  })
1279
1259
  },
@@ -1282,27 +1262,79 @@ function stateModelFactory() {
1282
1262
  */
1283
1263
  zoomOut() {
1284
1264
  transaction(() => {
1285
- self.colWidth = Math.max(1, Math.floor(self.colWidth * 0.75))
1286
- self.rowHeight = Math.max(1.5, Math.floor(self.rowHeight * 0.75))
1265
+ self.colWidth = Math.max(
1266
+ minColWidth,
1267
+ Math.floor(self.colWidth * 0.75),
1268
+ )
1269
+ self.rowHeight = Math.max(
1270
+ minRowHeight,
1271
+ Math.floor(self.rowHeight * 0.75),
1272
+ )
1287
1273
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1288
1274
  })
1289
1275
  },
1276
+ /**
1277
+ * #action
1278
+ * Smoothly zoom by a continuous scaleFactor. The column under the cursor
1279
+ * (offsetX/offsetY, px relative to the MSA area) stays anchored
1280
+ * horizontally. Vertically the anchor is biased toward the top: when the
1281
+ * alignment nearly fits the viewport, snap to y=0 rather than pinning a
1282
+ * random row under the cursor, with the bias fading out as the alignment
1283
+ * grows taller than the viewport (where cursor-anchoring is useful).
1284
+ * Drives wheel/trackpad-pinch zoom.
1285
+ */
1286
+ zoomToPos(scaleFactor: number, offsetX: number, offsetY: number) {
1287
+ transaction(() => {
1288
+ const colInView = (-self.scrollX + offsetX) / self.colWidth
1289
+ const rowInView = (-self.scrollY + offsetY) / self.rowHeight
1290
+ self.colWidth = clamp(
1291
+ self.colWidth * scaleFactor,
1292
+ minColWidth,
1293
+ maxCellSize,
1294
+ )
1295
+ self.rowHeight = clamp(
1296
+ self.rowHeight * scaleFactor,
1297
+ minRowHeight,
1298
+ maxCellSize,
1299
+ )
1300
+ self.scrollX = clamp(
1301
+ offsetX - colInView * self.colWidth,
1302
+ self.maxScrollX,
1303
+ 0,
1304
+ )
1305
+
1306
+ const anchoredScrollY = offsetY - rowInView * self.rowHeight
1307
+ const overflow = Math.max(0, self.totalHeight - self.height)
1308
+ const topBias =
1309
+ self.height > 0 ? clamp(1 - overflow / self.height, 0, 1) : 0
1310
+ self.scrollY = clamp(
1311
+ anchoredScrollY * (1 - topBias),
1312
+ self.maxScrollY,
1313
+ 0,
1314
+ )
1315
+ })
1316
+ },
1290
1317
  /**
1291
1318
  * #action
1292
1319
  */
1293
1320
  doScrollY(deltaY: number) {
1294
- self.scrollY = clamp(self.scrollY + deltaY, -self.totalHeight + 10, 0)
1321
+ self.scrollY = clamp(self.scrollY + deltaY, self.maxScrollY, 0)
1295
1322
  },
1296
1323
 
1297
- setInterProAnnotations(data?: Record<string, InterProScanResults>) {
1324
+ /**
1325
+ * #action
1326
+ * Set domain annotations and reveal the overlay in a single step (or clear
1327
+ * both when passed undefined). Shared by every domain source: InterProScan,
1328
+ * GFF, user-provided uploads, and NCBI CDD.
1329
+ */
1330
+ setDomains(data?: Record<string, InterProScanResults>) {
1298
1331
  self.interProAnnotations = data
1332
+ self.setShowDomains(!!data)
1299
1333
  },
1300
1334
 
1301
1335
  applyGFFText(gffText: string) {
1302
1336
  const gffRecords = parseGFF(gffText)
1303
- const interProResults = gffToInterProResults(gffRecords)
1304
- self.interProAnnotations = interProResults
1305
- self.setShowDomains(true)
1337
+ this.setDomains(gffToInterProResults(gffRecords))
1306
1338
  },
1307
1339
 
1308
1340
  /**
@@ -1342,16 +1374,15 @@ function stateModelFactory() {
1342
1374
  */
1343
1375
  get labelWidthMap() {
1344
1376
  const { rowHeight, leaves, treeMetadata, fontSize } = self
1345
- if (rowHeight <= 5) {
1346
- return new Map<string, number>()
1347
- }
1348
- return new Map(
1349
- leaves.map(node => {
1350
- const { name } = node.data
1351
- const displayName = treeMetadata[name]?.genome || name
1352
- return [name, measureTextCanvas(displayName, fontSize)] as const
1353
- }),
1354
- )
1377
+ return rowHeight <= 5
1378
+ ? new Map<string, number>()
1379
+ : new Map(
1380
+ leaves.map(node => {
1381
+ const { name } = node.data
1382
+ const displayName = treeMetadata[name]?.genome ?? name
1383
+ return [name, measureTextCanvas(displayName, fontSize)] as const
1384
+ }),
1385
+ )
1355
1386
  },
1356
1387
 
1357
1388
  get labelsWidth() {
@@ -1378,18 +1409,13 @@ function stateModelFactory() {
1378
1409
  */
1379
1410
  get adapterTrackModels(): BasicTrack[] {
1380
1411
  const { rowHeight, MSA, hideGapsEffective, blanks } = self
1381
- const tracks = (MSA?.tracks ?? []) as (TextTrackModel & {
1382
- data?: string
1383
- })[]
1412
+ const tracks = MSA?.tracks ?? []
1384
1413
  return tracks
1385
1414
  .filter(t => !!t.data)
1386
1415
  .map(t => ({
1387
1416
  model: {
1388
1417
  ...t,
1389
- data:
1390
- hideGapsEffective && t.data
1391
- ? skipBlanks(blanks, t.data)
1392
- : t.data,
1418
+ data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
1393
1419
  height: rowHeight,
1394
1420
  },
1395
1421
  ReactComponent: TextTrack,
@@ -1545,26 +1571,25 @@ function stateModelFactory() {
1545
1571
  )
1546
1572
  },
1547
1573
  get tidyInterProAnnotations() {
1548
- const { interProAnnotations } = self
1549
- if (!interProAnnotations) {
1550
- return []
1551
- }
1552
- return Object.entries(interProAnnotations)
1553
- .flatMap(([id, val]) =>
1554
- val.matches.flatMap(({ signature, locations }) =>
1555
- signature.entry
1556
- ? locations.map(({ start, end }) => ({
1557
- id,
1558
- name: signature.entry!.name,
1559
- accession: signature.entry!.accession,
1560
- description: signature.entry!.description,
1561
- start,
1562
- end,
1563
- }))
1564
- : [],
1565
- ),
1566
- )
1567
- .toSorted((a, b) => len(b) - len(a))
1574
+ return self.interProAnnotations
1575
+ ? Object.entries(self.interProAnnotations)
1576
+ .flatMap(([id, val]) =>
1577
+ val.matches.flatMap(({ signature, locations }) => {
1578
+ const { entry } = signature
1579
+ return entry
1580
+ ? locations.map(({ start, end }) => ({
1581
+ id,
1582
+ name: entry.name,
1583
+ accession: entry.accession,
1584
+ description: entry.description,
1585
+ start,
1586
+ end,
1587
+ }))
1588
+ : []
1589
+ }),
1590
+ )
1591
+ .toSorted((a, b) => len(b) - len(a))
1592
+ : []
1568
1593
  },
1569
1594
  get tidyFilteredInterProAnnotations() {
1570
1595
  return this.tidyInterProAnnotations.filter(r =>
@@ -1594,12 +1619,10 @@ function stateModelFactory() {
1594
1619
  return createPaletteMap([...self.tidyInterProAnnotationTypes.keys()])
1595
1620
  },
1596
1621
  get strokePalette() {
1597
- return Object.fromEntries(
1598
- Object.entries(this.fillPalette).map(([key, val]) => [
1599
- key,
1600
- colord(val).darken(0.1).toHex(),
1601
- ]),
1602
- )
1622
+ return transform(this.fillPalette, ([key, val]) => [
1623
+ key,
1624
+ colord(val).darken(0.1).toHex(),
1625
+ ])
1603
1626
  },
1604
1627
 
1605
1628
  /**
@@ -1641,8 +1664,7 @@ function stateModelFactory() {
1641
1664
  self.setTreeFilehandle(undefined)
1642
1665
  self.setMSAFilehandle(undefined)
1643
1666
  self.setGFFFilehandle(undefined)
1644
- self.setInterProAnnotations(undefined)
1645
- self.setShowDomains(false)
1667
+ self.setDomains(undefined)
1646
1668
  },
1647
1669
  /**
1648
1670
  * #action
@@ -1671,8 +1693,20 @@ function stateModelFactory() {
1671
1693
  * #action
1672
1694
  */
1673
1695
  fit() {
1674
- self.rowHeight = self.msaAreaHeight / self.numRows
1675
- self.colWidth = self.msaAreaWidth / self.numColumns
1696
+ if (self.numRows > 0) {
1697
+ self.rowHeight = clamp(
1698
+ self.msaAreaHeight / self.numRows,
1699
+ minRowHeight,
1700
+ maxCellSize,
1701
+ )
1702
+ }
1703
+ if (self.numColumns > 0) {
1704
+ self.colWidth = clamp(
1705
+ self.msaAreaWidth / self.numColumns,
1706
+ minColWidth,
1707
+ maxCellSize,
1708
+ )
1709
+ }
1676
1710
  self.scrollX = 0
1677
1711
  self.scrollY = 0
1678
1712
  },
@@ -1680,14 +1714,26 @@ function stateModelFactory() {
1680
1714
  * #action
1681
1715
  */
1682
1716
  fitVertically() {
1683
- self.rowHeight = self.msaAreaHeight / self.numRows
1717
+ if (self.numRows > 0) {
1718
+ self.rowHeight = clamp(
1719
+ self.msaAreaHeight / self.numRows,
1720
+ minRowHeight,
1721
+ maxCellSize,
1722
+ )
1723
+ }
1684
1724
  self.scrollY = 0
1685
1725
  },
1686
1726
  /**
1687
1727
  * #action
1688
1728
  */
1689
1729
  fitHorizontally() {
1690
- self.colWidth = self.msaAreaWidth / self.numColumns
1730
+ if (self.numColumns > 0) {
1731
+ self.colWidth = clamp(
1732
+ self.msaAreaWidth / self.numColumns,
1733
+ minColWidth,
1734
+ maxCellSize,
1735
+ )
1736
+ }
1691
1737
  self.scrollX = 0
1692
1738
  },
1693
1739
 
@@ -1701,26 +1747,39 @@ function stateModelFactory() {
1701
1747
  }),
1702
1748
  )
1703
1749
 
1704
- // autorun opens treeFilehandle
1750
+ // autorun opens treeFilehandle. generation guard: if the filehandle
1751
+ // changes mid-fetch, a slower earlier request must not clobber the data
1752
+ // (or loading flag) set by the newer one
1753
+ let treeGeneration = 0
1705
1754
  addDisposer(
1706
1755
  self,
1707
1756
  autorun(async () => {
1708
1757
  const { treeFilehandle } = self
1709
1758
  if (treeFilehandle) {
1759
+ const generation = ++treeGeneration
1710
1760
  try {
1711
1761
  self.setLoadingTree(true)
1712
- self.setTree(
1713
- await fetchAndMaybeUnzipText(openLocation(treeFilehandle)),
1762
+ const text = await fetchAndMaybeUnzipText(
1763
+ openLocation(treeFilehandle),
1714
1764
  )
1715
- if (treeFilehandle.locationType === 'BlobLocation') {
1716
- // clear filehandle after loading if from a local file
1717
- self.setTreeFilehandle(undefined)
1765
+ if (generation === treeGeneration) {
1766
+ transaction(() => {
1767
+ self.setTree(text)
1768
+ if (treeFilehandle.locationType === 'BlobLocation') {
1769
+ // clear filehandle after loading if from a local file
1770
+ self.setTreeFilehandle(undefined)
1771
+ }
1772
+ })
1718
1773
  }
1719
1774
  } catch (e) {
1720
- console.error(e)
1721
- self.setError(e)
1775
+ if (generation === treeGeneration) {
1776
+ console.error(e)
1777
+ self.setError(e)
1778
+ }
1722
1779
  } finally {
1723
- self.setLoadingTree(false)
1780
+ if (generation === treeGeneration) {
1781
+ self.setLoadingTree(false)
1782
+ }
1724
1783
  }
1725
1784
  }
1726
1785
  }),
@@ -1783,36 +1842,47 @@ function stateModelFactory() {
1783
1842
  }),
1784
1843
  )
1785
1844
 
1786
- // autorun opens msaFilehandle
1845
+ // autorun opens msaFilehandle. generation guard: see treeFilehandle
1846
+ let msaGeneration = 0
1787
1847
  addDisposer(
1788
1848
  self,
1789
1849
  autorun(async () => {
1790
1850
  const { msaFilehandle } = self
1791
1851
  if (msaFilehandle) {
1852
+ const generation = ++msaGeneration
1792
1853
  try {
1793
1854
  self.setLoadingMSA(true)
1794
1855
  self.setError(undefined)
1795
1856
  const txt = await fetchAndMaybeUnzipText(
1796
1857
  openLocation(msaFilehandle),
1797
1858
  )
1798
- transaction(() => {
1799
- self.setMSA(txt)
1800
- if (msaFilehandle.locationType === 'BlobLocation') {
1801
- // clear filehandle after loading if from a local file
1802
- self.setMSAFilehandle(undefined)
1803
- }
1804
- })
1859
+ if (generation === msaGeneration) {
1860
+ transaction(() => {
1861
+ self.setMSA(txt)
1862
+ if (msaFilehandle.locationType === 'BlobLocation') {
1863
+ // clear filehandle after loading if from a local file
1864
+ self.setMSAFilehandle(undefined)
1865
+ }
1866
+ })
1867
+ }
1805
1868
  } catch (e) {
1806
- console.error(e)
1807
- self.setError(e)
1869
+ if (generation === msaGeneration) {
1870
+ console.error(e)
1871
+ self.setError(e)
1872
+ }
1808
1873
  } finally {
1809
- self.setLoadingMSA(false)
1874
+ if (generation === msaGeneration) {
1875
+ self.setLoadingMSA(false)
1876
+ }
1810
1877
  }
1811
1878
  }
1812
1879
  }),
1813
1880
  )
1814
1881
 
1815
- // force colStats not to go stale
1882
+ // Keep the parse chain warm: reading self.columns transitively holds
1883
+ // self.MSA (parseMSA) computed alive, so it is parsed once per data
1884
+ // change rather than re-parsed on every non-reactive access. colStats is
1885
+ // additionally held for dynamic color schemes. Do not remove.
1816
1886
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
1817
1887
  // xref problem https://github.com/GMOD/react-msaview/issues/75
1818
1888
  addDisposer(