react-msaview 5.6.0 → 5.6.2

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 (94) hide show
  1. package/bundle/index.js +95 -95
  2. package/bundle/index.js.map +4 -4
  3. package/dist/clustalX.d.ts +4 -3
  4. package/dist/clustalX.js +25 -4
  5. package/dist/clustalX.js.map +1 -1
  6. package/dist/columnCounts.d.ts +37 -0
  7. package/dist/columnCounts.js +148 -0
  8. package/dist/columnCounts.js.map +1 -0
  9. package/dist/components/MSAView.js +1 -8
  10. package/dist/components/MSAView.js.map +1 -1
  11. package/dist/components/getVisibleLeaves.d.ts +6 -0
  12. package/dist/components/getVisibleLeaves.js.map +1 -0
  13. package/dist/components/msa/MSACanvasBlock.js +17 -54
  14. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  15. package/dist/components/msa/renderBoxFeatureCanvasBlock.d.ts +2 -1
  16. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +21 -19
  17. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  18. package/dist/components/msa/renderMSABlock.js +97 -96
  19. package/dist/components/msa/renderMSABlock.js.map +1 -1
  20. package/dist/components/msa/renderMSAMouseover.js +9 -32
  21. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  22. package/dist/components/msa/useMsaBlockMouse.d.ts +23 -0
  23. package/dist/components/msa/useMsaBlockMouse.js +45 -0
  24. package/dist/components/msa/useMsaBlockMouse.js.map +1 -0
  25. package/dist/components/tree/TreeCanvas.js +14 -19
  26. package/dist/components/tree/TreeCanvas.js.map +1 -1
  27. package/dist/components/tree/TreeCanvasBlock.js +40 -111
  28. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  29. package/dist/components/tree/renderTreeCanvas.d.ts +2 -0
  30. package/dist/components/tree/renderTreeCanvas.js +15 -3
  31. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  32. package/dist/components/tree/useTreeHover.d.ts +24 -0
  33. package/dist/components/tree/useTreeHover.js +44 -0
  34. package/dist/components/tree/useTreeHover.js.map +1 -0
  35. package/dist/hierarchy.js +15 -9
  36. package/dist/hierarchy.js.map +1 -1
  37. package/dist/model/msaModel.js +4 -3
  38. package/dist/model/msaModel.js.map +1 -1
  39. package/dist/model/treeModel.js +9 -8
  40. package/dist/model/treeModel.js.map +1 -1
  41. package/dist/model.d.ts +52 -10
  42. package/dist/model.js +208 -93
  43. package/dist/model.js.map +1 -1
  44. package/dist/propertyConservation.d.ts +2 -1
  45. package/dist/propertyConservation.js +36 -25
  46. package/dist/propertyConservation.js.map +1 -1
  47. package/dist/seqPosToGlobalCol.d.ts +7 -0
  48. package/dist/seqPosToGlobalCol.js +17 -0
  49. package/dist/seqPosToGlobalCol.js.map +1 -1
  50. package/dist/stripDefault.d.ts +21 -0
  51. package/dist/stripDefault.js +26 -0
  52. package/dist/stripDefault.js.map +1 -0
  53. package/dist/types.d.ts +16 -0
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +3 -3
  57. package/src/clustalX.test.ts +9 -11
  58. package/src/clustalX.ts +27 -28
  59. package/src/colorSchemes.docs.test.ts +3 -1
  60. package/src/columnCounts.test.ts +58 -0
  61. package/src/columnCounts.ts +170 -0
  62. package/src/components/MSAView.tsx +1 -9
  63. package/src/components/{msa/getVisibleLeaves.ts → getVisibleLeaves.ts} +1 -1
  64. package/src/components/msa/MSACanvasBlock.tsx +42 -110
  65. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +25 -18
  66. package/src/components/msa/renderMSABlock.ts +113 -125
  67. package/src/components/msa/renderMSAMouseover.ts +11 -34
  68. package/src/components/msa/useMsaBlockMouse.ts +81 -0
  69. package/src/components/tree/TreeCanvas.tsx +16 -23
  70. package/src/components/tree/TreeCanvasBlock.tsx +62 -143
  71. package/src/components/tree/renderTreeCanvas.test.ts +100 -190
  72. package/src/components/tree/renderTreeCanvas.ts +14 -3
  73. package/src/components/tree/useTreeHover.ts +69 -0
  74. package/src/domainBands.test.ts +98 -0
  75. package/src/hierarchy.ts +15 -9
  76. package/src/model/msaModel.ts +4 -3
  77. package/src/model/treeModel.ts +9 -8
  78. package/src/model.ts +241 -106
  79. package/src/propertyConservation.test.ts +2 -13
  80. package/src/propertyConservation.ts +40 -31
  81. package/src/rowSpace.test.ts +46 -0
  82. package/src/seqPosToGlobalCol.ts +18 -0
  83. package/src/stripDefault.test.ts +48 -0
  84. package/src/stripDefault.ts +35 -0
  85. package/src/stripDefaultSnapshot.test.ts +4 -1
  86. package/src/types.ts +23 -0
  87. package/src/version.ts +1 -1
  88. package/dist/components/msa/getVisibleLeaves.d.ts +0 -6
  89. package/dist/components/msa/getVisibleLeaves.js.map +0 -1
  90. package/dist/useDevicePixelRatio.d.ts +0 -1
  91. package/dist/useDevicePixelRatio.js +0 -27
  92. package/dist/useDevicePixelRatio.js.map +0 -1
  93. package/src/useDevicePixelRatio.ts +0 -29
  94. /package/dist/components/{msa/getVisibleLeaves.js → getVisibleLeaves.js} +0 -0
