react-msaview 5.1.1 → 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 (190) 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/calculateBlocks.d.ts +4 -10
  6. package/dist/calculateBlocks.js +12 -20
  7. package/dist/calculateBlocks.js.map +1 -1
  8. package/dist/components/ConservationTrack.js +20 -37
  9. package/dist/components/ConservationTrack.js.map +1 -1
  10. package/dist/components/MSAView.js +8 -1
  11. package/dist/components/MSAView.js.map +1 -1
  12. package/dist/components/ResizeHandles.d.ts +3 -0
  13. package/dist/components/ResizeHandles.js +60 -105
  14. package/dist/components/ResizeHandles.js.map +1 -1
  15. package/dist/components/TextTrack.js +25 -34
  16. package/dist/components/TextTrack.js.map +1 -1
  17. package/dist/components/Track.js +1 -1
  18. package/dist/components/Track.js.map +1 -1
  19. package/dist/components/VerticalScrollbar.js +2 -2
  20. package/dist/components/VerticalScrollbar.js.map +1 -1
  21. package/dist/components/dialogs/ExportSVGDialog.js +20 -20
  22. package/dist/components/dialogs/ExportSVGDialog.js.map +1 -1
  23. package/dist/components/dialogs/InterProScanDialog.js +6 -1
  24. package/dist/components/dialogs/InterProScanDialog.js.map +1 -1
  25. package/dist/components/dialogs/UserProvidedDomainsDialog.js +1 -2
  26. package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +1 -1
  27. package/dist/components/header/ColorSchemeMenu.js +1 -1
  28. package/dist/components/header/ColorSchemeMenu.js.map +1 -1
  29. package/dist/components/header/FileMenu.js +3 -2
  30. package/dist/components/header/FileMenu.js.map +1 -1
  31. package/dist/components/header/Header.js +1 -1
  32. package/dist/components/header/HeaderInfoArea.js +28 -8
  33. package/dist/components/header/HeaderInfoArea.js.map +1 -1
  34. package/dist/components/header/MSASettingsMenu.js +1 -1
  35. package/dist/components/header/MSASettingsMenu.js.map +1 -1
  36. package/dist/components/header/TreeSettingsMenu.js +1 -1
  37. package/dist/components/header/TreeSettingsMenu.js.map +1 -1
  38. package/dist/components/header/ZoomControls.js +7 -2
  39. package/dist/components/header/ZoomControls.js.map +1 -1
  40. package/dist/components/header/ZoomMenu.js +9 -0
  41. package/dist/components/header/ZoomMenu.js.map +1 -1
  42. package/dist/components/header/settingsMenuItems.js +24 -8
  43. package/dist/components/header/settingsMenuItems.js.map +1 -1
  44. package/dist/components/minimap/Minimap.js +6 -17
  45. package/dist/components/minimap/Minimap.js.map +1 -1
  46. package/dist/components/minimap/MinimapSVG.js +7 -17
  47. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  48. package/dist/components/minimap/minimapLayout.d.ts +9 -0
  49. package/dist/components/minimap/minimapLayout.js +15 -0
  50. package/dist/components/minimap/minimapLayout.js.map +1 -0
  51. package/dist/components/msa/MSACanvas.js +6 -1
  52. package/dist/components/msa/MSACanvas.js.map +1 -1
  53. package/dist/components/msa/MSACanvasBlock.js +5 -1
  54. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  55. package/dist/components/msa/MSAMouseoverCanvas.js +2 -2
  56. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  57. package/dist/components/msa/getVisibleLeaves.d.ts +6 -0
  58. package/dist/components/msa/getVisibleLeaves.js +9 -0
  59. package/dist/components/msa/getVisibleLeaves.js.map +1 -0
  60. package/dist/components/msa/renderBoxFeatureCanvasBlock.d.ts +2 -1
  61. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +5 -6
  62. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  63. package/dist/components/msa/renderMSABlock.d.ts +2 -1
  64. package/dist/components/msa/renderMSABlock.js +100 -88
  65. package/dist/components/msa/renderMSABlock.js.map +1 -1
  66. package/dist/components/msa/renderMSAMouseover.js +5 -4
  67. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  68. package/dist/components/msa/visibleColRange.d.ts +8 -0
  69. package/dist/components/msa/visibleColRange.js +10 -0
  70. package/dist/components/msa/visibleColRange.js.map +1 -0
  71. package/dist/components/renderCtx.d.ts +7 -0
  72. package/dist/components/renderCtx.js +2 -0
  73. package/dist/components/renderCtx.js.map +1 -0
  74. package/dist/components/tracks/renderTracksSvg.d.ts +6 -5
  75. package/dist/components/tracks/renderTracksSvg.js +13 -5
  76. package/dist/components/tracks/renderTracksSvg.js.map +1 -1
  77. package/dist/components/tree/TreeCanvas.js.map +1 -1
  78. package/dist/components/tree/TreeCanvasBlock.js +2 -10
  79. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  80. package/dist/components/tree/renderTreeCanvas.d.ts +5 -4
  81. package/dist/components/tree/renderTreeCanvas.js +18 -26
  82. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  83. package/dist/components/util.d.ts +0 -14
  84. package/dist/components/util.js +0 -58
  85. package/dist/components/util.js.map +1 -1
  86. package/dist/constants.d.ts +4 -0
  87. package/dist/constants.js +7 -0
  88. package/dist/constants.js.map +1 -1
  89. package/dist/flatToTree.js +6 -0
  90. package/dist/flatToTree.js.map +1 -1
  91. package/dist/hierarchy.d.ts +3 -1
  92. package/dist/hierarchy.js +142 -89
  93. package/dist/hierarchy.js.map +1 -1
  94. package/dist/index.d.ts +1 -0
  95. package/dist/index.js +8 -0
  96. package/dist/index.js.map +1 -1
  97. package/dist/launchInterProScan.js +22 -9
  98. package/dist/launchInterProScan.js.map +1 -1
  99. package/dist/measureTextCanvas.js +2 -1
  100. package/dist/measureTextCanvas.js.map +1 -1
  101. package/dist/model.d.ts +234 -176
  102. package/dist/model.js +285 -259
  103. package/dist/model.js.map +1 -1
  104. package/dist/parseAsn1.js +8 -2
  105. package/dist/parseAsn1.js.map +1 -1
  106. package/dist/renderToSvg.d.ts +1 -1
  107. package/dist/renderToSvg.js +43 -46
  108. package/dist/renderToSvg.js.map +1 -1
  109. package/dist/rowCoordinateCalculations.d.ts +0 -9
  110. package/dist/rowCoordinateCalculations.js +0 -20
  111. package/dist/rowCoordinateCalculations.js.map +1 -1
  112. package/dist/setFontSize.d.ts +3 -0
  113. package/dist/setFontSize.js +14 -0
  114. package/dist/setFontSize.js.map +1 -0
  115. package/dist/useDevicePixelRatio.d.ts +1 -0
  116. package/dist/useDevicePixelRatio.js +27 -0
  117. package/dist/useDevicePixelRatio.js.map +1 -0
  118. package/dist/useWheelScroll.d.ts +3 -1
  119. package/dist/useWheelScroll.js +64 -22
  120. package/dist/useWheelScroll.js.map +1 -1
  121. package/dist/util.d.ts +5 -0
  122. package/dist/util.js +59 -1
  123. package/dist/util.js.map +1 -1
  124. package/dist/version.d.ts +1 -1
  125. package/dist/version.js +1 -1
  126. package/dist/webpack.js +0 -1
  127. package/dist/webpack.js.map +1 -1
  128. package/dist/wheelZoom.d.ts +4 -0
  129. package/dist/wheelZoom.js +43 -0
  130. package/dist/wheelZoom.js.map +1 -0
  131. package/package.json +5 -5
  132. package/src/calculateBlocks.ts +18 -51
  133. package/src/components/ConservationTrack.tsx +20 -45
  134. package/src/components/MSAView.tsx +10 -1
  135. package/src/components/ResizeHandles.tsx +101 -113
  136. package/src/components/TextTrack.tsx +31 -38
  137. package/src/components/Track.tsx +1 -1
  138. package/src/components/VerticalScrollbar.tsx +2 -2
  139. package/src/components/dialogs/ExportSVGDialog.tsx +27 -17
  140. package/src/components/dialogs/InterProScanDialog.tsx +6 -1
  141. package/src/components/dialogs/UserProvidedDomainsDialog.tsx +1 -2
  142. package/src/components/header/ColorSchemeMenu.tsx +1 -0
  143. package/src/components/header/FileMenu.tsx +3 -1
  144. package/src/components/header/Header.tsx +1 -1
  145. package/src/components/header/HeaderInfoArea.tsx +32 -7
  146. package/src/components/header/MSASettingsMenu.tsx +1 -0
  147. package/src/components/header/TreeSettingsMenu.tsx +1 -0
  148. package/src/components/header/ZoomControls.tsx +14 -1
  149. package/src/components/header/ZoomMenu.tsx +10 -0
  150. package/src/components/header/settingsMenuItems.ts +24 -8
  151. package/src/components/minimap/Minimap.tsx +6 -20
  152. package/src/components/minimap/MinimapSVG.tsx +10 -27
  153. package/src/components/minimap/minimapLayout.ts +17 -0
  154. package/src/components/msa/MSACanvas.tsx +9 -0
  155. package/src/components/msa/MSACanvasBlock.tsx +7 -1
  156. package/src/components/msa/MSAMouseoverCanvas.tsx +4 -3
  157. package/src/components/msa/getVisibleLeaves.ts +21 -0
  158. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +9 -9
  159. package/src/components/msa/renderMSABlock.ts +123 -125
  160. package/src/components/msa/renderMSAMouseover.ts +5 -4
  161. package/src/components/msa/visibleColRange.ts +17 -0
  162. package/src/components/renderCtx.ts +29 -0
  163. package/src/components/tracks/renderTracksSvg.ts +20 -10
  164. package/src/components/tree/TreeCanvas.tsx +3 -1
  165. package/src/components/tree/TreeCanvasBlock.tsx +2 -12
  166. package/src/components/tree/renderTreeCanvas.ts +27 -30
  167. package/src/components/util.ts +0 -81
  168. package/src/constants.ts +8 -0
  169. package/src/flatToTree.test.ts +30 -0
  170. package/src/flatToTree.ts +10 -1
  171. package/src/hierarchy.test.ts +53 -1
  172. package/src/hierarchy.ts +153 -100
  173. package/src/index.ts +8 -0
  174. package/src/launchInterProScan.ts +33 -11
  175. package/src/measureTextCanvas.ts +3 -1
  176. package/src/model.ts +359 -306
  177. package/src/parseAsn1.test.ts +6 -0
  178. package/src/parseAsn1.ts +11 -3
  179. package/src/renderToSvg.tsx +99 -95
  180. package/src/rowCoordinateCalculations.test.ts +0 -51
  181. package/src/rowCoordinateCalculations.ts +0 -23
  182. package/src/setFontSize.test.ts +33 -0
  183. package/src/setFontSize.ts +20 -0
  184. package/src/useDevicePixelRatio.ts +29 -0
  185. package/src/useWheelScroll.ts +79 -21
  186. package/src/util.ts +60 -1
  187. package/src/version.ts +1 -1
  188. package/src/webpack.ts +0 -1
  189. package/src/wheelZoom.test.ts +46 -0
  190. package/src/wheelZoom.ts +47 -0
