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/dist/model.js CHANGED
@@ -5,12 +5,12 @@ import { addDisposer, cast, types } from '@jbrowse/mobx-state-tree';
5
5
  import { colord } from 'colord';
6
6
  import { autorun, transaction } from 'mobx';
7
7
  import { generateNodeIds, gffToInterProResults, parseEmfTree, parseGFF, parseMSA, parseNewick, } from 'msa-parsers';
8
- import { blocksX, blocksY } from "./calculateBlocks.js";
8
+ import { calculateBlocks } from "./calculateBlocks.js";
9
9
  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
  */
@@ -217,12 +234,6 @@ function stateModelFactory() {
217
234
  * array of column indices to highlight
218
235
  */
219
236
  highlightedColumns: undefined,
220
- /**
221
- * #volatile
222
- * a dummy variable that is incremented when ref changes so autorun for
223
- * drawing canvas commands will run
224
- */
225
- nref: 0,
226
237
  /**
227
238
  * #volatile
228
239
  */
@@ -282,6 +293,14 @@ function stateModelFactory() {
282
293
  setWidth(arg) {
283
294
  self.volatileWidth = arg;
284
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
+ },
285
304
  /**
286
305
  * #action
287
306
  * set the height of the view in px
@@ -299,31 +318,22 @@ function stateModelFactory() {
299
318
  /**
300
319
  * #action
301
320
  * set mouse position (row, column) in the MSA
321
+ *
322
+ * CROSS-REPO CONTRACT: jbrowse-plugin-protein3d calls this (and reads the
323
+ * `mouseCol` volatile) to sync MSA<->3D-structure hover. Keep the name and
324
+ * signature stable; see that repo's ProteinToMsaHoverSync.tsx.
302
325
  */
303
326
  setMousePos(col, row) {
304
327
  self.mouseCol = col;
305
328
  self.mouseRow = row;
306
329
  },
307
- /**
308
- * #action
309
- * set hovered tree node and its descendants
310
- */
311
- setHoveredTreeNode(nodeId) {
312
- if (!nodeId) {
313
- self.hoveredTreeNode = undefined;
314
- return;
315
- }
316
- const node = find(self.hierarchy, n => n.data.id === nodeId);
317
- if (!node) {
318
- self.hoveredTreeNode = undefined;
319
- return;
320
- }
321
- const descendantNames = leaves(node).map(leaf => leaf.data.name);
322
- self.hoveredTreeNode = { nodeId, descendantNames };
323
- },
324
330
  /**
325
331
  * #action
326
332
  * set highlighted columns
333
+ *
334
+ * CROSS-REPO CONTRACT: called by jbrowse-plugin-msaview
335
+ * (afterCreateAutoruns.ts) to highlight alignment columns. It has no
336
+ * in-repo caller, so do not flag it as dead code — it is public API.
327
337
  */
328
338
  setHighlightedColumns(columns) {
329
339
  self.highlightedColumns = columns;
@@ -482,19 +492,22 @@ function stateModelFactory() {
482
492
  * #getter
483
493
  */
484
494
  get colorScheme() {
485
- 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]);
486
499
  },
487
500
  /**
488
501
  * #getter
489
502
  */
490
503
  get header() {
491
- return (this.MSA?.getHeader() || {});
504
+ return this.MSA?.getHeader() ?? {};
492
505
  },
493
506
  /**
494
507
  * #getter
495
508
  */
496
509
  get alignmentNames() {
497
- return this.MSA?.alignmentNames || [];
510
+ return this.MSA?.alignmentNames ?? [];
498
511
  },
499
512
  /**
500
513
  * #getter
@@ -527,7 +540,7 @@ function stateModelFactory() {
527
540
  * #getter
528
541
  */
529
542
  get numColumns() {
530
- return (this.MSA?.getWidth() || 0) - this.blanks.length;
543
+ return (this.MSA?.getWidth() ?? 0) - this.blanks.length;
531
544
  },
532
545
  /**
533
546
  * #getter
@@ -535,28 +548,36 @@ function stateModelFactory() {
535
548
  get tree() {
536
549
  const text = self.data.tree;
537
550
  return text
538
- ? generateNodeIds(text.startsWith('BioTreeContainer')
539
- ? flatToTree(parseAsn1(text))
540
- : parseNewick(text.startsWith('SEQ') ? parseEmfTree(text).tree : text))
541
- : this.MSA?.getTree() || {
551
+ ? generateNodeIds(parseTreeText(text))
552
+ : (this.MSA?.getTree() ?? {
542
553
  noTree: true,
543
554
  children: [],
544
555
  id: 'empty',
545
556
  name: 'empty',
546
- };
557
+ });
547
558
  },
548
559
  /**
549
560
  * #getter
561
+ * Returns the list of row (sequence) names in display order.
562
+ * Part of the public API used by downstream consumers (e.g. jbrowse plugins).
550
563
  */
551
564
  get rowNames() {
552
565
  return this.leaves.map(n => n.data.name);
553
566
  },
567
+ /**
568
+ * #getter
569
+ */
570
+ get rowNamesSet() {
571
+ return new Map(this.leaves.map((leaf, index) => [leaf.data.name, index]));
572
+ },
554
573
  /**
555
574
  * #getter
556
575
  */
557
576
  get mouseOverRowName() {
558
577
  const { mouseRow } = self;
559
- return mouseRow === undefined ? undefined : this.rowNames[mouseRow];
578
+ return mouseRow === undefined
579
+ ? undefined
580
+ : this.leaves[mouseRow]?.data.name;
560
581
  },
561
582
  /**
562
583
  * #getter
@@ -564,24 +585,16 @@ function stateModelFactory() {
564
585
  */
565
586
  get hoveredInsertion() {
566
587
  const { mouseCol, mouseRow } = self;
567
- if (mouseCol === undefined || mouseRow === undefined) {
568
- return undefined;
569
- }
570
- const rowName = this.rowNames[mouseRow];
571
- if (!rowName) {
572
- return undefined;
573
- }
574
- const insertions = this.insertionPositions.get(rowName);
575
- if (!insertions) {
576
- return undefined;
577
- }
578
- const insertion = insertions.find(ins => ins.pos === mouseCol);
579
- if (insertion) {
580
- return {
581
- rowName,
582
- col: mouseCol,
583
- letters: insertion.letters,
584
- };
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
+ }
585
598
  }
586
599
  return undefined;
587
600
  },
@@ -600,14 +613,13 @@ function stateModelFactory() {
600
613
  }
601
614
  for (const collapsedId of self.collapsed) {
602
615
  const node = find(hier, n => n.data.id === collapsedId);
603
- if (!node) {
604
- continue;
605
- }
606
- if (node.children) {
607
- collapse(node);
608
- }
609
- else if (node.parent?.children) {
610
- 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
+ }
611
623
  }
612
624
  }