package/dist/model.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { HierarchyNode } from './hierarchy.ts';
2
2
  import type { InterProScanResults } from './launchInterProScan.ts';
3
- import type { BasicTrack, NodeWithIds, NodeWithIdsAndLength } from './types.ts';
3
+ import type { BasicTrack, DomainBand, NodeWithIds, NodeWithIdsAndLength } from './types.ts';
4
4
  import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types';
5
5
  import type { Instance } from '@jbrowse/mobx-state-tree';
6
6
  import type { Theme } from '@mui/material';
@@ -937,8 +937,19 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
937
937
  readonly rows: [string, string][];
938
938
  /**
939
939
  * #getter
940
+ * number of rows the alignment occupies on screen. This is the leaf count,
941
+ * not `rows.length`: a tree leaf with no matching MSA row still takes up a
942
+ * row of vertical space (drawn blank), so row hit-testing and fit-to-height
943
+ * must count it.
940
944
  */
941
945
  readonly numRows: number;
946
+ /**
947
+ * #getter
948
+ * per-row index of the global column holding each ungapped sequence
949
+ * position, so seqPos -> column is a lookup rather than a scan of the row.
950
+ * The domain overlay resolves thousands of these per redraw.
951
+ */
952
+ readonly seqPosGlobalColIndex: Map<string, Int32Array<ArrayBuffer>>;
942
953
  /**
943
954
  * #getter
944
955
  */
@@ -958,11 +969,12 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
958
969
  /**
959
970
  * #getter
960
971
  */
961
- readonly colStats: Record<string, number>[];
972
+ readonly colStats: import("./columnCounts.ts").ColumnCounts;
962
973
  /**
963
974
  * #getter
975
+ * per-column residue totals (gaps included)
964
976
  */
965
- readonly colStatsSums: number[];
977
+ readonly colStatsSums: Uint32Array<ArrayBufferLike>;
966
978
  /**
967
979
  * #getter
968
980
  * Detects sequence type based on letters present in the alignment.
@@ -1382,20 +1394,50 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
1382
1394
  end: number;
1383
1395
  strand: number | undefined;
1384
1396
  }[];
1397
+ /**
1398
+ * #getter
1399
+ * every filtered-on domain annotation resolved to the visible column span
1400
+ * it is drawn across, keyed by row name and in draw order (largest first,
1401
+ * as tidyInterProAnnotations orders them). Resolving these once here rather
1402
+ * than inside each canvas block removes a per-feature, per-block sequence
1403
+ * position conversion from every redraw, and gives the letter renderer the
1404
+ * band colors it needs to keep residues readable on top of the boxes.
1405
+ */
1406
+ readonly domainBands: Map<string, DomainBand[]>;
1407
+ /**
1408
+ * #getter
1409
+ * the same bands ordered by start column, for left-to-right sweeps (the
1410
+ * letter renderer walks columns and needs the band covering each one)
1411
+ */
1412
+ readonly domainBandsByStart: Map<string, DomainBand[]>;
1385
1413
  /**
1386
1414
  * #getter
1387
1415
  * domain annotations under the mouse, hit-tested against the exact visible
1388
1416
  * column span each box is drawn at (so it matches the overlay across gaps)
1389
1417
  */
