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/dist/model.js CHANGED
@@ -10,7 +10,7 @@ import { clustalXColumnColors } from "./clustalX.js";
10
10
  import colorSchemes from "./colorSchemes.js";
11
11
  import ConservationTrack from "./components/ConservationTrack.js";
12
12
  import TextTrack from "./components/TextTrack.js";
13
- import { defaultAllowedGappyness, defaultBgColor, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawLabels, defaultDrawMsaLetters, defaultDrawNodeBubbles, defaultDrawTree, defaultHeight, defaultHideGaps, defaultLabelsAlignRight, defaultRowHeight, defaultScrollX, defaultScrollY, defaultShowBranchLen, defaultShowDomains, defaultSubFeatureRows, defaultTreeAreaWidth, defaultTreeWidth, } from "./constants.js";
13
+ import { defaultAllowedGappyness, defaultBgColor, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawLabels, defaultDrawMsaLetters, defaultDrawNodeBubbles, defaultDrawTree, defaultHeight, defaultHideGaps, defaultLabelsAlignRight, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultShowBranchLen, defaultShowDomains, defaultSubFeatureRows, defaultTreeAreaWidth, defaultTreeWidth, maxCellSize, minColWidth, minRowHeight, } from "./constants.js";
14
14
  import { createPaletteMap } from "./createPaletteMap.js";
15
15
  import { flatToTree } from "./flatToTree.js";
16
16
  import { calcDepthToLeaf, clusterLayout, collapse, find, findMaxBranchLen, hierarchy, leaves, links, maxLength, setBrLength, sort, sum as hierarchySum, } from "./hierarchy.js";
@@ -23,14 +23,25 @@ import { calculateNeighborJoiningTree } from "./neighborJoining.js";
23
23
  import { parseAsn1 } from "./parseAsn1.js";
24
24
  import { globalColToVisibleCol, visibleColToGlobalCol, visibleColToSeqPosForRow, } from "./rowCoordinateCalculations.js";
25
25
  import { seqPosToGlobalCol } from "./seqPosToGlobalCol.js";
26
- import { len, skipBlanks } from "./util.js";
26
+ import { computeRowInsertions, len, skipBlanks, transform } from "./util.js";
27
27
  import { saveAs } from "./vendor/fileSaver.js";
28
+ function parseTreeText(text) {
29
+ if (text.startsWith('BioTreeContainer')) {
30
+ return flatToTree(parseAsn1(text));
31
+ }
32
+ return parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text);
33
+ }
28
34
  /**
29
35
  * #stateModel MsaView
30
- * extends
31
- * - [DialogQueueSessionMixin](../dialogqueuesessionmixin)
32
- * - [MSAModel](../msamodel)
33
- * - [Tree](../tree)
36
+ * #example
37
+ * ```js
38
+ * import { MSAModelF } from 'react-msaview'
39
+ * import { types } from '@jbrowse/mobx-state-tree'
40
+ *
41
+ * const RootModel = types.model({ view: types.optional(MSAModelF(), {}) })
42
+ * const root = RootModel.create({})
43
+ * root.view.setData({ msa: '>seq1\nACGT\n>seq2\nACGT' })
44
+ * ```
34
45
  */