613
625
  return hier;
@@ -617,7 +629,7 @@ function stateModelFactory() {
617
629
  * widget width minus the tree area gives the space for the MSA
618
630
  */
619
631
  get msaAreaWidth() {
620
- return self.width - self.treeAreaWidth;
632
+ return self.width - self.treeAreaWidth - self.resizeHandleWidth;
621
633
  },
622
634
  /**
623
635
  * #getter
@@ -671,51 +683,13 @@ function stateModelFactory() {
671
683
  * Returns a map of row name to array of insertions with display position and letters
672
684
  */
673
685
  get insertionPositions() {
674
- const { hideGapsEffective } = self;
675
686
  const { blanks, rows } = this;
676
- const blanksLen = blanks.length;
677
- if (blanksLen === 0 || !hideGapsEffective) {
687
+ if (blanks.length === 0 || !self.hideGapsEffective) {
678
688
  return new Map();
679
689
  }
680
690
  const result = new Map();
681
691
  for (const [name, seq] of rows) {
682
- const insertions = [];
683
- let displayPos = 0;
684
- let blankIdx = 0;
685
- let currentInsertPos = -1;
686
- let letterChars = [];
687
- const seqLen = seq.length;
688
- for (let i = 0; i < seqLen; i++) {
689
- if (blankIdx < blanksLen && blanks[blankIdx] === i) {
690
- // bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
691
- const code = seq.charCodeAt(i);
692
- if (!((code - 45) >>> 0 <= 1)) {
693
- if (currentInsertPos === displayPos) {
694
- letterChars.push(seq[i]);
695
- }
696
- else {
697
- if (letterChars.length > 0) {
698
- insertions.push({
699
- pos: currentInsertPos,
700
- letters: letterChars.join(''),
701
- });
702
- }
703
- currentInsertPos = displayPos;
704
- letterChars = [seq[i]];
705
- }
706
- }
707
- blankIdx++;
708
- }
709
- else {
710
- displayPos++;
711
- }
712
- }
713
- if (letterChars.length > 0) {
714
- insertions.push({
715
- pos: currentInsertPos,
716
- letters: letterChars.join(''),
717
- });
718
- }
692
+ const insertions = computeRowInsertions(blanks, seq);
719
693
  if (insertions.length > 0) {
720
694
  result.set(name, insertions);
721
695
  }
@@ -747,16 +721,17 @@ function stateModelFactory() {
747
721
  * #getter
748
722
  */
749
723
  get columns() {
750
- return Object.fromEntries(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]]));
751
726
  },
752
727
  /**
753
728
  * #getter
754
729
  */
755
730
  get columns2d() {
756
731
  const { hideGapsEffective } = self;
757
- return this.rows
758
- .map(r => r[1])
759
- .map(str => (hideGapsEffective ? skipBlanks(this.blanks, str) : str));
732
+ return this.rows.map(([, str]) => (hideGapsEffective
733
+ ? skipBlanks(this.blanks, str)
734
+ : str).toUpperCase());
760
735
  },
761
736
  /**
762
737
  * #getter
@@ -783,7 +758,7 @@ function stateModelFactory() {
783
758
  * #getter
784
759
  */
785
760
  get colStatsSums() {
786
- return this.colStats.map(val => sum(Object.values(val)));
761
+ return this.colStats.map(col => sum(Object.values(col)));
787
762
  },
788
763
  /**
789
764
  * #getter
@@ -794,23 +769,18 @@ function stateModelFactory() {
794
769
  const letters = new Set();
795
770
  for (const stats of this.colStats) {
796
771
  for (const letter of Object.keys(stats)) {
797
- const upper = letter.toUpperCase();
798
- if (upper !== '-' && upper !== '.') {
799
- letters.add(upper);
772
+ if (letter !== '-' && letter !== '.') {
773
+ letters.add(letter);
800
774
  }
801
775
  }
802
776
  }
777
+ // isDna already excludes U (not in the DNA set) and isRna excludes T,
778
+ // so the set membership alone disambiguates the two
803
779
  const dna = new Set(['A', 'C', 'G', 'T', 'N']);
804
780
  const rna = new Set(['A', 'C', 'G', 'U', 'N']);
805
- const isDna = [...letters].every(l => dna.has(l));
806
- const isRna = [...letters].every(l => rna.has(l));
807
- if (isDna && !letters.has('U')) {
808
- return 'dna';
809
- }
810
- if (isRna && !letters.has('T')) {
811
- return 'rna';
812
- }
813
- 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';
814
784
  },
815
785
  /**
816
786
  * #getter
@@ -823,8 +793,7 @@ function stateModelFactory() {
823
793
  const total = colStatsSums[i];
824
794
  let maxCount = 0;
825
795
  let letter = '';
826
- for (const key in stats) {
827
- const val = stats[key];
796
+ for (const [key, val] of Object.entries(stats)) {
828
797
  if (val > maxCount && key !== '-' && key !== '.') {
829
798
  maxCount = val;
830
799
  letter = key;
@@ -864,23 +833,15 @@ function stateModelFactory() {
864
833
  if (!total) {
865
834
  return 0;
866
835
  }
867
- const gapCount = (stats['-'] || 0) + (stats['.'] || 0);
836
+ const gapCount = (stats['-'] ?? 0) + (stats['.'] ?? 0);
868
837
  const nonGapTotal = total - gapCount;
869
838
  if (nonGapTotal === 0) {
870
839
  return 0;
871
840
  }
872
- const merged = {};
873
- for (const letter of Object.keys(stats)) {
874
- if (letter === '-' || letter === '.') {
875
- continue;
876
- }
877
- const upper = letter.toUpperCase();
878
- merged[upper] = (merged[upper] || 0) + stats[letter];
879
- }
880
841
  let entropy = 0;
881
- for (const key of Object.keys(merged)) {
882
- const freq = merged[key] / nonGapTotal;
883
- if (freq > 0) {
842
+ for (const [letter, count] of Object.entries(stats)) {
843
+ if (letter !== '-' && letter !== '.') {
844
+ const freq = count / nonGapTotal;
884
845
  entropy -= freq * Math.log2(freq);
885
846
  }
886
847
  }
@@ -897,7 +858,8 @@ function stateModelFactory() {
897
858
  const r = this.root;
898
859
  clusterLayout(r, this.totalHeight, self.treeWidth);
899
860
  r.data.length = 0;
900
- setBrLength(r, 0, self.treeWidth / maxLength(r));
861
+ const max = maxLength(r);
862
+ setBrLength(r, 0, max ? self.treeWidth / max : 0);
901
863
  return r;
902
864
  },
903
865
  /**
@@ -953,28 +915,28 @@ function stateModelFactory() {
953
915
  * #getter
954
916
  */
955
917
  get dataInitialized() {
956
- return (self.data.msa || self.data.tree) && !self.error;
918
+ return (self.data.msa !== '' || self.data.tree !== '') && !self.error;
957
919
  },
958
920
  /**
959
921
  * #getter
960
922
  */
961
923
  get blocksX() {
962
- return blocksX({
963
- viewportWidth: self.msaAreaWidth,
964
- viewportX: -self.scrollX,
924
+ return calculateBlocks({
925
+ viewportSize: self.msaAreaWidth,
926
+ viewportPos: -self.scrollX,
965
927
  blockSize: self.blockSize,
966
- mapWidth: self.totalWidth,
928
+ mapSize: self.totalWidth,
967
929
  });
968
930
  },
969
931
  /**
970
932
  * #getter
971
933
  */
972
934
  get blocksY() {
973
- return blocksY({
974
- viewportHeight: self.height,
975
- viewportY: -self.scrollY,
935
+ return calculateBlocks({
936
+ viewportSize: self.height,
937
+ viewportPos: -self.scrollY,
976
938
  blockSize: self.blockSize,
977
- mapHeight: self.totalHeight,
939
+ mapSize: self.totalHeight,
978
940
  });
979
941
  },
980
942
  }))
@@ -997,6 +959,18 @@ function stateModelFactory() {
997
959
  get maxScrollX() {
998
960
  return Math.min(-self.totalWidth + (self.msaAreaWidth - 100), 0);
999
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
+ },
1000
974
  /**
1001
975
  * #getter
1002
976
  */
@@ -1019,6 +993,30 @@ function stateModelFactory() {
1019
993
  setDrawMsaLetters(arg) {
1020
994
  self.drawMsaLetters = arg;
1021
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
+ },
1022
1020
  /**
1023
1021
  * #action
1024
1022
  * Calculate a neighbor joining tree from the current MSA using BLOSUM62 distances
@@ -1041,35 +1039,35 @@ function stateModelFactory() {
1041
1039
  * #action
1042
1040
  */
1043
1041
  zoomOutHorizontal() {
1044
- self.colWidth = Math.max(1, Math.floor(self.colWidth * 0.75));
1042
+ self.colWidth = Math.max(minColWidth, Math.floor(self.colWidth * 0.75));
1045
1043
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1046
1044
  },
1047
1045
  /**
1048
1046
  * #action
1049
1047
  */
1050
1048
  zoomInHorizontal() {
1051
- self.colWidth = Math.ceil(self.colWidth * 1.5);
1049
+ self.colWidth = Math.min(maxCellSize, Math.ceil(self.colWidth * 1.5));
1052
1050
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1053
1051
  },
1054
1052
  /**
1055
1053
  * #action
1056
1054
  */
1057
1055
  zoomInVertical() {
1058
- self.rowHeight = Math.ceil(self.rowHeight * 1.5);
1056
+ self.rowHeight = Math.min(maxCellSize, Math.ceil(self.rowHeight * 1.5));
1059
1057
  },
1060
1058
  /**
1061
1059
  * #action
1062
1060
  */
1063
1061
  zoomOutVertical() {
1064
- self.rowHeight = Math.max(1.5, Math.floor(self.rowHeight * 0.75));
1062
+ self.rowHeight = Math.max(minRowHeight, Math.floor(self.rowHeight * 0.75));
1065
1063
  },
1066
1064
  /**
1067
1065
  * #action
1068
1066
  */
1069
1067
  zoomIn() {
1070
1068
  transaction(() => {
1071
- self.colWidth = Math.ceil(self.colWidth * 1.5);
1072
- 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));
1073
1071
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1074
1072
  });
1075
1073
  },
@@ -1078,25 +1076,53 @@ function stateModelFactory() {
1078
1076
  */
1079
1077
  zoomOut() {
1080
1078
  transaction(() => {
1081
- self.colWidth = Math.max(1, Math.floor(self.colWidth * 0.75));
1082
- 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));
1083
1081
  self.scrollX = clamp(self.scrollX, self.maxScrollX, 0);
1084
1082
  });
1085
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
+ },
1086
1107
  /**
1087
1108
  * #action
1088
1109
  */
1089
1110
  doScrollY(deltaY) {
1090
- self.scrollY = clamp(self.scrollY + deltaY, -self.totalHeight + 10, 0);
1111
+ self.scrollY = clamp(self.scrollY + deltaY, self.maxScrollY, 0);
1091
1112
  },
1092
- 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) {
1093
1120
  self.interProAnnotations = data;
1121
+ self.setShowDomains(!!data);
1094
1122
  },
1095
1123
  applyGFFText(gffText) {
1096
1124
  const gffRecords = parseGFF(gffText);
1097
- const interProResults = gffToInterProResults(gffRecords);
1098
- self.interProAnnotations = interProResults;
1099
- self.setShowDomains(true);
1125
+ this.setDomains(gffToInterProResults(gffRecords));
1100
1126
  },
1101
1127
  /**
1102
1128
  * #action
@@ -1132,12 +1158,19 @@ function stateModelFactory() {
1132
1158
  /**
1133
1159
  * #getter
1134
1160
  */
1135
- get labelsWidth() {
1161
+ get labelWidthMap() {
1136
1162
  const { rowHeight, leaves, treeMetadata, fontSize } = self;
1137
- if (rowHeight <= 5) {
1138
- return 0;
1139
- }
1140
- return leaves.reduce((max, node) => Math.max(max, measureTextCanvas(treeMetadata[node.data.name]?.genome || node.data.name, fontSize)), 0);
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
+ }));
1170
+ },
1171
+ get labelsWidth() {
1172
+ const widths = this.labelWidthMap;
1173
+ return widths.size === 0 ? 0 : Math.max(...widths.values());
1141
1174
  },