1390
- readonly mouseOverDomains: {
1391
- id: string;
1392
- name: string;
1393
- accession: string;
1394
- description: string;
1395
- featureType: string | undefined;
1418
+ readonly mouseOverDomains: import("./types.ts").TidyDomainAnnotation[];
1419
+ /**
1420
+ * #getter
1421
+ * row index of the reference row (`relativeTo`), undefined when unset
1422
+ */
1423
+ readonly referenceRowIndex: number | undefined;
1424
+ /**
1425
+ * #getter
1426
+ * row indices highlighted by the current tree hover (a hovered internal
1427
+ * node highlights every tip below it). Shared by the tree and MSA overlay
1428
+ * canvases so they cannot disagree, and resolved through the memoized
1429
+ * name->index map rather than rebuilding a lookup on each mouse move.
1430
+ */
1431
+ readonly hoveredRowIndices: number[];
1432
+ /**
1433
+ * #getter
1434
+ * contiguous runs of `highlightedColumns`, so a run of highlighted columns
1435
+ * draws as one bordered band. Computed here because the overlay canvas
1436
+ * redraws on every mouse move while the highlight itself rarely changes.
1437
+ */
1438
+ readonly highlightedColumnRuns: {
1396
1439
  start: number;
1397
1440
  end: number;
1398
- strand: number | undefined;
1399
1441
  }[];
1400
1442
  /**
1401
1443
  * #getter
package/dist/model.js CHANGED
@@ -8,6 +8,7 @@ import { generateNodeIds, gffToInterProResults, parseEmfTree, parseGFF, parseMSA
8
8
  import { calculateBlocks } from "./calculateBlocks.js";
9
9
  import { clustalXColumnColors } from "./clustalX.js";
10
10
  import colorSchemes from "./colorSchemes.js";
11
+ import { columnCountsFromRows, letterOfResidueSlot } from "./columnCounts.js";
11
12
  import ConservationTrack from "./components/ConservationTrack.js";
12
13
  import TextTrack from "./components/TextTrack.js";
13
14
  import { defaultAllowedGappyness, defaultColWidth, defaultColorSchemeName, defaultCurrentAlignment, defaultDrawMsaLetters, defaultHeight, defaultHideGaps, defaultRowHeight, defaultScrollX, defaultScrollY, defaultScrollZoom, defaultShowDomains, defaultSubFeatureRows, maxCellSize, minColWidth, minRowHeight, segmentFeatureTypes, segmentShades, } from "./constants.js";
@@ -24,7 +25,8 @@ import { calculateNeighborJoiningTree } from "./neighborJoining.js";
24
25
  import { parseAsn1 } from "./parseAsn1.js";
25
26
  import { calculatePropertyConservation } from "./propertyConservation.js";
26
27
  import { globalColToVisibleCol, visibleColToGlobalCol, visibleColToSeqPosForRow, } from "./rowCoordinateCalculations.js";
27
- import { seqPosToGlobalCol } from "./seqPosToGlobalCol.js";
28
+ import { buildSeqPosIndex } from "./seqPosToGlobalCol.js";
29
+ import { stripDefault } from "./stripDefault.js";
28
30
  import { computeRowInsertions, len, skipBlanks, transform } from "./util.js";
29
31
  import { saveAs } from "./vendor/fileSaver.js";
30
32
  function parseTreeText(text) {
@@ -64,19 +66,19 @@ function stateModelFactory() {
64
66
  /**
65
67
  * #property
66
68
  */
67
- showDomains: types.stripDefault(types.boolean, defaultShowDomains),
69
+ showDomains: stripDefault(types.boolean, defaultShowDomains),
68
70
  /**
69
71
  * #property
70
72
  */
71
- hideGaps: types.stripDefault(types.boolean, defaultHideGaps),
73
+ hideGaps: stripDefault(types.boolean, defaultHideGaps),
72
74
  /**
73
75
  * #property
74
76
  */
75
- allowedGappyness: types.stripDefault(types.number, defaultAllowedGappyness),
77
+ allowedGappyness: stripDefault(types.number, defaultAllowedGappyness),
76
78
  /**
77
79
  * #property
78
80
  */
79
- subFeatureRows: types.stripDefault(types.boolean, defaultSubFeatureRows),
81
+ subFeatureRows: stripDefault(types.boolean, defaultSubFeatureRows),
80
82
  /**
81
83
  * #property
82
84
  * hardcoded view type
@@ -85,37 +87,37 @@ function stateModelFactory() {
85
87
  /**
86
88
  * #property
87
89
  */
88
- drawMsaLetters: types.stripDefault(types.boolean, defaultDrawMsaLetters),
90
+ drawMsaLetters: stripDefault(types.boolean, defaultDrawMsaLetters),
89
91
  /**
90
92
  * #property
91
93
  * zoom in/out on plain mouse-wheel without holding ctrl
92
94
  */
93
- scrollZoom: types.stripDefault(types.boolean, defaultScrollZoom),
95
+ scrollZoom: stripDefault(types.boolean, defaultScrollZoom),
94
96
  /**
95
97
  * #property
96
98
  * height of the div containing the view, px
97
99
  */
98
- height: types.stripDefault(types.number, defaultHeight),
100
+ height: stripDefault(types.number, defaultHeight),
99
101
  /**
100
102
  * #property
101
103
  * height of each row, px
102
104
  */
103
- rowHeight: types.stripDefault(types.number, defaultRowHeight),
105
+ rowHeight: stripDefault(types.number, defaultRowHeight),
104
106
  /**
105
107
  * #property
106
108
  * scroll position, Y-offset, px
107
109
  */
108
- scrollY: types.stripDefault(types.number, defaultScrollY),
110
+ scrollY: stripDefault(types.number, defaultScrollY),
109
111
  /**
110
112
  * #property
111
113
  * scroll position, X-offset, px
112
114
  */
113
- scrollX: types.stripDefault(types.number, defaultScrollX),
115
+ scrollX: stripDefault(types.number, defaultScrollX),
114
116
  /**
115
117
  * #property
116
118
  * width of columns, px
117
119
  */
118
- colWidth: types.stripDefault(types.number, defaultColWidth),
120
+ colWidth: stripDefault(types.number, defaultColWidth),
119
121
  /**
120
122
  * #property
121
123
  * filehandle object for the tree
@@ -140,13 +142,13 @@ function stateModelFactory() {
140
142
  /**
141
143
  * #property
142
144
  */
143
- currentAlignment: types.stripDefault(types.number, defaultCurrentAlignment),
145
+ currentAlignment: stripDefault(types.number, defaultCurrentAlignment),
144
146
  /**
145
147
  * #property
146
148
  * array of tree parent nodes that are 'collapsed' (all children are
147
149
  * hidden)
148
150
  */
149
- collapsed: types.stripDefault(types.array(types.string), []),
151
+ collapsed: stripDefault(types.array(types.string), []),
150
152
  /**
151
153
  * #property
152
154
  * focus on particular subtree
@@ -156,7 +158,7 @@ function stateModelFactory() {
156
158
  * #property
157
159
  * turned off tracks
158
160
  */
159
- turnedOffTracks: types.stripDefault(types.map(types.boolean), {}),
161
+ turnedOffTracks: stripDefault(types.map(types.boolean), {}),
160
162
  /**
161
163
  * #property
162
164
  * data from the loaded tree/msa/treeMetadata, generally loaded by
@@ -170,7 +172,7 @@ function stateModelFactory() {
170
172
  /**
171
173
  * #property
172
174
  */
173
- featureFilters: types.stripDefault(types.map(types.boolean), {}),
175
+ featureFilters: stripDefault(types.map(types.boolean), {}),
174
176
  /**
175
177
  * #property
176
178
  */
@@ -681,15 +683,19 @@ function stateModelFactory() {
681
683
  if (strs.length === 0) {
682
684
  return [];
683
685
  }
684
- const numCols = strs[0].length;
686
+ // ragged input (a3m, hand-edited fasta) can have rows shorter than the
687
+ // alignment; the widest row defines the column count and a row that
688
+ // stops early counts as gapped for the remainder
689
+ const numCols = strs.reduce((max, str) => Math.max(max, str.length), 0);
685
690
  const numRows = strs.length;
686
691
  const threshold = Math.ceil((realAllowedGappyness / 100) * numRows);
687
- const blankCounts = new Uint16Array(numCols);
692
+ const blankCounts = new Uint32Array(numCols);
688
693
  for (let j = 0; j < numRows; j++) {
689
694
  const str = strs[j];
695
+ const len = str.length;
690
696
  for (let i = 0; i < numCols; i++) {
691
697
  // bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
692
- if ((str.charCodeAt(i) - 45) >>> 0 <= 1) {
698
+ if (i >= len || (str.charCodeAt(i) - 45) >>> 0 <= 1) {
693
699
  blankCounts[i]++;
694
700
  }
695
701
  }
@@ -737,9 +743,22 @@ function stateModelFactory() {
737
743
  },
738
744
  /**
739
745
  * #getter
746
+ * number of rows the alignment occupies on screen. This is the leaf count,
747
+ * not `rows.length`: a tree leaf with no matching MSA row still takes up a
748
+ * row of vertical space (drawn blank), so row hit-testing and fit-to-height
749
+ * must count it.
740
750
  */
741
751
  get numRows() {
742
- return this.rows.length;
752
+ return this.leaves.length;
753
+ },
754
+ /**
755
+ * #getter
756
+ * per-row index of the global column holding each ungapped sequence
757
+ * position, so seqPos -> column is a lookup rather than a scan of the row.
758
+ * The domain overlay resolves thousands of these per redraw.
759
+ */
760
+ get seqPosGlobalColIndex() {
761
+ return new Map(this.rows.map(([name, seq]) => [name, buildSeqPosIndex(seq)]));
743
762
  },
744
763
  /**
745
764
  * #getter
@@ -773,22 +792,14 @@ function stateModelFactory() {
773
792
  * #getter
774
793
  */
775
794
  get colStats() {
776
- const r = [];
777
- const columns = this.columns2d;
778
- for (const column of columns) {
779
- for (let j = 0; j < column.length; j++) {
780
- const l = (r[j] ??= {});
781
- const cj = column[j];
782
- l[cj] = (l[cj] ?? 0) + 1;
783
- }
784
- }
785
- return r;
795
+ return columnCountsFromRows(this.columns2d);
786
796
  },
787
797
  /**
788
798
  * #getter
799
+ * per-column residue totals (gaps included)
789
800
  */
790
801
  get colStatsSums() {
791
- return this.colStats.map(col => sum(Object.values(col)));
802
+ return this.colStats.totals;
792
803
  },
793
804
  /**
794
805
  * #getter
@@ -796,14 +807,7 @@ function stateModelFactory() {
796
807
  * Returns 'dna', 'rna', or 'amino'.
797
808
  */
798
809
  get sequenceType() {
799
- const letters = new Set();
800
- for (const stats of this.colStats) {
801
- for (const letter of Object.keys(stats)) {
802
- if (letter !== '-' && letter !== '.') {
803
- letters.add(letter);
804
- }
805
- }
806
- }
810
+ const letters = this.colStats.lettersPresent;
807
811
  // isDna already excludes U (not in the DNA set) and isRna excludes T,
808
812
  // so the set membership alone disambiguates the two
809
813
  const dna = new Set(['A', 'C', 'G', 'T', 'N']);
@@ -818,21 +822,18 @@ function stateModelFactory() {
818
822
  * Used by percent_identity_dynamic color scheme.
819
823
  */
820
824
  get colConsensus() {
821
- const { colStats, colStatsSums } = this;
822
- return colStats.map((stats, i) => {
823
- const total = colStatsSums[i];
824
- if (!total) {
825
- return { letter: '', color: undefined };
826
- }
825
+ const { colStats } = this;
826
+ return Array.from({ length: colStats.numColumns }, (_, col) => {
827
+ const total = colStats.total(col);
827
828
  let maxCount = 0;
828
829
  let letter = '';
829
- for (const [key, val] of Object.entries(stats)) {
830
- if (val > maxCount && key !== '-' && key !== '.') {
831
- maxCount = val;
832
- letter = key;
830
+ colStats.forEachResidue(col, (slot, count) => {
831
+ if (count > maxCount) {
832
+ maxCount = count;
833
+ letter = letterOfResidueSlot(slot);
833
834
  }
834
- }
835
- const proportion = maxCount / total;
835
+ });
836
+ const proportion = total ? maxCount / total : 0;
836
837
  return {
837
838
  letter,
838
839
  color: proportion > 0.4
@@ -848,8 +849,8 @@ function stateModelFactory() {
848
849
  * ref http://www.jalview.org/help/html/colourSchemes/clustal.html
849
850
  */
850
851
  get colClustalX() {
851
- const { colStats, colStatsSums } = this;
852
- return colStats.map((stats, i) => clustalXColumnColors(stats, colStatsSums[i]));
852
+ const { colStats } = this;
853
+ return Array.from({ length: colStats.numColumns }, (_, col) => clustalXColumnColors(colStats, col));
853
854
  },
854
855
  /**
855
856
  * #getter
@@ -858,29 +859,24 @@ function stateModelFactory() {
858
859
  * Returns values 0-1 where 1 = fully conserved, 0 = no conservation.
859
860
  */
860
861
  get conservation() {
861
- const { colStats, colStatsSums, sequenceType } = this;
862
+ const { colStats, sequenceType } = this;
862
863
  const alphabetSize = sequenceType === 'amino' ? 20 : 4;
863
864
  const maxEntropy = Math.log2(alphabetSize);
864
- return colStats.map((stats, i) => {
865
- const total = colStatsSums[i];
866
- if (!total) {
867
- return 0;
868
- }
869
- const gapCount = (stats['-'] ?? 0) + (stats['.'] ?? 0);
865
+ return Array.from({ length: colStats.numColumns }, (_, col) => {
866
+ const total = colStats.total(col);
867
+ const gapCount = colStats.gapCount(col);
870
868
  const nonGapTotal = total - gapCount;
871
- if (nonGapTotal === 0) {
872
- return 0;
873
- }
874
- let entropy = 0;
875
- for (const [letter, count] of Object.entries(stats)) {
876
- if (letter !== '-' && letter !== '.') {
869
+ let score = 0;
870
+ if (nonGapTotal > 0) {
871
+ let entropy = 0;
872
+ colStats.forEachResidue(col, (_slot, count) => {
877
873
  const freq = count / nonGapTotal;
878
874
  entropy -= freq * Math.log2(freq);
879
- }
875
+ });
876
+ score =
877
+ Math.max(0, 1 - entropy / maxEntropy) * (1 - gapCount / total);
880
878
  }
881
- const gapFraction = gapCount / total;
882
- const conservation = Math.max(0, 1 - entropy / maxEntropy);
883
- return conservation * (1 - gapFraction);
879
+ return score;
884
880
  });
885
881
  },
886
882
  /**
@@ -891,7 +887,7 @@ function stateModelFactory() {
891
887
  */
892
888
  get propertyConservation() {
893
889
  return this.sequenceType === 'amino'
894
- ? calculatePropertyConservation(this.colStats, this.colStatsSums)
890
+ ? calculatePropertyConservation(this.colStats)
895
891
  : [];
896
892
  },
897
893
  /**
@@ -1372,7 +1368,13 @@ function stateModelFactory() {
1372
1368
  */
1373
1369
  seqPosToGlobalCol(rowName, seqPos) {
1374
1370
  const seq = self.rowMap.get(rowName);
1375
- return seq ? seqPosToGlobalCol({ row: seq, seqPos }) : 0;
1371
+ if (seq === undefined) {
1372
+ return 0;
1373
+ }
1374
+ const col = self.seqPosGlobalColIndex.get(rowName)?.[seqPos];
1375
+ // past the end of the ungapped sequence: 0 for the degenerate empty-row
1376
+ // case, otherwise one past the last column, as the scan version returned
1377
+ return col ?? (seqPos === 0 ? 0 : seq.length);
1376
1378
  },
1377
1379
  /**
1378
1380
  * #method
@@ -1507,6 +1509,48 @@ function stateModelFactory() {
1507
1509
  .filter(d => self.featureFilters.get(d.accession))
1508
1510
  .toSorted((a, b) => a.start - b.start);
1509
1511
  },
1512
+ /**
1513
+ * #getter
1514
+ * every filtered-on domain annotation resolved to the visible column span
1515
+ * it is drawn across, keyed by row name and in draw order (largest first,
1516
+ * as tidyInterProAnnotations orders them). Resolving these once here rather
1517
+ * than inside each canvas block removes a per-feature, per-block sequence
1518
+ * position conversion from every redraw, and gives the letter renderer the
1519
+ * band colors it needs to keep residues readable on top of the boxes.
1520
+ */
1521
+ get domainBands() {
1522
+ const bands = new Map();
1523
+ for (const [name, annotations] of Object.entries(self.tidyFilteredGatheredInterProAnnotations)) {
1524
+ const rowBands = annotations
1525
+ .map((annotation, stackIndex) => {
1526
+ // InterPro positions are 1-based and inclusive; endCol is the
1527
+ // exclusive column *after* the last residue, taken from that
1528
+ // residue rather than from the next one, so a band stops at its own
1529
+ // last residue instead of stretching across a following gap run
1530
+ const startCol = self.seqPosToVisibleCol(name, annotation.start - 1);
1531
+ const lastCol = self.seqPosToVisibleCol(name, annotation.end - 1);
1532
+ return startCol !== undefined && lastCol !== undefined
1533
+ ? { annotation, startCol, endCol: lastCol + 1, stackIndex }
1534
+ : undefined;
1535
+ })
1536
+ .filter(notEmpty);
1537
+ if (rowBands.length > 0) {
1538
+ bands.set(name, rowBands);
1539
+ }
1540
+ }
1541
+ return bands;
1542
+ },
1543
+ /**
1544
+ * #getter
1545
+ * the same bands ordered by start column, for left-to-right sweeps (the
1546
+ * letter renderer walks columns and needs the band covering each one)
1547
+ */
1548
+ get domainBandsByStart() {
1549
+ return new Map([...this.domainBands].map(([name, bands]) => [
1550
+ name,
1551
+ bands.toSorted((a, b) => a.startCol - b.startCol),
1552
+ ]));
1553
+ },
1510
1554
  /**
1511
1555
  * #getter
1512
1556
  * domain annotations under the mouse, hit-tested against the exact visible
@@ -1515,18 +1559,56 @@ function stateModelFactory() {
1515
1559
  get mouseOverDomains() {
1516
1560
  const { mouseCol } = self;
1517
1561
  const name = self.mouseOverRowName;
1518
- if (name !== undefined && mouseCol !== undefined) {
1519
- const entry = self.tidyFilteredGatheredInterProAnnotations[name] ?? [];
1520
- return entry.filter(d => {
1521
- const m1 = self.seqPosToVisibleCol(name, d.start - 1);
1522
- const m2 = self.seqPosToVisibleCol(name, d.end);
1523
- return (m1 !== undefined &&
1524
- m2 !== undefined &&
1525
- mouseCol >= m1 &&
1526
- mouseCol < m2);
1527
- });
1562
+ return name !== undefined && mouseCol !== undefined
1563
+ ? (this.domainBands.get(name) ?? [])
1564
+ .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
1565
+ .map(b => b.annotation)
1566
+ : [];
1567
+ },
1568
+ /**
1569
+ * #getter
1570
+ * row index of the reference row (`relativeTo`), undefined when unset
1571
+ */
1572
+ get referenceRowIndex() {
1573
+ const { relativeTo } = self;
1574
+ return relativeTo === undefined
1575
+ ? undefined
1576
+ : self.rowNamesSet.get(relativeTo);
1577
+ },
1578
+ /**
1579
+ * #getter
1580
+ * row indices highlighted by the current tree hover (a hovered internal
1581
+ * node highlights every tip below it). Shared by the tree and MSA overlay
1582
+ * canvases so they cannot disagree, and resolved through the memoized
1583
+ * name->index map rather than rebuilding a lookup on each mouse move.
1584
+ */
1585
+ get hoveredRowIndices() {
1586
+ const { hoveredTreeNode, rowNamesSet } = self;
1587
+ return hoveredTreeNode
1588
+ ? hoveredTreeNode.descendantNames
1589
+ .map(name => rowNamesSet.get(name))
1590
+ .filter(notEmpty)
1591
+ : [];
1592
+ },
1593
+ /**
1594
+ * #getter
1595
+ * contiguous runs of `highlightedColumns`, so a run of highlighted columns
1596
+ * draws as one bordered band. Computed here because the overlay canvas
1597
+ * redraws on every mouse move while the highlight itself rarely changes.
1598
+ */
1599
+ get highlightedColumnRuns() {
1600
+ const { highlightedColumns } = self;
1601
+ const runs = [];
1602
+ for (const col of [...(highlightedColumns ?? [])].sort((a, b) => a - b)) {
1603
+ const last = runs.at(-1);
1604
+ if (last && col === last.end + 1) {
1605
+ last.end = col;
1606
+ }
1607
+ else {
1608
+ runs.push({ start: col, end: col });
1609
+ }
1528
1610
  }
1529
- return [];
1611
+ return runs;
1530
1612
  },
1531
1613
  /**
1532
1614
  * #getter
@@ -1539,14 +1621,14 @@ function stateModelFactory() {
1539
1621
  if (mouseCol === undefined) {
1540
1622
  return undefined;
1541
1623
  }
1542
- const stats = self.colStats[mouseCol];
1543
- const total = self.colStatsSums[mouseCol];
1544
- if (!stats || !total) {
1624
+ const { colStats } = self;
1625
+ const total = colStats.total(mouseCol);
1626
+ if (mouseCol >= colStats.numColumns || !total) {
1545
1627
  return undefined;
1546
1628
  }
1547
- const gaps = (stats['-'] ?? 0) + (stats['.'] ?? 0);
1548
- const distribution = Object.entries(stats)
1549
- .filter(([letter]) => letter !== '-' && letter !== '.')
1629
+ const gaps = colStats.gapCount(mouseCol);
1630
+ const distribution = colStats
1631
+ .residueEntries(mouseCol)
1550
1632
  .sort((a, b) => b[1] - a[1]);
1551
1633
  const consensus = distribution[0];
1552
1634
  return {
@@ -1657,6 +1739,25 @@ function stateModelFactory() {
1657
1739
  this.initFilter(key);
1658
1740
  }
1659
1741
  }));
1742
+ // track the live device pixel ratio so canvas backing stores re-scale
1743
+ // when the window moves between monitors or the browser zooms. The
1744
+ // matchMedia query is pinned to the current ratio, so each change
1745
+ // re-registers against the new one to keep tracking further moves.
1746
+ if (typeof window !== 'undefined' &&
1747
+ typeof window.matchMedia === 'function') {
1748
+ const query = () => window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
1749
+ let mql = query();
1750
+ const onChange = () => {
1751
+ self.setHighResScaleFactor(window.devicePixelRatio);
1752
+ mql.removeEventListener('change', onChange);
1753
+ mql = query();
1754
+ mql.addEventListener('change', onChange);
1755
+ };
1756
+ mql.addEventListener('change', onChange);
1757
+ addDisposer(self, () => {
1758
+ mql.removeEventListener('change', onChange);
1759
+ });
1760
+ }
1660
1761
  // autorun opens treeFilehandle. generation guard: if the filehandle
1661
1762
  // changes mid-fetch, a slower earlier request must not clobber the data
1662
1763
  // (or loading flag) set by the newer one
@@ -1804,16 +1905,30 @@ function stateModelFactory() {
1804
1905
  }));
1805
1906
  // Keep the parse chain warm: reading self.columns transitively holds
1806
1907
  // self.MSA (parseMSA) computed alive, so it is parsed once per data
1807
- // change rather than re-parsed on every non-reactive access. colStats is
1808
- // additionally held for dynamic color schemes. Do not remove.
1908
+ // change rather than re-parsed on every non-reactive access. Do not
1909
+ // remove.
1809
1910
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
1810
1911
  // xref problem https://github.com/GMOD/react-msaview/issues/75
1912
+ //
1913
+ // The column statistics are held for the same reason whenever something
1914
+ // reads them off the reactive path: dynamic color schemes, and the hover
1915
+ // tooltip, which is read from a mousemove handler. Without this the
1916
+ // tooltip's cost depends on whether some visible track happens to be
1917
+ // observing them, so closing the conservation track would silently turn
1918
+ // every mouse move into a full-alignment recount.
1811
1919
  addDisposer(self, autorun(() => {
1812
1920
  if (self.colorSchemeName.includes('dynamic')) {
1813
1921
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1814
1922
  self.colStats;
1923
+ }
1924
+ if (self.showColumnStats) {
1925
+ // everything the hover tooltip reads per column
1926
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1927
+ self.colStats;
1928
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1929
+ self.conservation;
1815
1930
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1816
- self.colStatsSums;
1931
+ self.propertyConservation;
1817
1932
  }
1818
1933
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1819
1934
  self.columns;
@@ -1836,7 +1951,7 @@ function stateModelFactory() {
1836
1951
  },
1837
1952
  }))
1838
1953
  .postProcessSnapshot(({ data, ...rest }) => ({
1839
- // per-property defaults are stripped by types.stripDefault; the only thing
1954
+ // per-property defaults are stripped by the stripDefault helper; the only thing
1840
1955
  // it can't express is this cross-field rule: drop inline tree/msa/metadata
1841
1956
  // when a sibling filehandle can refetch them, keeping sessions/URLs small
1842
1957
  ...rest,