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/src/model.ts CHANGED
@@ -13,9 +13,11 @@ import {
13
13
  parseNewick,
14
14
  } from 'msa-parsers'
15
15
 
16
+
16
17
  import { calculateBlocks } from './calculateBlocks.ts'
17
18
  import { clustalXColumnColors } from './clustalX.ts'
18
19
  import colorSchemes from './colorSchemes.ts'
20
+ import { columnCountsFromRows, letterOfResidueSlot } from './columnCounts.ts'
19
21
  import ConservationTrack from './components/ConservationTrack.tsx'
20
22
  import TextTrack from './components/TextTrack.tsx'
21
23
  import {
@@ -70,13 +72,19 @@ import {
70
72
  visibleColToGlobalCol,
71
73
  visibleColToSeqPosForRow,
72
74
  } from './rowCoordinateCalculations.ts'
73
- import { seqPosToGlobalCol } from './seqPosToGlobalCol.ts'
75
+ import { buildSeqPosIndex } from './seqPosToGlobalCol.ts'
76
+ import { stripDefault } from './stripDefault.ts'
74
77
  import { computeRowInsertions, len, skipBlanks, transform } from './util.ts'
75
78
  import { saveAs } from './vendor/fileSaver.ts'
76
79
 
77
80
  import type { HierarchyNode } from './hierarchy.ts'
78
81
  import type { InterProScanResults } from './launchInterProScan.ts'
79
- import type { BasicTrack, NodeWithIds, NodeWithIdsAndLength } from './types.ts'
82
+ import type {
83
+ BasicTrack,
84
+ DomainBand,
85
+ NodeWithIds,
86
+ NodeWithIdsAndLength,
87
+ } from './types.ts'
80
88
  import type { FileLocation as FileLocationType } from '@jbrowse/core/util/types'
81
89
  import type { Instance } from '@jbrowse/mobx-state-tree'
82
90
  import type { Theme } from '@mui/material'
@@ -124,22 +132,19 @@ function stateModelFactory() {
124
132
  /**
125
133
  * #property
126
134
  */
127
- showDomains: types.stripDefault(types.boolean, defaultShowDomains),
135
+ showDomains: stripDefault(types.boolean, defaultShowDomains),
128
136
  /**
129
137
  * #property
130
138
  */
131
- hideGaps: types.stripDefault(types.boolean, defaultHideGaps),
139
+ hideGaps: stripDefault(types.boolean, defaultHideGaps),
132
140
  /**
133
141
  * #property
134
142
  */
135
- allowedGappyness: types.stripDefault(
136
- types.number,
137
- defaultAllowedGappyness,
138
- ),
143
+ allowedGappyness: stripDefault(types.number, defaultAllowedGappyness),
139
144
  /**
140
145
  * #property
141
146
  */
142
- subFeatureRows: types.stripDefault(types.boolean, defaultSubFeatureRows),
147
+ subFeatureRows: stripDefault(types.boolean, defaultSubFeatureRows),
143
148
 
144
149
  /**
145
150
  * #property
@@ -150,43 +155,43 @@ function stateModelFactory() {
150
155
  /**
151
156
  * #property
152
157
  */
153
- drawMsaLetters: types.stripDefault(types.boolean, defaultDrawMsaLetters),
158
+ drawMsaLetters: stripDefault(types.boolean, defaultDrawMsaLetters),
154
159
 
155
160
  /**
156
161
  * #property
157
162
  * zoom in/out on plain mouse-wheel without holding ctrl
158
163
  */
159
- scrollZoom: types.stripDefault(types.boolean, defaultScrollZoom),
164
+ scrollZoom: stripDefault(types.boolean, defaultScrollZoom),
160
165
 
161
166
  /**
162
167
  * #property
163
168
  * height of the div containing the view, px
164
169
  */
165
- height: types.stripDefault(types.number, defaultHeight),
170
+ height: stripDefault(types.number, defaultHeight),
166
171
 
167
172
  /**
168
173
  * #property
169
174
  * height of each row, px
170
175
  */
171
- rowHeight: types.stripDefault(types.number, defaultRowHeight),
176
+ rowHeight: stripDefault(types.number, defaultRowHeight),
172
177
 
173
178
  /**
174
179
  * #property
175
180
  * scroll position, Y-offset, px
176
181
  */
177
- scrollY: types.stripDefault(types.number, defaultScrollY),
182
+ scrollY: stripDefault(types.number, defaultScrollY),
178
183
 
179
184
  /**
180
185
  * #property
181
186
  * scroll position, X-offset, px
182
187
  */
183
- scrollX: types.stripDefault(types.number, defaultScrollX),
188
+ scrollX: stripDefault(types.number, defaultScrollX),
184
189
 
185
190
  /**
186
191
  * #property
187
192
  * width of columns, px
188
193
  */
189
- colWidth: types.stripDefault(types.number, defaultColWidth),
194
+ colWidth: stripDefault(types.number, defaultColWidth),
190
195
 
191
196
  /**
192
197
  * #property
@@ -216,17 +221,14 @@ function stateModelFactory() {
216
221
  /**
217
222
  * #property
218
223
  */
219
- currentAlignment: types.stripDefault(
220
- types.number,
221
- defaultCurrentAlignment,
222
- ),
224
+ currentAlignment: stripDefault(types.number, defaultCurrentAlignment),
223
225
 
224
226
  /**
225
227
  * #property
226
228
  * array of tree parent nodes that are 'collapsed' (all children are
227
229
  * hidden)
228
230
  */
229
- collapsed: types.stripDefault(types.array(types.string), []),
231
+ collapsed: stripDefault(types.array(types.string), []),
230
232
 
231
233
  /**
232
234
  * #property
@@ -237,7 +239,7 @@ function stateModelFactory() {
237
239
  * #property
238
240
  * turned off tracks
239
241
  */
240
- turnedOffTracks: types.stripDefault(types.map(types.boolean), {}),
242
+ turnedOffTracks: stripDefault(types.map(types.boolean), {}),
241
243
 
242
244
  /**
243
245
  * #property
@@ -253,7 +255,7 @@ function stateModelFactory() {
253
255
  /**
254
256
  * #property
255
257
  */
256
- featureFilters: types.stripDefault(types.map(types.boolean), {}),
258
+ featureFilters: stripDefault(types.map(types.boolean), {}),
257
259
  /**
258
260
  * #property
259
261
  */
@@ -820,15 +822,19 @@ function stateModelFactory() {
820
822
  if (strs.length === 0) {
821
823
  return []
822
824
  }
823
- const numCols = strs[0]!.length
825
+ // ragged input (a3m, hand-edited fasta) can have rows shorter than the
826
+ // alignment; the widest row defines the column count and a row that
827
+ // stops early counts as gapped for the remainder
828
+ const numCols = strs.reduce((max, str) => Math.max(max, str.length), 0)
824
829
  const numRows = strs.length
825
830
  const threshold = Math.ceil((realAllowedGappyness / 100) * numRows)
826
- const blankCounts = new Uint16Array(numCols)
831
+ const blankCounts = new Uint32Array(numCols)
827
832
  for (let j = 0; j < numRows; j++) {
828
833
  const str = strs[j]!
834
+ const len = str.length
829
835
  for (let i = 0; i < numCols; i++) {
830
836
  // bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
831
- if ((str.charCodeAt(i) - 45) >>> 0 <= 1) {
837
+ if (i >= len || (str.charCodeAt(i) - 45) >>> 0 <= 1) {
832
838
  blankCounts[i]!++
833
839
  }
834
840
  }
@@ -876,9 +882,27 @@ function stateModelFactory() {
876
882
  },
877
883
  /**
878
884
  * #getter
885
+ * number of rows the alignment occupies on screen. This is the leaf count,
886
+ * not `rows.length`: a tree leaf with no matching MSA row still takes up a
887
+ * row of vertical space (drawn blank), so row hit-testing and fit-to-height
888
+ * must count it.
879
889
  */
880
890
  get numRows() {
881
- return this.rows.length
891
+ return this.leaves.length
892
+ },
893
+
894
+ /**
895
+ * #getter
896
+ * per-row index of the global column holding each ungapped sequence
897
+ * position, so seqPos -> column is a lookup rather than a scan of the row.
898
+ * The domain overlay resolves thousands of these per redraw.
899
+ */
900
+ get seqPosGlobalColIndex() {
901
+ return new Map(
902
+ this.rows.map(
903
+ ([name, seq]) => [name, buildSeqPosIndex(seq)] as const,
904
+ ),
905
+ )
882
906
  },
883
907
 
884
908
  /**
@@ -918,23 +942,15 @@ function stateModelFactory() {
918
942
  * #getter
919
943
  */
920
944
  get colStats() {
921
- const r: Record<string, number>[] = []
922
- const columns = this.columns2d
923
- for (const column of columns) {
924
- for (let j = 0; j < column.length; j++) {
925
- const l = (r[j] ??= {})
926
- const cj = column[j]!
927
- l[cj] = (l[cj] ?? 0) + 1
928
- }
929
- }
930
- return r
945
+ return columnCountsFromRows(this.columns2d)
931
946
  },
932
947
 
933
948
  /**
934
949
  * #getter
950
+ * per-column residue totals (gaps included)
935
951
  */
936
952
  get colStatsSums() {
937
- return this.colStats.map(col => sum(Object.values(col)))
953
+ return this.colStats.totals
938
954
  },
939
955
 
940
956
  /**
@@ -943,14 +959,7 @@ function stateModelFactory() {
943
959
  * Returns 'dna', 'rna', or 'amino'.
944
960
  */
945
961
  get sequenceType(): 'dna' | 'rna' | 'amino' {
946
- const letters = new Set<string>()
947
- for (const stats of this.colStats) {
948
- for (const letter of Object.keys(stats)) {
949
- if (letter !== '-' && letter !== '.') {
950
- letters.add(letter)
951
- }
952
- }
953
- }
962
+ const letters = this.colStats.lettersPresent
954
963
  // isDna already excludes U (not in the DNA set) and isRna excludes T,
955
964
  // so the set membership alone disambiguates the two
956
965
  const dna = new Set(['A', 'C', 'G', 'T', 'N'])
@@ -966,21 +975,18 @@ function stateModelFactory() {
966
975
  * Used by percent_identity_dynamic color scheme.
967
976
  */
968
977
  get colConsensus() {
969
- const { colStats, colStatsSums } = this
970
- return colStats.map((stats, i) => {
971
- const total = colStatsSums[i]
972
- if (!total) {
973
- return { letter: '', color: undefined }
974
- }
978
+ const { colStats } = this
979
+ return Array.from({ length: colStats.numColumns }, (_, col) => {
980
+ const total = colStats.total(col)
975
981
  let maxCount = 0
976
982
  let letter = ''
977
- for (const [key, val] of Object.entries(stats)) {
978
- if (val > maxCount && key !== '-' && key !== '.') {
979
- maxCount = val
980
- letter = key
983
+ colStats.forEachResidue(col, (slot, count) => {
984
+ if (count > maxCount) {
985
+ maxCount = count
986
+ letter = letterOfResidueSlot(slot)
981
987
  }
982
- }
983
- const proportion = maxCount / total
988
+ })
989
+ const proportion = total ? maxCount / total : 0
984
990
  return {
985
991
  letter,
986
992
  color:
@@ -998,9 +1004,9 @@ function stateModelFactory() {
998
1004
  * ref http://www.jalview.org/help/html/colourSchemes/clustal.html
999
1005
  */
1000
1006
  get colClustalX() {
1001
- const { colStats, colStatsSums } = this
1002
- return colStats.map((stats, i) =>
1003
- clustalXColumnColors(stats, colStatsSums[i]!),
1007
+ const { colStats } = this
1008
+ return Array.from({ length: colStats.numColumns }, (_, col) =>
1009
+ clustalXColumnColors(colStats, col),
1004
1010
  )
1005
1011
  },
1006
1012
 
@@ -1011,33 +1017,25 @@ function stateModelFactory() {
1011
1017
  * Returns values 0-1 where 1 = fully conserved, 0 = no conservation.
1012
1018
  */
1013
1019
  get conservation() {
1014
- const { colStats, colStatsSums, sequenceType } = this
1020
+ const { colStats, sequenceType } = this
1015
1021
  const alphabetSize = sequenceType === 'amino' ? 20 : 4
1016
1022
  const maxEntropy = Math.log2(alphabetSize)
1017
1023
 
1018
- return colStats.map((stats, i) => {
1019
- const total = colStatsSums[i]
1020
- if (!total) {
1021
- return 0
1022
- }
1023
-
1024
- const gapCount = (stats['-'] ?? 0) + (stats['.'] ?? 0)
1024
+ return Array.from({ length: colStats.numColumns }, (_, col) => {
1025
+ const total = colStats.total(col)
1026
+ const gapCount = colStats.gapCount(col)
1025
1027
  const nonGapTotal = total - gapCount
1026
- if (nonGapTotal === 0) {
1027
- return 0
1028
- }
1029
-
1030
- let entropy = 0
1031
- for (const [letter, count] of Object.entries(stats)) {
1032
- if (letter !== '-' && letter !== '.') {
1028
+ let score = 0
1029
+ if (nonGapTotal > 0) {
1030
+ let entropy = 0
1031
+ colStats.forEachResidue(col, (_slot, count) => {
1033
1032
  const freq = count / nonGapTotal
1034
1033
  entropy -= freq * Math.log2(freq)
1035
- }
1034
+ })
1035
+ score =
1036
+ Math.max(0, 1 - entropy / maxEntropy) * (1 - gapCount / total)
1036
1037
  }
1037
-
1038
- const gapFraction = gapCount / total
1039
- const conservation = Math.max(0, 1 - entropy / maxEntropy)
1040
- return conservation * (1 - gapFraction)
1038
+ return score
1041
1039
  })
1042
1040
  },
1043
1041
  /**
@@ -1048,7 +1046,7 @@ function stateModelFactory() {
1048
1046
  */
1049
1047
  get propertyConservation() {
1050
1048
  return this.sequenceType === 'amino'
1051
- ? calculatePropertyConservation(this.colStats, this.colStatsSums)
1049
+ ? calculatePropertyConservation(this.colStats)
1052
1050
  : []
1053
1051
  },
1054
1052
  /**
@@ -1584,7 +1582,13 @@ function stateModelFactory() {
1584
1582
  */
1585
1583
  seqPosToGlobalCol(rowName: string, seqPos: number) {
1586
1584
  const seq = self.rowMap.get(rowName)
1587
- return seq ? seqPosToGlobalCol({ row: seq, seqPos }) : 0
1585
+ if (seq === undefined) {
1586
+ return 0
1587
+ }
1588
+ const col = self.seqPosGlobalColIndex.get(rowName)?.[seqPos]
1589
+ // past the end of the ungapped sequence: 0 for the degenerate empty-row
1590
+ // case, otherwise one past the last column, as the scan version returned
1591
+ return col ?? (seqPos === 0 ? 0 : seq.length)
1588
1592
  },
1589
1593
 
1590
1594
  /**
@@ -1740,6 +1744,60 @@ function stateModelFactory() {
1740
1744
  .toSorted((a, b) => a.start - b.start)
1741
1745
  },
1742
1746
 
1747
+ /**
1748
+ * #getter
1749
+ * every filtered-on domain annotation resolved to the visible column span
1750
+ * it is drawn across, keyed by row name and in draw order (largest first,
1751
+ * as tidyInterProAnnotations orders them). Resolving these once here rather
1752
+ * than inside each canvas block removes a per-feature, per-block sequence
1753
+ * position conversion from every redraw, and gives the letter renderer the
1754
+ * band colors it needs to keep residues readable on top of the boxes.
1755
+ */
1756
+ get domainBands() {
1757
+ const bands = new Map<string, DomainBand[]>()
1758
+ for (const [name, annotations] of Object.entries(
1759
+ self.tidyFilteredGatheredInterProAnnotations,
1760
+ )) {
1761
+ const rowBands = annotations
1762
+ .map((annotation, stackIndex) => {
1763
+ // InterPro positions are 1-based and inclusive; endCol is the
1764
+ // exclusive column *after* the last residue, taken from that
1765
+ // residue rather than from the next one, so a band stops at its own
1766
+ // last residue instead of stretching across a following gap run
1767
+ const startCol = self.seqPosToVisibleCol(
1768
+ name,
1769
+ annotation.start - 1,
1770
+ )
1771
+ const lastCol = self.seqPosToVisibleCol(name, annotation.end - 1)
1772
+ return startCol !== undefined && lastCol !== undefined
1773
+ ? { annotation, startCol, endCol: lastCol + 1, stackIndex }
1774
+ : undefined
1775
+ })
1776
+ .filter(notEmpty)
1777
+ if (rowBands.length > 0) {
1778
+ bands.set(name, rowBands)
1779
+ }
1780
+ }
1781
+ return bands
1782
+ },
1783
+
1784
+ /**
1785
+ * #getter
1786
+ * the same bands ordered by start column, for left-to-right sweeps (the
1787
+ * letter renderer walks columns and needs the band covering each one)
1788
+ */
1789
+ get domainBandsByStart() {
1790
+ return new Map(
1791
+ [...this.domainBands].map(
1792
+ ([name, bands]) =>
1793
+ [
1794
+ name,
1795
+ bands.toSorted((a, b) => a.startCol - b.startCol),
1796
+ ] as const,
1797
+ ),
1798
+ )
1799
+ },
1800
+
1743
1801
  /**
1744
1802
  * #getter
1745
1803
  * domain annotations under the mouse, hit-tested against the exact visible
@@ -1748,20 +1806,60 @@ function stateModelFactory() {
1748
1806
  get mouseOverDomains() {
1749
1807
  const { mouseCol } = self
1750
1808
  const name = self.mouseOverRowName
1751
- if (name !== undefined && mouseCol !== undefined) {
1752
- const entry = self.tidyFilteredGatheredInterProAnnotations[name] ?? []
1753
- return entry.filter(d => {
1754
- const m1 = self.seqPosToVisibleCol(name, d.start - 1)
1755
- const m2 = self.seqPosToVisibleCol(name, d.end)
1756
- return (
1757
- m1 !== undefined &&
1758
- m2 !== undefined &&
1759
- mouseCol >= m1 &&
1760
- mouseCol < m2
1761
- )
1762
- })
1809
+ return name !== undefined && mouseCol !== undefined
1810
+ ? (this.domainBands.get(name) ?? [])
1811
+ .filter(b => mouseCol >= b.startCol && mouseCol < b.endCol)
1812
+ .map(b => b.annotation)
1813
+ : []
1814
+ },
1815
+
1816
+ /**
1817
+ * #getter
1818
+ * row index of the reference row (`relativeTo`), undefined when unset
1819
+ */
1820
+ get referenceRowIndex() {
1821
+ const { relativeTo } = self
1822
+ return relativeTo === undefined
1823
+ ? undefined
1824
+ : self.rowNamesSet.get(relativeTo)
1825
+ },
1826
+
1827
+ /**
1828
+ * #getter
1829
+ * row indices highlighted by the current tree hover (a hovered internal
1830
+ * node highlights every tip below it). Shared by the tree and MSA overlay
1831
+ * canvases so they cannot disagree, and resolved through the memoized
1832
+ * name->index map rather than rebuilding a lookup on each mouse move.
1833
+ */
1834
+ get hoveredRowIndices() {
1835
+ const { hoveredTreeNode, rowNamesSet } = self
1836
+ return hoveredTreeNode
1837
+ ? hoveredTreeNode.descendantNames
1838
+ .map(name => rowNamesSet.get(name))
1839
+ .filter(notEmpty)
1840
+ : []
1841
+ },
1842
+
1843
+ /**
1844
+ * #getter
1845
+ * contiguous runs of `highlightedColumns`, so a run of highlighted columns
1846
+ * draws as one bordered band. Computed here because the overlay canvas
1847
+ * redraws on every mouse move while the highlight itself rarely changes.
1848
+ */
1849
+ get highlightedColumnRuns() {
1850
+ const { highlightedColumns } = self
1851
+ const runs: { start: number; end: number }[] = []
1852
+ for (const col of [...(highlightedColumns ?? [])].sort(
1853
+ (a, b) => a - b,
1854
+ )) {
1855
+ const last = runs.at(-1)
1856
+ if (last && col === last.end + 1) {
1857
+ last.end = col
1858
+ } else {
1859
+ runs.push({ start: col, end: col })
1860
+ }
1763
1861
  }
1764
- return []
1862
+ return runs
1765
1863
  },
1766
1864
 
1767
1865
  /**
@@ -1775,14 +1873,14 @@ function stateModelFactory() {
1775
1873
  if (mouseCol === undefined) {
1776
1874
  return undefined
1777
1875
  }
1778
- const stats = self.colStats[mouseCol]
1779
- const total = self.colStatsSums[mouseCol]
1780
- if (!stats || !total) {
1876
+ const { colStats } = self
1877
+ const total = colStats.total(mouseCol)
1878
+ if (mouseCol >= colStats.numColumns || !total) {
1781
1879
  return undefined
1782
1880
  }
1783
- const gaps = (stats['-'] ?? 0) + (stats['.'] ?? 0)
1784
- const distribution = Object.entries(stats)
1785
- .filter(([letter]) => letter !== '-' && letter !== '.')
1881
+ const gaps = colStats.gapCount(mouseCol)
1882
+ const distribution = colStats
1883
+ .residueEntries(mouseCol)
1786
1884
  .sort((a, b) => b[1] - a[1])
1787
1885
  const consensus = distribution[0]
1788
1886
  return {
@@ -1914,6 +2012,29 @@ function stateModelFactory() {
1914
2012
  }),
1915
2013
  )
1916
2014
 
2015
+ // track the live device pixel ratio so canvas backing stores re-scale
2016
+ // when the window moves between monitors or the browser zooms. The
2017
+ // matchMedia query is pinned to the current ratio, so each change
2018
+ // re-registers against the new one to keep tracking further moves.
2019
+ if (
2020
+ typeof window !== 'undefined' &&
2021
+ typeof window.matchMedia === 'function'
2022
+ ) {
2023
+ const query = () =>
2024
+ window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
2025
+ let mql = query()
2026
+ const onChange = () => {
2027
+ self.setHighResScaleFactor(window.devicePixelRatio)
2028
+ mql.removeEventListener('change', onChange)
2029
+ mql = query()
2030
+ mql.addEventListener('change', onChange)
2031
+ }
2032
+ mql.addEventListener('change', onChange)
2033
+ addDisposer(self, () => {
2034
+ mql.removeEventListener('change', onChange)
2035
+ })
2036
+ }
2037
+
1917
2038
  // autorun opens treeFilehandle. generation guard: if the filehandle
1918
2039
  // changes mid-fetch, a slower earlier request must not clobber the data
1919
2040
  // (or loading flag) set by the newer one
@@ -2084,18 +2205,32 @@ function stateModelFactory() {
2084
2205
 
2085
2206
  // Keep the parse chain warm: reading self.columns transitively holds
2086
2207
  // self.MSA (parseMSA) computed alive, so it is parsed once per data
2087
- // change rather than re-parsed on every non-reactive access. colStats is
2088
- // additionally held for dynamic color schemes. Do not remove.
2208
+ // change rather than re-parsed on every non-reactive access. Do not
2209
+ // remove.
2089
2210
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
2090
2211
  // xref problem https://github.com/GMOD/react-msaview/issues/75
2212
+ //
2213
+ // The column statistics are held for the same reason whenever something
2214
+ // reads them off the reactive path: dynamic color schemes, and the hover
2215
+ // tooltip, which is read from a mousemove handler. Without this the
2216
+ // tooltip's cost depends on whether some visible track happens to be
2217
+ // observing them, so closing the conservation track would silently turn
2218
+ // every mouse move into a full-alignment recount.
2091
2219
  addDisposer(
2092
2220
  self,
2093
2221
  autorun(() => {
2094
2222
  if (self.colorSchemeName.includes('dynamic')) {
2095
2223
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2096
2224
  self.colStats
2225
+ }
2226
+ if (self.showColumnStats) {
2227
+ // everything the hover tooltip reads per column
2228
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2229
+ self.colStats
2230
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2231
+ self.conservation
2097
2232
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2098
- self.colStatsSums
2233
+ self.propertyConservation
2099
2234
  }
2100
2235
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2101
2236
  self.columns
@@ -2134,7 +2269,7 @@ function stateModelFactory() {
2134
2269
  },
2135
2270
  }))
2136
2271
  .postProcessSnapshot(({ data, ...rest }) => ({
2137
- // per-property defaults are stripped by types.stripDefault; the only thing
2272
+ // per-property defaults are stripped by the stripDefault helper; the only thing
2138
2273
  // it can't express is this cross-field rule: drop inline tree/msa/metadata
2139
2274
  // when a sibling filehandle can refetch them, keeping sessions/URLs small
2140
2275
  ...rest,
@@ -1,21 +1,10 @@
1
1
  import { describe, expect, test } from 'vitest'
2
2
 
3
+ import { columnCountsFromColumns } from './columnCounts.ts'
3
4
  import { calculatePropertyConservation } from './propertyConservation.ts'
4
5
 
5
- function counts(letters: string) {
6
- const stats: Record<string, number> = {}
7
- for (const c of letters) {
8
- stats[c] = (stats[c] ?? 0) + 1
9
- }
10
- return stats
11
- }
12
-
13
6
  function propertyConservation(columns: string[]) {
14
- const colStats = columns.map(counts)
15
- const colStatsSums = colStats.map(s =>
16
- Object.values(s).reduce((a, b) => a + b, 0),
17
- )
18
- return calculatePropertyConservation(colStats, colStatsSums)
7
+ return calculatePropertyConservation(columnCountsFromColumns(columns))
19
8
  }
20
9
 
21
10
  describe('property conservation', () => {