1142
1175
  /**
1143
1176
  * #getter
@@ -1156,15 +1189,13 @@ function stateModelFactory() {
1156
1189
  */
1157
1190
  get adapterTrackModels() {
1158
1191
  const { rowHeight, MSA, hideGapsEffective, blanks } = self;
1159
- const tracks = (MSA?.tracks ?? []);
1192
+ const tracks = MSA?.tracks ?? [];
1160
1193
  return tracks
1161
1194
  .filter(t => !!t.data)
1162
1195
  .map(t => ({
1163
1196
  model: {
1164
1197
  ...t,
1165
- data: hideGapsEffective && t.data
1166
- ? skipBlanks(blanks, t.data)
1167
- : t.data,
1198
+ data: hideGapsEffective ? skipBlanks(blanks, t.data) : t.data,
1168
1199
  height: rowHeight,
1169
1200
  },
1170
1201
  ReactComponent: TextTrack,
@@ -1196,12 +1227,6 @@ function stateModelFactory() {
1196
1227
  get showHorizontalScrollbar() {
1197
1228
  return self.msaAreaWidth < self.totalWidth;
1198
1229
  },
1199
- /**
1200
- * #getter
1201
- */
1202
- get rowNamesSet() {
1203
- return new Map(self.rowNames.map((r, idx) => [r, idx]));
1204
- },
1205
1230
  /**
1206
1231
  * #method
1207
1232
  * Return a row-specific letter at a visible column, or undefined if gap.
@@ -1219,6 +1244,11 @@ function stateModelFactory() {
1219
1244
  * Convert a visible column to a row-specific sequence position (0-based).
1220
1245
  * Returns undefined if the position is a gap in the sequence.
1221
1246
  *
1247
+ * CROSS-REPO CONTRACT: this and the sibling coordinate converters
1248
+ * (seqPosToVisibleCol, globalColToVisibleCol, seqPosToGlobalCol) are used
1249
+ * by jbrowse-plugin-protein3d to translate between alignment columns and
1250
+ * structure/sequence residue positions across gaps. Keep them stable.
1251
+ *
1222
1252
  * @param rowName - The name of the row
1223
1253
  * @param visibleCol - The visible column index
1224
1254
  * @returns The sequence position (0-based), or undefined if it's a gap
@@ -1269,14 +1299,8 @@ function stateModelFactory() {
1269
1299
  * @returns The global column index in the full MSA
1270
1300
  */
1271
1301
  seqPosToGlobalCol(rowName, seqPos) {
1272
- const { rows } = self;
1273
- const index = this.rowNamesSet.get(rowName);
1274
- return index !== undefined && rows[index]
1275
- ? seqPosToGlobalCol({
1276
- row: rows[index][1],
1277
- seqPos,
1278
- })
1279
- : 0;
1302
+ const seq = self.rowMap.get(rowName);
1303
+ return seq ? seqPosToGlobalCol({ row: seq, seqPos }) : 0;
1280
1304
  },
1281
1305
  /**
1282
1306
  * #method
@@ -1293,29 +1317,6 @@ function stateModelFactory() {
1293
1317
  },
1294
1318
  }))
1295
1319
  .views(self => ({
1296
- /**
1297
- * #getter
1298
- * Returns information about the currently hovered cell
1299
- */
1300
- get hoveredCell() {
1301
- const { mouseCol, mouseRow } = self;
1302
- if (mouseCol === undefined || mouseRow === undefined) {
1303
- return undefined;
1304
- }
1305
- const rowName = self.rowNames[mouseRow];
1306
- if (!rowName) {
1307
- return undefined;
1308
- }
1309
- const seq = self.columns[rowName];
1310
- const base = seq?.[mouseCol];
1311
- const seqPos = self.visibleColToSeqPosOneBased(rowName, mouseCol);
1312
- return {
1313
- rowName,
1314
- col: mouseCol,
1315
- base,
1316
- seqPos,
1317
- };
1318
- },
1319
1320
  /**
1320
1321
  * #getter
1321
1322
  * widget width minus the tree area gives the space for the MSA
@@ -1336,22 +1337,23 @@ function stateModelFactory() {
1336
1337
  return new Map(this.tidyInterProAnnotations.map(annot => [annot.accession, annot]));
1337
1338
  },
1338
1339
  get tidyInterProAnnotations() {
1339
- const { interProAnnotations } = self;
1340
- if (!interProAnnotations) {
1341
- return [];
1342
- }
1343
- return Object.entries(interProAnnotations)
1344
- .flatMap(([id, val]) => val.matches.flatMap(({ signature, locations }) => signature.entry
1345
- ? locations.map(({ start, end }) => ({
1346
- id,
1347
- name: signature.entry.name,
1348
- accession: signature.entry.accession,
1349
- description: signature.entry.description,
1350
- start,
1351
- 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
+ : [];
1352
1354
  }))
1353
- : []))
1354
- .toSorted((a, b) => len(b) - len(a));
1355
+ .toSorted((a, b) => len(b) - len(a))
1356
+ : [];
1355
1357
  },
1356
1358
  get tidyFilteredInterProAnnotations() {
1357
1359
  return this.tidyInterProAnnotations.filter(r => self.featureFilters.get(r.accession));
@@ -1379,10 +1381,10 @@ function stateModelFactory() {
1379
1381
  return createPaletteMap([...self.tidyInterProAnnotationTypes.keys()]);
1380
1382
  },
1381
1383
  get strokePalette() {
1382
- return Object.fromEntries(Object.entries(this.fillPalette).map(([key, val]) => [
1384
+ return transform(this.fillPalette, ([key, val]) => [
1383
1385
  key,
1384
1386
  colord(val).darken(0.1).toHex(),
1385
- ]));
1387
+ ]);
1386
1388
  },
1387
1389
  /**
1388
1390
  * #method
@@ -1423,8 +1425,7 @@ function stateModelFactory() {
1423
1425
  self.setTreeFilehandle(undefined);
1424
1426
  self.setMSAFilehandle(undefined);
1425
1427
  self.setGFFFilehandle(undefined);
1426
- self.setInterProAnnotations({});
1427
- self.setShowDomains(false);
1428
+ self.setDomains(undefined);
1428
1429
  },
1429
1430
  /**
1430
1431
  * #action
@@ -1435,13 +1436,6 @@ function stateModelFactory() {
1435
1436
  const blob = new Blob([html], { type: 'image/svg+xml' });
1436
1437
  saveAs(blob, 'image.svg');
1437
1438
  },
1438
- /**
1439
- * #action
1440
- * internal, used for drawing to canvas
1441
- */
1442
- incrementRef() {
1443
- self.nref++;
1444
- },
1445
1439
  initFilter(arg) {
1446
1440
  if (!self.featureFilters.has(arg)) {
1447
1441
  self.featureFilters.set(arg, true);
@@ -1454,8 +1448,12 @@ function stateModelFactory() {
1454
1448
  * #action
1455
1449
  */
1456
1450
  fit() {
1457
- self.rowHeight = self.msaAreaHeight / self.numRows;
1458
- 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
+ }
1459
1457
  self.scrollX = 0;
1460
1458
  self.scrollY = 0;
1461
1459
  },
@@ -1463,14 +1461,18 @@ function stateModelFactory() {
1463
1461
  * #action
1464
1462
  */
1465
1463
  fitVertically() {
1466
- self.rowHeight = self.msaAreaHeight / self.numRows;
1464
+ if (self.numRows > 0) {
1465
+ self.rowHeight = clamp(self.msaAreaHeight / self.numRows, minRowHeight, maxCellSize);
1466
+ }
1467
1467
  self.scrollY = 0;
1468
1468
  },
1469
1469
  /**
1470
1470
  * #action
1471
1471
  */
1472
1472
  fitHorizontally() {
1473
- self.colWidth = self.msaAreaWidth / self.numColumns;
1473
+ if (self.numColumns > 0) {
1474
+ self.colWidth = clamp(self.msaAreaWidth / self.numColumns, minColWidth, maxCellSize);
1475
+ }
1474
1476
  self.scrollX = 0;
1475
1477
  },
1476
1478
  afterCreate() {
@@ -1479,24 +1481,37 @@ function stateModelFactory() {
1479
1481
  this.initFilter(key);
1480
1482
  }
1481
1483
  }));
1482
- // 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;
1483
1488
  addDisposer(self, autorun(async () => {
1484
1489
  const { treeFilehandle } = self;
1485
1490
  if (treeFilehandle) {
1491
+ const generation = ++treeGeneration;
1486
1492
  try {
1487
1493
  self.setLoadingTree(true);
1488
- self.setTree(await fetchAndMaybeUnzipText(openLocation(treeFilehandle)));
1489
- if (treeFilehandle.locationType === 'BlobLocation') {
1490
- // clear filehandle after loading if from a local file
1491
- 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
+ });
1492
1503
  }
1493
1504
  }
1494
1505
  catch (e) {
1495
- console.error(e);
1496
- self.setError(e);
1506
+ if (generation === treeGeneration) {
1507
+ console.error(e);
1508
+ self.setError(e);
1509
+ }
1497
1510
  }
1498
1511
  finally {
1499
- self.setLoadingTree(false);
1512
+ if (generation === treeGeneration) {
1513
+ self.setLoadingTree(false);
1514
+ }
1500
1515
  }
1501
1516
  }
1502
1517
  }));
@@ -1543,32 +1558,43 @@ function stateModelFactory() {
1543
1558
  }
1544
1559
  }
1545
1560
  }));