package/src/model.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  parseNewick,
20
20
  } from 'msa-parsers'
21
21
 
22
- import { blocksX, blocksY } from './calculateBlocks.ts'
22
+ import { calculateBlocks } from './calculateBlocks.ts'
23
23
  import { clustalXColumnColors } from './clustalX.ts'
24
24
  import colorSchemes from './colorSchemes.ts'
25
25
  import ConservationTrack from './components/ConservationTrack.tsx'
@@ -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
@@ -317,13 +336,6 @@ function stateModelFactory() {
317
336
  */
318
337
  highlightedColumns: undefined as number[] | undefined,
319
338
 
320
- /**
321
- * #volatile
322
- * a dummy variable that is incremented when ref changes so autorun for
323
- * drawing canvas commands will run
324
- */
325
- nref: 0,
326
-
327
339
  /**
328
340
  * #volatile
329
341
  */
@@ -389,6 +401,14 @@ function stateModelFactory() {
389
401
  setWidth(arg: number) {
390
402
  self.volatileWidth = arg
391
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
+ },
392
412
  /**
393
413
  * #action
394
414
  * set the height of the view in px
@@ -408,38 +428,23 @@ function stateModelFactory() {
408
428
  /**
409
429
  * #action
410
430
  * set mouse position (row, column) in the MSA
431
+ *
432
+ * CROSS-REPO CONTRACT: jbrowse-plugin-protein3d calls this (and reads the
433
+ * `mouseCol` volatile) to sync MSA<->3D-structure hover. Keep the name and
434
+ * signature stable; see that repo's ProteinToMsaHoverSync.tsx.
411
435
  */
412
436
  setMousePos(col?: number, row?: number) {
413
437
  self.mouseCol = col
414
438
  self.mouseRow = row
415
439
  },
416
440
 
417
- /**
418
- * #action
419
- * set hovered tree node and its descendants
420
- */
421
- setHoveredTreeNode(nodeId?: string) {
422
- if (!nodeId) {
423
- self.hoveredTreeNode = undefined
424
- return
425
- }
426
-
427
- const node = find(
428
- (self as MsaViewModel).hierarchy,
429
- n => n.data.id === nodeId,
430
- )
431
- if (!node) {
432
- self.hoveredTreeNode = undefined
433
- return
434
- }
435
-
436
- const descendantNames = leaves(node).map(leaf => leaf.data.name)
437
-
438
- self.hoveredTreeNode = { nodeId, descendantNames }
439
- },
440
441
  /**
441
442
  * #action
442
443
  * set highlighted columns
444
+ *
445
+ * CROSS-REPO CONTRACT: called by jbrowse-plugin-msaview
446
+ * (afterCreateAutoruns.ts) to highlight alignment columns. It has no
447
+ * in-repo caller, so do not flag it as dead code — it is public API.
443
448
  */
444
449
  setHighlightedColumns(columns?: number[]) {
445
450
  self.highlightedColumns = columns
@@ -618,21 +623,26 @@ function stateModelFactory() {
618
623
  * #getter
619
624
  */
620
625
  get colorScheme() {
621
- 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
+ )
622
632
  },
623
633
 
624
634
  /**
625
635
  * #getter
626
636
  */
627
637
  get header() {
628
- return (this.MSA?.getHeader() || {}) as Record<string, unknown>
638
+ return this.MSA?.getHeader() ?? {}
629
639
  },
630
640
 
631
641
  /**
632
642
  * #getter
633
643
  */
634
644
  get alignmentNames() {
635
- return this.MSA?.alignmentNames || []
645
+ return this.MSA?.alignmentNames ?? []
636
646
  },
637
647
  /**
638
648
  * #getter
@@ -665,7 +675,7 @@ function stateModelFactory() {
665
675
  * #getter
666
676
  */
667
677
  get numColumns() {
668
- return (this.MSA?.getWidth() || 0) - this.blanks.length
678
+ return (this.MSA?.getWidth() ?? 0) - this.blanks.length
669
679
  },
670
680
 
671
681
  /**
@@ -673,35 +683,40 @@ function stateModelFactory() {
673
683
  */
674
684
  get tree(): NodeWithIds {
675
685
  const text = self.data.tree
676
-
677
686
  return text
678
- ? generateNodeIds(
679
- text.startsWith('BioTreeContainer')
680
- ? flatToTree(parseAsn1(text))
681
- : parseNewick(
682
- text.startsWith('SEQ') ? parseEmfTree(text).tree : text,
683
- ),
684
- )
685
- : this.MSA?.getTree() || {
687
+ ? generateNodeIds(parseTreeText(text))
688
+ : (this.MSA?.getTree() ?? {
686
689
  noTree: true,
687
690
  children: [],
688
691
  id: 'empty',
689
692
  name: 'empty',
690
- }
693
+ })
691
694
  },
692
695
 
693
696
  /**
694
697
  * #getter
698
+ * Returns the list of row (sequence) names in display order.
699
+ * Part of the public API used by downstream consumers (e.g. jbrowse plugins).
695
700
  */
696
701
  get rowNames(): string[] {
697
702
  return this.leaves.map(n => n.data.name)
698
703
  },
704
+ /**
705
+ * #getter
706
+ */
707
+ get rowNamesSet() {
708
+ return new Map(
709
+ this.leaves.map((leaf, index) => [leaf.data.name, index] as const),
710
+ )
711
+ },
699
712
  /**
700
713
  * #getter
701
714
  */
702
715
  get mouseOverRowName() {
703
716
  const { mouseRow } = self
704
- return mouseRow === undefined ? undefined : this.rowNames[mouseRow]
717
+ return mouseRow === undefined
718
+ ? undefined
719
+ : this.leaves[mouseRow]?.data.name
705
720
  },
706
721
  /**
707
722
  * #getter
@@ -709,23 +724,15 @@ function stateModelFactory() {
709
724
  */
710
725
  get hoveredInsertion() {
711
726
  const { mouseCol, mouseRow } = self
712
- if (mouseCol === undefined || mouseRow === undefined) {
713
- return undefined
714
- }
715
- const rowName = this.rowNames[mouseRow]
716
- if (!rowName) {
717
- return undefined
718
- }
719
- const insertions = this.insertionPositions.get(rowName)
720
- if (!insertions) {
721
- return undefined
722
- }
723
- const insertion = insertions.find(ins => ins.pos === mouseCol)
724
- if (insertion) {
725
- return {
726
- rowName,
727
- col: mouseCol,
728
- 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
+ }
729
736
  }
730
737
  }
731
738
  return undefined
@@ -748,15 +755,14 @@ function stateModelFactory() {
748
755
 
749
756
  for (const collapsedId of self.collapsed) {
750
757
  const node = find(hier, n => n.data.id === collapsedId)
751
- if (!node) {
752
- continue
753
- }
754
- if (node.children) {
755
- collapse(node)
756
- } else if (node.parent?.children) {
757
- node.parent.children = node.parent.children.filter(
758
- c => c.data.id !== collapsedId,
759
- )
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
+ }
760
766
  }
761
767
  }
762
768
 
@@ -768,7 +774,7 @@ function stateModelFactory() {
768
774
  * widget width minus the tree area gives the space for the MSA
769
775
  */
770
776
  get msaAreaWidth() {
771
- return self.width - self.treeAreaWidth
777
+ return self.width - self.treeAreaWidth - self.resizeHandleWidth
772
778
  },
773
779
 
774
780
  /**
@@ -823,49 +829,13 @@ function stateModelFactory() {
823
829
  * Returns a map of row name to array of insertions with display position and letters
824
830
  */
825
831
  get insertionPositions() {
826
- const { hideGapsEffective } = self
827
832
  const { blanks, rows } = this
828
- const blanksLen = blanks.length
829
- if (blanksLen === 0 || !hideGapsEffective) {
833
+ if (blanks.length === 0 || !self.hideGapsEffective) {
830
834
  return new Map<string, { pos: number; letters: string }[]>()
831
835
  }
832
836
  const result = new Map<string, { pos: number; letters: string }[]>()
833
837
  for (const [name, seq] of rows) {
834
- const insertions: { pos: number; letters: string }[] = []
835
- let displayPos = 0
836
- let blankIdx = 0
837
- let currentInsertPos = -1
838
- let letterChars: string[] = []
839
- const seqLen = seq.length
840
- for (let i = 0; i < seqLen; i++) {
841
- if (blankIdx < blanksLen && blanks[blankIdx] === i) {
842
- // bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
843
- const code = seq.charCodeAt(i)
844
- if (!((code - 45) >>> 0 <= 1)) {
845
- if (currentInsertPos === displayPos) {
846
- letterChars.push(seq[i]!)
847
- } else {
848
- if (letterChars.length > 0) {
849
- insertions.push({
850
- pos: currentInsertPos,
851
- letters: letterChars.join(''),
852
- })
853
- }
854
- currentInsertPos = displayPos
855
- letterChars = [seq[i]!]
856
- }
857
- }
858
- blankIdx++
859
- } else {
860
- displayPos++
861
- }
862
- }
863
- if (letterChars.length > 0) {
864
- insertions.push({
865
- pos: currentInsertPos,
866
- letters: letterChars.join(''),
867
- })
868
- }
838
+ const insertions = computeRowInsertions(blanks, seq)
869
839
  if (insertions.length > 0) {
870
840
  result.set(name, insertions)
871
841
  }
@@ -898,10 +868,9 @@ function stateModelFactory() {
898
868
  * #getter
899
869
  */
900
870
  get columns() {
901
- return Object.fromEntries(
902
- this.rows.map(
903
- (row, index) => [row[0], this.columns2d[index]!] as const,
904
- ),
871
+ const columns2d = this.columns2d
872
+ return new Map(
873
+ this.rows.map((row, index) => [row[0], columns2d[index]!] as const),
905
874
  )
906
875
  },
907
876
  /**
@@ -909,9 +878,12 @@ function stateModelFactory() {
909
878
  */
910
879
  get columns2d() {
911
880
  const { hideGapsEffective } = self
912
- return this.rows
913
- .map(r => r[1])
914
- .map(str => (hideGapsEffective ? skipBlanks(this.blanks, str) : str))
881
+ return this.rows.map(([, str]) =>
882
+ (hideGapsEffective
883
+ ? skipBlanks(this.blanks, str)
884
+ : str
885
+ ).toUpperCase(),
886
+ )
915
887
  },
916
888
  /**
917
889
  * #getter
@@ -923,7 +895,7 @@ function stateModelFactory() {
923
895
  * #getter
924
896
  */
925
897
  get colStats() {
926
- const r = [] as Record<string, number>[]
898
+ const r: Record<string, number>[] = []
927
899
  const columns = this.columns2d
928
900
  for (const column of columns) {
929
901
  for (let j = 0; j < column.length; j++) {
@@ -939,7 +911,7 @@ function stateModelFactory() {
939
911
  * #getter
940
912
  */
941
913
  get colStatsSums() {
942
- return this.colStats.map(val => sum(Object.values(val)))
914
+ return this.colStats.map(col => sum(Object.values(col)))
943
915
  },
944
916
 
945
917
  /**
@@ -951,23 +923,18 @@ function stateModelFactory() {
951
923
  const letters = new Set<string>()
952
924
  for (const stats of this.colStats) {
953
925
  for (const letter of Object.keys(stats)) {
954
- const upper = letter.toUpperCase()
955
- if (upper !== '-' && upper !== '.') {
956
- letters.add(upper)
926
+ if (letter !== '-' && letter !== '.') {
927
+ letters.add(letter)
957
928
  }
958
929
  }
959
930
  }
931
+ // isDna already excludes U (not in the DNA set) and isRna excludes T,
932
+ // so the set membership alone disambiguates the two
960
933
  const dna = new Set(['A', 'C', 'G', 'T', 'N'])
961
934
  const rna = new Set(['A', 'C', 'G', 'U', 'N'])
962
- const isDna = [...letters].every(l => dna.has(l))
963
- const isRna = [...letters].every(l => rna.has(l))
964
- if (isDna && !letters.has('U')) {
965
- return 'dna'
966
- }
967
- if (isRna && !letters.has('T')) {
968
- return 'rna'
969
- }
970
- 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'
971
938
  },
972
939
 
973
940
  /**
@@ -981,8 +948,7 @@ function stateModelFactory() {
981
948
  const total = colStatsSums[i]!
982
949
  let maxCount = 0
983
950
  let letter = ''
984
- for (const key in stats) {
985
- const val = stats[key]!
951
+ for (const [key, val] of Object.entries(stats)) {
986
952
  if (val > maxCount && key !== '-' && key !== '.') {
987
953
  maxCount = val
988
954
  letter = key
@@ -1029,25 +995,16 @@ function stateModelFactory() {
1029
995
  return 0
1030
996
  }
1031
997
 
1032
- const gapCount = (stats['-'] || 0) + (stats['.'] || 0)
998
+ const gapCount = (stats['-'] ?? 0) + (stats['.'] ?? 0)
1033
999
  const nonGapTotal = total - gapCount
1034
1000
  if (nonGapTotal === 0) {
1035
1001
  return 0
1036
1002
  }
1037
1003
 
1038
- const merged: Record<string, number> = {}
1039
- for (const letter of Object.keys(stats)) {
1040
- if (letter === '-' || letter === '.') {
1041
- continue
1042
- }
1043
- const upper = letter.toUpperCase()
1044
- merged[upper] = (merged[upper] || 0) + stats[letter]!
1045
- }
1046
-
1047
1004
  let entropy = 0
1048
- for (const key of Object.keys(merged)) {
1049
- const freq = merged[key]! / nonGapTotal
1050
- if (freq > 0) {
1005
+ for (const [letter, count] of Object.entries(stats)) {
1006
+ if (letter !== '-' && letter !== '.') {
1007
+ const freq = count / nonGapTotal
1051
1008
  entropy -= freq * Math.log2(freq)
1052
1009
  }
1053
1010
  }
@@ -1065,7 +1022,8 @@ function stateModelFactory() {
1065
1022
  const r = this.root
1066
1023
  clusterLayout(r, this.totalHeight, self.treeWidth)
1067
1024
  r.data.length = 0
1068
- setBrLength(r, 0, self.treeWidth / maxLength(r))
1025
+ const max = maxLength(r)
1026
+ setBrLength(r, 0, max ? self.treeWidth / max : 0)
1069
1027
  return r as HierarchyNode<NodeWithIdsAndLength>
1070
1028
  },
1071
1029
 
@@ -1128,28 +1086,28 @@ function stateModelFactory() {
1128
1086
  * #getter
1129
1087
  */
1130
1088
  get dataInitialized() {
1131
- return (self.data.msa || self.data.tree) && !self.error
1089
+ return (self.data.msa !== '' || self.data.tree !== '') && !self.error
1132
1090
  },
1133
1091
  /**
1134
1092
  * #getter
1135
1093
  */
1136
1094
  get blocksX() {
1137
- return blocksX({
1138
- viewportWidth: self.msaAreaWidth,
1139
- viewportX: -self.scrollX,
1095
+ return calculateBlocks({
1096
+ viewportSize: self.msaAreaWidth,
1097
+ viewportPos: -self.scrollX,
1140
1098
  blockSize: self.blockSize,
1141
- mapWidth: self.totalWidth,
1099
+ mapSize: self.totalWidth,
1142
1100
  })
1143
1101
  },
1144
1102
  /**
1145
1103
  * #getter
1146
1104
  */
1147
1105
  get blocksY() {
1148
- return blocksY({
1149
- viewportHeight: self.height,
1150
- viewportY: -self.scrollY,
1106
+ return calculateBlocks({
1107
+ viewportSize: self.height,
1108
+ viewportPos: -self.scrollY,
1151
1109
  blockSize: self.blockSize,
1152
- mapHeight: self.totalHeight,
1110
+ mapSize: self.totalHeight,
1153
1111
  })
1154
1112
  },
1155
1113
  }))
@@ -1175,6 +1133,19 @@ function stateModelFactory() {
1175
1133
  get maxScrollX() {
1176
1134
  return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0)
1177
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
+ },
1178
1149
  /**
1179
1150
  * #getter
1180
1151
  */
@@ -1200,6 +1171,31 @@ function stateModelFactory() {
1200
1171
  self.drawMsaLetters = arg
1201
1172
  },
1202
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
+
1203
1199
  /**
1204
1200
  * #action
1205
1201
  * Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
@@ -1223,35 +1219,41 @@ function stateModelFactory() {
1223
1219
  * #action
1224
1220
  */
1225
1221
  zoomOutHorizontal() {
1226
- self.colWidth = Math.max(1, Math.floor(self.colWidth * 0.75))
1222
+ self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75))
1227
1223
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1228
1224
  },
1229
1225
  /**
1230
1226
  * #action
1231
1227
  */
1232
1228
  zoomInHorizontal() {
1233
- self.colWidth = Math.ceil(self.colWidth * 1.5)
1229
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5))
1234
1230
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1235
1231
  },
1236
1232
  /**
1237
1233
  * #action
1238
1234
  */
1239
1235
  zoomInVertical() {
1240
- self.rowHeight = Math.ceil(self.rowHeight * 1.5)
1236
+ self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5))
1241
1237
  },
1242
1238
  /**
1243
1239
  * #action
1244
1240
  */
1245
1241
  zoomOutVertical() {
1246
- 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
+ )
1247
1246
  },
1248
1247
  /**
1249
1248
  * #action
1250
1249
  */
1251
1250
  zoomIn() {
1252
1251
  transaction(() => {
1253
- self.colWidth = Math.ceil(self.colWidth * 1.5)
1254
- 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
+ )
1255
1257
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1256
1258
  })
1257
1259
  },
@@ -1260,27 +1262,79 @@ function stateModelFactory() {
1260
1262
  */
1261
1263
  zoomOut() {
1262
1264
  transaction(() => {
1263
- self.colWidth = Math.max(1, Math.floor(self.colWidth * 0.75))
1264
- 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
+ )
1265
1273
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0)
1266
1274
  })
1267
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
+ },
1268
1317
  /**
1269
1318
  * #action
1270
1319
  */
1271
1320
  doScrollY(deltaY: number) {
1272
- self.scrollY = clamp(self.scrollY + deltaY, -self.totalHeight + 10, 0)
1321
+ self.scrollY = clamp(self.scrollY + deltaY, self.maxScrollY, 0)
1273
1322
  },
1274
1323
 
1275
- 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>) {
1276
1331
  self.interProAnnotations = data
1332
+ self.setShowDomains(!!data)
1277
1333
  },
1278
1334
 
1279
1335
  applyGFFText(gffText: string) {
1280
1336
  const gffRecords = parseGFF(gffText)
1281
- const interProResults = gffToInterProResults(gffRecords)
1282
- self.interProAnnotations = interProResults
1283
- self.setShowDomains(true)
1337
+ this.setDomains(gffToInterProResults(gffRecords))
1284
1338
  },
1285
1339
 
1286
1340
  /**
@@ -1318,22 +1372,22 @@ function stateModelFactory() {
1318
1372
  /**
1319
1373
  * #getter
1320
1374
  */
1321
- get labelsWidth() {
1375
+ get labelWidthMap() {
1322
1376
  const { rowHeight, leaves, treeMetadata, fontSize } = self
1323
- if (rowHeight <= 5) {
1324
- return 0
1325
- }
1326
- return leaves.reduce(
1327
- (max, node) =>
1328
- Math.max(
1329
- max,
1330
- measureTextCanvas(
1331
- treeMetadata[node.data.name]?.genome || node.data.name,
1332
- fontSize,
1333
- ),
1334
- ),
1335
- 0,
1336
- )
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
+ )
1386
+ },
1387
+
1388
+ get labelsWidth() {
1389
+ const widths = this.labelWidthMap
1390
+ return widths.size === 0 ? 0 : Math.max(...widths.values())
1337
1391
  },
1338
1392
 
1339
1393
  /**
@@ -1355,18 +1409,13 @@ function stateModelFactory() {
1355
1409
  */
1356
1410
  get adapterTrackModels(): BasicTrack[] {
1357
1411
  const { rowHeight, MSA, hideGapsEffective, blanks } = self
1358
- const tracks = (MSA?.tracks ?? []) as (TextTrackModel & {
1359
- data?: string
1360
- })[]
1412
+ const tracks = MSA?.tracks ?? []
1361
1413
  return tracks
1362
1414
  .filter(t => !!t.data)
1363
1415
  .map(t => ({
1364
1416
  model: {
1365
1417
  ...t,
1366
- data:
1367
- hideGapsEffective && t.data
1368
- ? skipBlanks(blanks, t.data)
1369
- : t.data,
1418
+ data: hideGapsEffective ? skipBlanks(blanks, t.data!) : t.data,
1370
1419
  height: rowHeight,
1371
1420
  },
1372
1421
  ReactComponent: TextTrack,
@@ -1402,13 +1451,6 @@ function stateModelFactory() {
1402
1451
  return self.msaAreaWidth < self.totalWidth
1403
1452
  },
1404
1453
 
1405
- /**
1406
- * #getter
1407
- */
1408
- get rowNamesSet() {
1409
- return new Map(self.rowNames.map((r, idx) => [r, idx]))
1410
- },
1411
-
1412
1454
  /**
1413
1455
  * #method
1414
1456
  * Return a row-specific letter at a visible column, or undefined if gap.
@@ -1427,6 +1469,11 @@ function stateModelFactory() {
1427
1469
  * Convert a visible column to a row-specific sequence position (0-based).
1428
1470
  * Returns undefined if the position is a gap in the sequence.
1429
1471
  *
1472
+ * CROSS-REPO CONTRACT: this and the sibling coordinate converters
1473
+ * (seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) are used
1474
+ * by jbrowse-plugin-protein3d to translate between alignment columns and
1475
+ * structure/sequence residue positions across gaps. Keep them stable.
1476
+ *
1430
1477
  * @param rowName - The name of the row
1431
1478
  * @param visibleCol - The visible column index
1432
1479
  * @returns The sequence position (0-based), or undefined if it's a gap
@@ -1480,14 +1527,8 @@ function stateModelFactory() {
1480
1527
  * @returns The global column index in the full MSA
1481
1528
  */
1482
1529
  seqPosToGlobalCol(rowName: string, seqPos: number) {
1483
- const { rows } = self
1484
- const index = this.rowNamesSet.get(rowName)
1485
- return index !== undefined && rows[index]
1486
- ? seqPosToGlobalCol({
1487
- row: rows[index][1],
1488
- seqPos,
1489
- })
1490
- : 0
1530
+ const seq = self.rowMap.get(rowName)
1531
+ return seq ? seqPosToGlobalCol({ row: seq, seqPos }) : 0
1491
1532
  },
1492
1533
 
1493
1534
  /**
@@ -1506,30 +1547,6 @@ function stateModelFactory() {
1506
1547
  }))
1507
1548
 
1508
1549
  .views(self => ({
1509
- /**
1510
- * #getter
1511
- * Returns information about the currently hovered cell
1512
- */
1513
- get hoveredCell() {
1514
- const { mouseCol, mouseRow } = self
1515
- if (mouseCol === undefined || mouseRow === undefined) {
1516
- return undefined
1517
- }
1518
- const rowName = self.rowNames[mouseRow]
1519
- if (!rowName) {
1520
- return undefined
1521
- }
1522
- const seq = self.columns[rowName]
1523
- const base = seq?.[mouseCol]
1524
- const seqPos = self.visibleColToSeqPosOneBased(rowName, mouseCol)
1525
- return {
1526
- rowName,
1527
- col: mouseCol,
1528
- base,
1529
- seqPos,
1530
- }
1531
- },
1532
-
1533
1550
  /**
1534
1551
  * #getter
1535
1552
  * widget width minus the tree area gives the space for the MSA
@@ -1554,26 +1571,25 @@ function stateModelFactory() {
1554
1571
  )
1555
1572
  },
1556
1573
  get tidyInterProAnnotations() {
1557
- const { interProAnnotations } = self
1558
- if (!interProAnnotations) {
1559
- return []
1560
- }
1561
- return Object.entries(interProAnnotations)
1562
- .flatMap(([id, val]) =>
1563
- val.matches.flatMap(({ signature, locations }) =>
1564
- signature.entry
1565
- ? locations.map(({ start, end }) => ({
1566
- id,
1567
- name: signature.entry!.name,
1568
- accession: signature.entry!.accession,
1569
- description: signature.entry!.description,
1570
- start,
1571
- end,
1572
- }))
1573
- : [],
1574
- ),
1575
- )
1576
- .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
+ : []
1577
1593
  },
1578
1594
  get tidyFilteredInterProAnnotations() {
1579
1595
  return this.tidyInterProAnnotations.filter(r =>
@@ -1603,12 +1619,10 @@ function stateModelFactory() {
1603
1619
  return createPaletteMap([...self.tidyInterProAnnotationTypes.keys()])
1604
1620
  },
1605
1621
  get strokePalette() {
1606
- return Object.fromEntries(
1607
- Object.entries(this.fillPalette).map(([key, val]) => [
1608
- key,
1609
- colord(val).darken(0.1).toHex(),
1610
- ]),
1611
- )
1622
+ return transform(this.fillPalette, ([key, val]) => [
1623
+ key,
1624
+ colord(val).darken(0.1).toHex(),
1625
+ ])
1612
1626
  },
1613
1627
 
1614
1628
  /**
@@ -1650,8 +1664,7 @@ function stateModelFactory() {
1650
1664
  self.setTreeFilehandle(undefined)
1651
1665
  self.setMSAFilehandle(undefined)
1652
1666
  self.setGFFFilehandle(undefined)
1653
- self.setInterProAnnotations({})
1654
- self.setShowDomains(false)
1667
+ self.setDomains(undefined)
1655
1668
  },
1656
1669
  /**
1657
1670
  * #action
@@ -1660,21 +1673,13 @@ function stateModelFactory() {
1660
1673
  theme: Theme
1661
1674
  includeMinimap?: boolean
1662
1675
  includeTracks?: boolean
1663
- exportType: string
1676
+ exportType: 'entire' | 'viewport'
1664
1677
  }) {
1665
1678
  const { renderToSvg } = await import('./renderToSvg.tsx')
1666
1679
  const html = await renderToSvg(self as MsaViewModel, opts)
1667
1680
  const blob = new Blob([html], { type: 'image/svg+xml' })
1668
1681
  saveAs(blob, 'image.svg')
1669
1682
  },
1670
- /**
1671
- * #action
1672
- * internal, used for drawing to canvas
1673
- */
1674
- incrementRef() {
1675
- self.nref++
1676
- },
1677
-
1678
1683
  initFilter(arg: string) {
1679
1684
  if (!self.featureFilters.has(arg)) {
1680
1685
  self.featureFilters.set(arg, true)
@@ -1688,8 +1693,20 @@ function stateModelFactory() {
1688
1693
  * #action
1689
1694
  */
1690
1695
  fit() {
1691
- self.rowHeight = self.msaAreaHeight / self.numRows
1692
- 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
+ }
1693
1710
  self.scrollX = 0
1694
1711
  self.scrollY = 0
1695
1712
  },
@@ -1697,14 +1714,26 @@ function stateModelFactory() {
1697
1714
  * #action
1698
1715
  */
1699
1716
  fitVertically() {
1700
- 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
+ }
1701
1724
  self.scrollY = 0
1702
1725
  },
1703
1726
  /**
1704
1727
  * #action
1705
1728
  */
1706
1729
  fitHorizontally() {
1707
- 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
+ }
1708
1737
  self.scrollX = 0
1709
1738
  },
1710
1739
 
@@ -1718,26 +1747,39 @@ function stateModelFactory() {
1718
1747
  }),
1719
1748
  )
1720
1749
 
1721
- // 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
1722
1754
  addDisposer(
1723
1755
  self,
1724
1756
  autorun(async () => {
1725
1757
  const { treeFilehandle } = self
1726
1758
  if (treeFilehandle) {
1759
+ const generation = ++treeGeneration
1727
1760
  try {
1728
1761
  self.setLoadingTree(true)
1729
- self.setTree(
1730
- await fetchAndMaybeUnzipText(openLocation(treeFilehandle)),
1762
+ const text = await fetchAndMaybeUnzipText(
1763
+ openLocation(treeFilehandle),
1731
1764
  )
1732
- if (treeFilehandle.locationType === 'BlobLocation') {
1733
- // clear filehandle after loading if from a local file
1734
- 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
+ })
1735
1773
  }
1736
1774
  } catch (e) {
1737
- console.error(e)
1738
- self.setError(e)
1775
+ if (generation === treeGeneration) {
1776
+ console.error(e)
1777
+ self.setError(e)
1778
+ }
1739
1779
  } finally {
1740
- self.setLoadingTree(false)
1780
+ if (generation === treeGeneration) {
1781
+ self.setLoadingTree(false)
1782
+ }
1741
1783
  }
1742
1784
  }
1743
1785
  }),
@@ -1800,36 +1842,47 @@ function stateModelFactory() {
1800
1842
  }),
1801
1843
  )
1802
1844
 
1803
- // autorun opens msaFilehandle
1845
+ // autorun opens msaFilehandle. generation guard: see treeFilehandle
1846
+ let msaGeneration = 0
1804
1847
  addDisposer(
1805
1848
  self,
1806
1849
  autorun(async () => {
1807
1850
  const { msaFilehandle } = self
1808
1851
  if (msaFilehandle) {
1852
+ const generation = ++msaGeneration
1809
1853
  try {
1810
1854
  self.setLoadingMSA(true)
1811
1855
  self.setError(undefined)
1812
1856
  const txt = await fetchAndMaybeUnzipText(
1813
1857
  openLocation(msaFilehandle),
1814
1858
  )
1815
- transaction(() => {
1816
- self.setMSA(txt)
1817
- if (msaFilehandle.locationType === 'BlobLocation') {
1818
- // clear filehandle after loading if from a local file
1819
- self.setMSAFilehandle(undefined)
1820
- }
1821
- })
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
+ }
1822
1868
  } catch (e) {
1823
- console.error(e)
1824
- self.setError(e)
1869
+ if (generation === msaGeneration) {
1870
+ console.error(e)
1871
+ self.setError(e)
1872
+ }
1825
1873
  } finally {
1826
- self.setLoadingMSA(false)
1874
+ if (generation === msaGeneration) {
1875
+ self.setLoadingMSA(false)
1876
+ }
1827
1877
  }
1828
1878
  }
1829
1879
  }),
1830
1880
  )
1831
1881
 
1832
- // 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.
1833
1886
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
1834
1887
  // xref problem https://github.com/GMOD/react-msaview/issues/75
1835
1888
  addDisposer(