35
46
  function stateModelFactory() {
36
47
  return types
@@ -65,6 +76,11 @@ function stateModelFactory() {
65
76
  * #property
66
77
  */
67
78
  drawMsaLetters: defaultDrawMsaLetters,
79
+ /**
80
+ * #property
81
+ * zoom in/out on plain mouse-wheel without holding ctrl
82
+ */
83
+ scrollZoom: defaultScrollZoom,
68
84
  /**
69
85
  * #property
70
86
  * height of the div containing the view, px
@@ -162,9 +178,10 @@ function stateModelFactory() {
162
178
  /**
163
179
  * #volatile
164
180
  * high resolution scale factor, helps make canvas look better on hi-dpi
165
- * screens
181
+ * screens. derived from the device pixel ratio so canvases are crisp on
182
+ * retina/4k displays and not needlessly oversized on standard ones
166
183
  */
167
- highResScaleFactor: 2,
184
+ highResScaleFactor: typeof window === 'undefined' ? 1 : window.devicePixelRatio,
168
185
  /**
169
186
  * #volatile
170
187
  */
@@ -276,6 +293,14 @@ function stateModelFactory() {
276
293
  setWidth(arg) {
277
294
  self.volatileWidth = arg;
278
295
  },
296
+ /**
297
+ * #action
298
+ * high-res scale factor, tracks the device pixel ratio so canvases stay
299
+ * crisp when the window moves between monitors or the browser zooms
300
+ */
301
+ setHighResScaleFactor(arg) {
302
+ self.highResScaleFactor = arg;
303
+ },
279
304
  /**
280
305
  * #action
281
306
  * set the height of the view in px
@@ -302,23 +327,6 @@ function stateModelFactory() {
302
327
  self.mouseCol = col;
303
328
  self.mouseRow = row;
304
329
  },
305
- /**
306
- * #action
307
- * set hovered tree node and its descendants
308
- */
309
- setHoveredTreeNode(nodeId) {
310
- if (!nodeId) {
311
- self.hoveredTreeNode = undefined;
312
- return;
313
- }
314
- const node = find(self.hierarchy, n => n.data.id === nodeId);
315
- if (!node) {
316
- self.hoveredTreeNode = undefined;
317
- return;
318
- }
319
- const descendantNames = leaves(node).map(leaf => leaf.data.name);
320
- self.hoveredTreeNode = { nodeId, descendantNames };
321
- },
322
330
  /**
323
331
  * #action
324
332
  * set highlighted columns
@@ -484,13 +492,16 @@ function stateModelFactory() {
484
492
  * #getter
485
493
  */
486
494
  get colorScheme() {
487
- return colorSchemes[self.colorSchemeName];
495
+ // colorSchemeName is a free string (menus, snapshots, URL params); fall
496
+ // back to the default rather than returning undefined on a stale name
497
+ return (colorSchemes[self.colorSchemeName] ??
498
+ colorSchemes[defaultColorSchemeName]);
488
499
  },
489
500
  /**
490
501
  * #getter
491
502
  */
492
503
  get header() {
493
- return (this.MSA?.getHeader() ?? {});
504
+ return this.MSA?.getHeader() ?? {};
494
505
  },
495
506
  /**
496
507
  * #getter
@@ -537,19 +548,14 @@ function stateModelFactory() {
537
548
  get tree() {
538
549
  const text = self.data.tree;
539
550
  return text
540
- ? generateNodeIds(text.startsWith('BioTreeContainer')
541
- ? flatToTree(parseAsn1(text))
542
- : parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text))
543
- : this.MSA?.getTree() || {
551
+ ? generateNodeIds(parseTreeText(text))
552
+ : (this.MSA?.getTree() ?? {
544
553
  noTree: true,
545
554
  children: [],
546
555
  id: 'empty',
547
556
  name: 'empty',
548
- };
557
+ });
549
558
  },
550
- /**
551
- * #getter
552
- */
553
559
  /**
554
560
  * #getter
555
561
  * Returns the list of row (sequence) names in display order.
@@ -562,11 +568,7 @@ function stateModelFactory() {
562
568
  * #getter
563
569
  */
564
570
  get rowNamesSet() {
565
- const map = new Map();
566
- this.leaves.forEach((leaf, index) => {
567
- map.set(leaf.data.name, index);
568
- });
569
- return map;
571
+ return new Map(this.leaves.map((leaf, index) => [leaf.data.name, index]));
570
572
  },
571
573
  /**
572
574
  * #getter
@@ -583,24 +585,16 @@ function stateModelFactory() {
583
585
  */
584
586
  get hoveredInsertion() {
585
587
  const { mouseCol, mouseRow } = self;
586
- if (mouseCol === undefined || mouseRow === undefined) {
587
- return undefined;
588
- }
589
- const rowName = this.leaves[mouseRow]?.data.name;
590
- if (!rowName) {
591
- return undefined;
592
- }
593
- const insertions = this.insertionPositions.get(rowName);
594
- if (!insertions) {
595
- return undefined;
596
- }
597
- const insertion = insertions.find(ins => ins.pos === mouseCol);
598
- if (insertion) {
599
- return {
600
- rowName,
601
- col: mouseCol,
602
- letters: insertion.letters,
603
- };
588
+ if (mouseCol !== undefined && mouseRow !== undefined) {
589
+ const rowName = this.leaves[mouseRow]?.data.name;
590
+ if (rowName) {
591
+ const insertion = this.insertionPositions
592
+ .get(rowName)
593
+ ?.find(ins => ins.pos === mouseCol);
594
+ if (insertion) {
595
+ return { rowName, col: mouseCol, letters: insertion.letters };
596
+ }
597
+ }
604
598
  }
605
599
  return undefined;
606
600
  },
@@ -619,14 +613,13 @@ function stateModelFactory() {
619
613
  }
620
614
  for (const collapsedId of self.collapsed) {
621
615
  const node = find(hier, n => n.data.id === collapsedId);
622
- if (!node) {
623
- continue;
624
- }
625
- if (node.children) {
626
- collapse(node);
627
- }
628
- else if (node.parent?.children) {
629
- node.parent.children = node.parent.children.filter(c => c.data.id !== collapsedId);
616
+ if (node) {
617
+ if (node.children) {
618
+ collapse(node);
619
+ }
620
+ else if (node.parent?.children) {
621
+ node.parent.children = node.parent.children.filter(c => c.data.id !== collapsedId);
622
+ }
630
623
  }
631
624
  }
632
625
  return hier;
@@ -636,7 +629,7 @@ function stateModelFactory() {
636
629
  * widget width minus the tree area gives the space for the MSA
637
630
  */
638
631
  get msaAreaWidth() {
639
- return self.width - self.treeAreaWidth;
632
+ return self.width - self.treeAreaWidth - self.resizeHandleWidth;
640
633
  },
641
634
  /**
642
635
  * #getter
@@ -690,51 +683,13 @@ function stateModelFactory() {
690
683
  * Returns a map of row name to array of insertions with display position and letters
691
684
  */
692
685
  get insertionPositions() {
693
- const { hideGapsEffective } = self;
694
686
  const { blanks, rows } = this;
695
- const blanksLen = blanks.length;
696
- if (blanksLen === 0 || !hideGapsEffective) {
687
+ if (blanks.length === 0 || !self.hideGapsEffective) {
697
688
  return new Map();
698
689
  }
699
690
  const result = new Map();
700
691
  for (const [name, seq] of rows) {
701
- const insertions = [];
702
- let displayPos = 0;
703
- let blankIdx = 0;
704
- let currentInsertPos = -1;
705
- let letterChars = [];
706
- const seqLen = seq.length;
707
- for (let i = 0; i < seqLen; i++) {
708
- if (blankIdx < blanksLen && blanks[blankIdx] === i) {
709
- // bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
710
- const code = seq.charCodeAt(i);
711
- if (!((code - 45) >>> 0 <= 1)) {
712
- if (currentInsertPos === displayPos) {
713
- letterChars.push(seq[i]);
714
- }
715
- else {
716
- if (letterChars.length > 0) {
717
- insertions.push({
718
- pos: currentInsertPos,
719
- letters: letterChars.join(''),
720
- });
721
- }
722
- currentInsertPos = displayPos;
723
- letterChars = [seq[i]];
724
- }
725
- }
726
- blankIdx++;
727
- }
728
- else {
729
- displayPos++;
730
- }
731
- }
732
- if (letterChars.length > 0) {
733
- insertions.push({
734
- pos: currentInsertPos,
735
- letters: letterChars.join(''),
736
- });
737
- }
692
+ const insertions = computeRowInsertions(blanks, seq);
738
693
  if (insertions.length > 0) {
739
694
  result.set(name, insertions);
740
695
  }
@@ -766,16 +721,15 @@ function stateModelFactory() {
766
721
  * #getter
767
722
  */
768
723
  get columns() {
769
- return new Map(this.rows.map((row, index) => [row[0], this.columns2d[index]]));
724
+ const columns2d = this.columns2d;
725
+ return new Map(this.rows.map((row, index) => [row[0], columns2d[index]]));
770
726
  },
771
727
  /**
772
728
  * #getter
773
729
  */
774
730
  get columns2d() {
775
731
  const { hideGapsEffective } = self;
776
- return this.rows
777
- .map(r => r[1])
778
- .map(str => (hideGapsEffective
732
+ return this.rows.map(([, str]) => (hideGapsEffective
779
733
  ? skipBlanks(this.blanks, str)
780
734
  : str).toUpperCase());
781
735
  },
@@ -804,13 +758,7 @@ function stateModelFactory() {
804
758
  * #getter
805
759
  */
806
760
  get colStatsSums() {
807
- return this.colStats.map(col => {
808
- let s = 0;
809
- for (const k in col) {
810
- s += col[k];
811
- }
812
- return s;
813
- });
761
+ return this.colStats.map(col => sum(Object.values(col)));
814
762
  },
815
763
  /**
816
764
  * #getter
@@ -826,17 +774,13 @@ function stateModelFactory() {
826
774
  }
827
775
  }
828
776
  }
777
+ // isDna already excludes U (not in the DNA set) and isRna excludes T,
778
+ // so the set membership alone disambiguates the two
829
779
  const dna = new Set(['A', 'C', 'G', 'T', 'N']);
830
780
  const rna = new Set(['A', 'C', 'G', 'U', 'N']);
831
- const isDna = [...letters].every(l => dna.has(l));
832
- const isRna = [...letters].every(l => rna.has(l));
833
- if (isDna && !letters.has('U')) {
834
- return 'dna';
835
- }
836
- if (isRna && !letters.has('T')) {
837
- return 'rna';
838
- }
839
- return 'amino';
781
+ const isDna = letters.size > 0 && [...letters].every(l => dna.has(l));
782
+ const isRna = letters.size > 0 && [...letters].every(l => rna.has(l));
783
+ return isDna ? 'dna' : isRna ? 'rna' : 'amino';
840
784
  },
841
785
  /**
842
786
  * #getter
@@ -849,8 +793,7 @@ function stateModelFactory() {
849
793
  const total = colStatsSums[i];
850
794
  let maxCount = 0;
851
795
  let letter = '';
852
- for (const key in stats) {
853
- const val = stats[key];
796
+ for (const [key, val] of Object.entries(stats)) {
854
797
  if (val > maxCount && key !== '-' && key !== '.') {
855
798
  maxCount = val;
856
799
  letter = key;
@@ -896,12 +839,9 @@ function stateModelFactory() {
896
839
  return 0;
897
840
  }
898
841
  let entropy = 0;
899
- for (const letter of Object.keys(stats)) {
900
- if (letter === '-' || letter === '.') {
901
- continue;
902
- }
903
- const freq = stats[letter] / nonGapTotal;
904
- if (freq > 0) {
842
+ for (const [letter, count] of Object.entries(stats)) {
843
+ if (letter !== '-' && letter !== '.') {
844
+ const freq = count / nonGapTotal;
905
845
  entropy -= freq * Math.log2(freq);
906
846
  }
907
847
  }
@@ -918,7 +858,8 @@ function stateModelFactory() {
918
858
  const r = this.root;
919
859
  clusterLayout(r, this.totalHeight, self.treeWidth);
920
860
  r.data.length = 0;
921
- setBrLength(r, 0, self.treeWidth / maxLength(r));
861
+ const max = maxLength(r);
862
+ setBrLength(r, 0, max ? self.treeWidth / max : 0);
922
863
  return r;
923
864
  },
924
865
  /**
@@ -974,7 +915,7 @@ function stateModelFactory() {
974
915
  * #getter
975
916
  */
976
917
  get dataInitialized() {
977
- return (self.data.msa || self.data.tree) && !self.error;
918
+ return (self.data.msa !== '' || self.data.tree !== '') && !self.error;
978
919
  },
979
920
  /**
980
921
  * #getter
@@ -1018,6 +959,18 @@ function stateModelFactory() {
1018
959
  get maxScrollX() {
1019
960
  return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0);
1020
961
  },
962
+ /**
963
+ * #getter
964
+ * most-negative allowed scrollY, keeping the last row in view rather than
965
+ * letting the whole alignment scroll off the top. visible MSA height is
966
+ * computed inline here because msaAreaHeight is defined later in the chain.
967
+ */
968
+ get maxScrollY() {
969
+ const visibleHeight = self.height -
970
+ self.headerHeight -
971
+ (self.msaAreaWidth < self.totalWidth ? self.minimapHeight : 0);
972
+ return Math.min(-self.totalHeight + visibleHeight, 0);
973
+ },
1021
974
  /**
1022
975
  * #getter
1023
976
  */
@@ -1040,6 +993,30 @@ function stateModelFactory() {
1040
993
  setDrawMsaLetters(arg) {
1041
994
  self.drawMsaLetters = arg;
1042
995
  },
996
+ /**
997
+ * #action
998
+ */
999
+ setScrollZoom(arg) {
1000
+ self.scrollZoom = arg;
1001
+ },
1002
+ /**
1003
+ * #action
1004
+ * set hovered tree node and its descendants
1005
+ */
1006
+ setHoveredTreeNode(nodeId) {
1007
+ if (nodeId) {
1008
+ const node = find(self.hierarchy, n => n.data.id === nodeId);
1009
+ self.hoveredTreeNode = node
1010
+ ? {
1011
+ nodeId,
1012
+ descendantNames: leaves(node).map(leaf => leaf.data.name),
1013
+ }
1014
+ : undefined;
1015
+ }
1016
+ else {
1017
+ self.hoveredTreeNode = undefined;
1018
+ }
1019
+ },
1043
1020
  /**
1044
1021
  * #action
1045
1022
  * Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
@@ -1062,35 +1039,35 @@ function stateModelFactory() {
1062
1039
  * #action
1063
1040
  */
1064
1041
  zoomOutHorizontal() {
1065
- self.colWidth = Math.max(1, Math.floor(self.colWidth * 0.75));
1042
+ self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75));
1066
1043
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1067
1044
  },
1068
1045
  /**
1069
1046
  * #action
1070
1047
  */
1071
1048
  zoomInHorizontal() {
1072
- self.colWidth = Math.ceil(self.colWidth * 1.5);
1049
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5));
1073
1050
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1074
1051
  },
1075
1052
  /**
1076
1053
  * #action
1077
1054
  */
1078
1055
  zoomInVertical() {
1079
- self.rowHeight = Math.ceil(self.rowHeight * 1.5);
1056
+ self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5));
1080
1057
  },
1081
1058
  /**
1082
1059
  * #action
1083
1060
  */
1084
1061
  zoomOutVertical() {
1085
- self.rowHeight = Math.max(1.5, Math.floor(self.rowHeight * 0.75));
1062
+ self.rowHeight = Math.max(minRowHeight, Math.floor(self.rowHeight * 0.75));
1086
1063
  },
1087
1064
  /**
1088
1065
  * #action
1089
1066
  */
1090
1067
  zoomIn() {
1091
1068
  transaction(() => {
1092
- self.colWidth = Math.ceil(self.colWidth * 1.5);
1093
- self.rowHeight = Math.ceil(self.rowHeight * 1.5);
1069
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5));
1070
+ self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5));
1094
1071
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1095
1072
  });
1096
1073
  },
@@ -1099,25 +1076,53 @@ function stateModelFactory() {
1099
1076
  */
1100
1077
  zoomOut() {
1101
1078
  transaction(() => {
1102
- self.colWidth = Math.max(1, Math.floor(self.colWidth * 0.75));
1103
- self.rowHeight = Math.max(1.5, Math.floor(self.rowHeight * 0.75));
1079
+ self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75));
1080
+ self.rowHeight = Math.max(minRowHeight, Math.floor(self.rowHeight * 0.75));
1104
1081
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1105
1082
  });
1106
1083
  },
1084
+ /**
1085
+ * #action
1086
+ * Smoothly zoom by a continuous scaleFactor. The column under the cursor
1087
+ * (offsetX/offsetY, px relative to the MSA area) stays anchored
1088
+ * horizontally. Vertically the anchor is biased toward the top: when the
1089
+ * alignment nearly fits the viewport, snap to y=0 rather than pinning a
1090
+ * random row under the cursor, with the bias fading out as the alignment
1091
+ * grows taller than the viewport (where cursor-anchoring is useful).
1092
+ * Drives wheel/trackpad-pinch zoom.
1093
+ */
1094
+ zoomToPos(scaleFactor, offsetX, offsetY) {
1095
+ transaction(() => {
1096
+ const colInView = (-self.scrollX + offsetX) / self.colWidth;
1097
+ const rowInView = (-self.scrollY + offsetY) / self.rowHeight;
1098
+ self.colWidth = clamp(self.colWidth * scaleFactor, minColWidth, maxCellSize);
1099
+ self.rowHeight = clamp(self.rowHeight * scaleFactor, minRowHeight, maxCellSize);
1100
+ self.scrollX = clamp(offsetX - colInView * self.colWidth, self.maxScrollX, 0);
1101
+ const anchoredScrollY = offsetY - rowInView * self.rowHeight;
1102
+ const overflow = Math.max(0, self.totalHeight - self.height);
1103
+ const topBias = self.height > 0 ? clamp(1 - overflow / self.height, 0, 1) : 0;
1104
+ self.scrollY = clamp(anchoredScrollY * (1 - topBias), self.maxScrollY, 0);
1105
+ });
1106
+ },
1107
1107
  /**
1108
1108
  * #action
1109
1109
  */
1110
1110
  doScrollY(deltaY) {
1111
- self.scrollY = clamp(self.scrollY + deltaY, -self.totalHeight + 10, 0);
1111
+ self.scrollY = clamp(self.scrollY + deltaY, self.maxScrollY, 0);
1112
1112
  },
1113
- setInterProAnnotations(data) {
1113
+ /**
1114
+ * #action
1115
+ * Set domain annotations and reveal the overlay in a single step (or clear
1116
+ * both when passed undefined). Shared by every domain source: InterProScan,
1117
+ * GFF, user-provided uploads, and NCBI CDD.
1118
+ */
1119
+ setDomains(data) {
1114
1120
  self.interProAnnotations = data;
1121
+ self.setShowDomains(!!data);
1115
1122
  },
1116
1123
  applyGFFText(gffText) {
1117
1124
  const gffRecords = parseGFF(gffText);
1118
- const interProResults = gffToInterProResults(gffRecords);
1119
- self.interProAnnotations = interProResults;
1120
- self.setShowDomains(true);
1125
+ this.setDomains(gffToInterProResults(gffRecords));
1121
1126
  },
1122
1127
  /**
1123
1128
  * #action
@@ -1155,14 +1160,13 @@ function stateModelFactory() {
1155
1160
  */
1156
1161
  get labelWidthMap() {
1157
1162
  const { rowHeight, leaves, treeMetadata, fontSize } = self;
1158
- if (rowHeight <= 5) {
1159
- return new Map();
1160
- }
1161
- return new Map(leaves.map(node => {
1162
- const { name } = node.data;
1163
- const displayName = treeMetadata[name]?.genome || name;
1164
- return [name, measureTextCanvas(displayName, fontSize)];
1165
- }));
1163
+ return rowHeight <= 5
1164
+ ? new Map()
1165
+ : new Map(leaves.map(node => {
1166
+ const { name } = node.data;
1167
+ const displayName = treeMetadata[name]?.genome ?? name;
1168
+ return [name, measureTextCanvas(displayName, fontSize)];
1169
+ }));
1166
1170
  },
1167
1171
  get labelsWidth() {
1168
1172
  const widths = this.labelWidthMap;
@@ -1185,15 +1189,13 @@ function stateModelFactory() {
1185
1189
  */
1186
1190
  get adapterTrackModels() {
1187
1191
  const { rowHeight, MSA, hideGapsEffective, blanks } = self;
1188
- const tracks = (MSA?.tracks ?? []);
1192
+ const tracks = MSA?.tracks ?? [];
1189
1193
  return tracks
1190
1194
  .filter(t => !!t.data)
1191
1195
  .map(t => ({
1192
1196
  model: {
1193
1197
  ...t,
1194
- data: hideGapsEffective && t.data
1195
- ? skipBlanks(blanks, t.data)
1196
- : t.data,
1198
+ data: hideGapsEffective ? skipBlanks(blanks, t.data) : t.data,
1197
1199
  height: rowHeight,
1198
1200
  },
1199
1201
  ReactComponent: TextTrack,
@@ -1335,22 +1337,23 @@ function stateModelFactory() {
1335
1337
  return new Map(this.tidyInterProAnnotations.map(annot => [annot.accession, annot]));
1336
1338
  },
1337
1339
  get tidyInterProAnnotations() {
1338
- const { interProAnnotations } = self;
1339
- if (!interProAnnotations) {
1340
- return [];
1341
- }
1342
- return Object.entries(interProAnnotations)
1343
- .flatMap(([id, val]) => val.matches.flatMap(({ signature, locations }) => signature.entry
1344
- ? locations.map(({ start, end }) => ({
1345
- id,
1346
- name: signature.entry.name,
1347
- accession: signature.entry.accession,
1348
- description: signature.entry.description,
1349
- start,
1350
- end,
1340
+ return self.interProAnnotations
1341
+ ? Object.entries(self.interProAnnotations)
1342
+ .flatMap(([id, val]) => val.matches.flatMap(({ signature, locations }) => {
1343
+ const { entry } = signature;
1344
+ return entry
1345
+ ? locations.map(({ start, end }) => ({
1346
+ id,
1347
+ name: entry.name,
1348
+ accession: entry.accession,
1349
+ description: entry.description,
1350
+ start,
1351
+ end,
1352
+ }))
1353
+ : [];
1351
1354
  }))
1352
- : []))
1353
- .toSorted((a, b) => len(b) - len(a));
1355
+ .toSorted((a, b) => len(b) - len(a))
1356
+ : [];
1354
1357
  },
1355
1358
  get tidyFilteredInterProAnnotations() {
1356
1359
  return this.tidyInterProAnnotations.filter(r => self.featureFilters.get(r.accession));
@@ -1378,10 +1381,10 @@ function stateModelFactory() {
1378
1381
  return createPaletteMap([...self.tidyInterProAnnotationTypes.keys()]);
1379
1382
  },
1380
1383
  get strokePalette() {
1381
- return Object.fromEntries(Object.entries(this.fillPalette).map(([key, val]) => [
1384
+ return transform(this.fillPalette, ([key, val]) => [
1382
1385
  key,
1383
1386
  colord(val).darken(0.1).toHex(),
1384
- ]));
1387
+ ]);
1385
1388
  },
1386
1389
  /**
1387
1390
  * #method
@@ -1422,8 +1425,7 @@ function stateModelFactory() {
1422
1425
  self.setTreeFilehandle(undefined);
1423
1426
  self.setMSAFilehandle(undefined);
1424
1427
  self.setGFFFilehandle(undefined);
1425
- self.setInterProAnnotations(undefined);
1426
- self.setShowDomains(false);
1428
+ self.setDomains(undefined);
1427
1429
  },
1428
1430
  /**
1429
1431
  * #action
@@ -1446,8 +1448,12 @@ function stateModelFactory() {
1446
1448
  * #action
1447
1449
  */
1448
1450
  fit() {
1449
- self.rowHeight = self.msaAreaHeight / self.numRows;
1450
- self.colWidth = self.msaAreaWidth / self.numColumns;
1451
+ if (self.numRows > 0) {
1452
+ self.rowHeight = clamp(self.msaAreaHeight / self.numRows, minRowHeight, maxCellSize);
1453
+ }
1454
+ if (self.numColumns > 0) {
1455
+ self.colWidth = clamp(self.msaAreaWidth / self.numColumns, minColWidth, maxCellSize);
1456
+ }
1451
1457
  self.scrollX = 0;
1452
1458
  self.scrollY = 0;
1453
1459
  },
@@ -1455,14 +1461,18 @@ function stateModelFactory() {
1455
1461
  * #action
1456
1462
  */
1457
1463
  fitVertically() {
1458
- self.rowHeight = self.msaAreaHeight / self.numRows;
1464
+ if (self.numRows > 0) {
1465
+ self.rowHeight = clamp(self.msaAreaHeight / self.numRows, minRowHeight, maxCellSize);
1466
+ }
1459
1467
  self.scrollY = 0;
1460
1468
  },
1461
1469
  /**
1462
1470
  * #action
1463
1471
  */
1464
1472
  fitHorizontally() {
1465
- self.colWidth = self.msaAreaWidth / self.numColumns;
1473
+ if (self.numColumns > 0) {
1474
+ self.colWidth = clamp(self.msaAreaWidth / self.numColumns, minColWidth, maxCellSize);
1475
+ }
1466
1476
  self.scrollX = 0;
1467
1477
  },
1468
1478
  afterCreate() {
@@ -1471,24 +1481,37 @@ function stateModelFactory() {
1471
1481
  this.initFilter(key);
1472
1482
  }
1473
1483
  }));
1474
- // autorun opens treeFilehandle
1484
+ // autorun opens treeFilehandle. generation guard: if the filehandle
1485
+ // changes mid-fetch, a slower earlier request must not clobber the data
1486
+ // (or loading flag) set by the newer one
1487
+ let treeGeneration = 0;
1475
1488
  addDisposer(self, autorun(async () => {
1476
1489
  const { treeFilehandle } = self;
1477
1490
  if (treeFilehandle) {
1491
+ const generation = ++treeGeneration;
1478
1492
  try {
1479
1493
  self.setLoadingTree(true);
1480
- self.setTree(await fetchAndMaybeUnzipText(openLocation(treeFilehandle)));
1481
- if (treeFilehandle.locationType === 'BlobLocation') {
1482
- // clear filehandle after loading if from a local file
1483
- self.setTreeFilehandle(undefined);
1494
+ const text = await fetchAndMaybeUnzipText(openLocation(treeFilehandle));
1495
+ if (generation === treeGeneration) {
1496
+ transaction(() => {
1497
+ self.setTree(text);
1498
+ if (treeFilehandle.locationType === 'BlobLocation') {
1499
+ // clear filehandle after loading if from a local file
1500
+ self.setTreeFilehandle(undefined);
1501
+ }
1502
+ });
1484
1503
  }
1485
1504
  }
1486
1505
  catch (e) {
1487
- console.error(e);
1488
- self.setError(e);
1506
+ if (generation === treeGeneration) {
1507
+ console.error(e);
1508
+ self.setError(e);
1509
+ }
1489
1510
  }
1490
1511
  finally {
1491
- self.setLoadingTree(false);
1512
+ if (generation === treeGeneration) {
1513
+ self.setLoadingTree(false);
1514
+ }
1492
1515
  }
1493
1516
  }
1494
1517
  }));
@@ -1535,32 +1558,43 @@ function stateModelFactory() {
1535
1558
  }
1536
1559
  }
1537
1560
  }));
1538
- // autorun opens msaFilehandle
1561
+ // autorun opens msaFilehandle. generation guard: see treeFilehandle
1562
+ let msaGeneration = 0;
1539
1563
  addDisposer(self, autorun(async () => {
1540
1564
  const { msaFilehandle } = self;
1541
1565
  if (msaFilehandle) {
1566
+ const generation = ++msaGeneration;
1542
1567
  try {
1543
1568
  self.setLoadingMSA(true);
1544
1569
  self.setError(undefined);
1545
1570
  const txt = await fetchAndMaybeUnzipText(openLocation(msaFilehandle));
1546
- transaction(() => {
1547
- self.setMSA(txt);
1548
- if (msaFilehandle.locationType === 'BlobLocation') {
1549
- // clear filehandle after loading if from a local file
1550
- self.setMSAFilehandle(undefined);
1551
- }
1552
- });
1571
+ if (generation === msaGeneration) {
1572
+ transaction(() => {
1573
+ self.setMSA(txt);
1574
+ if (msaFilehandle.locationType === 'BlobLocation') {
1575
+ // clear filehandle after loading if from a local file
1576
+ self.setMSAFilehandle(undefined);
1577
+ }
1578
+ });
1579
+ }
1553
1580
  }
1554
1581
  catch (e) {
1555
- console.error(e);
1556
- self.setError(e);
1582
+ if (generation === msaGeneration) {
1583
+ console.error(e);
1584
+ self.setError(e);
1585
+ }
1557
1586
  }
1558
1587
  finally {
1559
- self.setLoadingMSA(false);
1588
+ if (generation === msaGeneration) {
1589
+ self.setLoadingMSA(false);
1590
+ }
1560
1591
  }
1561
1592
  }
1562
1593
  }));
1563
- // force colStats not to go stale
1594
+ // Keep the parse chain warm: reading self.columns transitively holds
1595
+ // self.MSA (parseMSA) computed alive, so it is parsed once per data
1596
+ // change rather than re-parsed on every non-reactive access. colStats is
1597
+ // additionally held for dynamic color schemes. Do not remove.
1564
1598
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
1565
1599
  // xref problem https://github.com/GMOD/react-msaview/issues/75
1566
1600
  addDisposer(self, autorun(() => {