1546
- // autorun opens msaFilehandle
1561
+ // autorun opens msaFilehandle. generation guard: see treeFilehandle
1562
+ let msaGeneration = 0;
1547
1563
  addDisposer(self, autorun(async () => {
1548
1564
  const { msaFilehandle } = self;
1549
1565
  if (msaFilehandle) {
1566
+ const generation = ++msaGeneration;
1550
1567
  try {
1551
1568
  self.setLoadingMSA(true);
1552
1569
  self.setError(undefined);
1553
1570
  const txt = await fetchAndMaybeUnzipText(openLocation(msaFilehandle));
1554
- transaction(() => {
1555
- self.setMSA(txt);
1556
- if (msaFilehandle.locationType === 'BlobLocation') {
1557
- // clear filehandle after loading if from a local file
1558
- self.setMSAFilehandle(undefined);
1559
- }
1560
- });
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
+ }
1561
1580
  }
1562
1581
  catch (e) {
1563
- console.error(e);
1564
- self.setError(e);
1582
+ if (generation === msaGeneration) {
1583
+ console.error(e);
1584
+ self.setError(e);
1585
+ }
1565
1586
  }
1566
1587
  finally {
1567
- self.setLoadingMSA(false);
1588
+ if (generation === msaGeneration) {
1589
+ self.setLoadingMSA(false);
1590
+ }
1568
1591
  }
1569
1592
  }
1570
1593
  }));
1571
- // 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.
1572
1598
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
1573
1599
  // xref problem https://github.com/GMOD/react-msaview/issues/75
1574
1600
  addDisposer(self, autorun(() => {