react-msaview 5.6.1 → 5.6.3

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/index.d.ts +2 -1
  38. package/dist/model/msaModel.js +4 -3
  39. package/dist/model/msaModel.js.map +1 -1
  40. package/dist/model/treeModel.js +9 -8
  41. package/dist/model/treeModel.js.map +1 -1
  42. package/dist/model.d.ts +52 -10
  43. package/dist/model.js +208 -93
  44. package/dist/model.js.map +1 -1
  45. package/dist/propertyConservation.d.ts +2 -1
  46. package/dist/propertyConservation.js +36 -25
  47. package/dist/propertyConservation.js.map +1 -1
  48. package/dist/seqPosToGlobalCol.d.ts +7 -0
  49. package/dist/seqPosToGlobalCol.js +17 -0
  50. package/dist/seqPosToGlobalCol.js.map +1 -1
  51. package/dist/stripDefault.d.ts +21 -0
  52. package/dist/stripDefault.js +26 -0
  53. package/dist/stripDefault.js.map +1 -0
  54. package/dist/types.d.ts +16 -0
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.js +1 -1
  57. package/package.json +3 -3
  58. package/src/clustalX.test.ts +9 -11
  59. package/src/clustalX.ts +27 -28
  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/index.ts +7 -0
  77. package/src/model/msaModel.ts +4 -6
  78. package/src/model/treeModel.ts +9 -14
  79. package/src/model.ts +241 -112
  80. package/src/propertyConservation.test.ts +2 -13
  81. package/src/propertyConservation.ts +40 -31
  82. package/src/rowSpace.test.ts +46 -0
  83. package/src/seqPosToGlobalCol.ts +18 -0
  84. package/src/stripDefault.test.ts +48 -0
  85. package/src/stripDefault.ts +35 -0
  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,25 +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(
143
- types.boolean,
144
- defaultSubFeatureRows,
145
- ),
147
+ subFeatureRows: stripDefault(types.boolean, defaultSubFeatureRows),
146
148
 
147
149
  /**
148
150
  * #property
@@ -153,46 +155,43 @@ function stateModelFactory() {
153
155
  /**
154
156
  * #property
155
157
  */
156
- drawMsaLetters: types.stripDefault(
157
- types.boolean,
158
- defaultDrawMsaLetters,
159
- ),
158
+ drawMsaLetters: stripDefault(types.boolean, defaultDrawMsaLetters),
160
159
 
161
160
  /**
162
161
  * #property
163
162
  * zoom in/out on plain mouse-wheel without holding ctrl
164
163
  */
165
- scrollZoom: types.stripDefault(types.boolean, defaultScrollZoom),
164
+ scrollZoom: stripDefault(types.boolean, defaultScrollZoom),
166
165
 
167
166
  /**
168
167
  * #property
169
168
  * height of the div containing the view, px
170
169
  */
171
- height: types.stripDefault(types.number, defaultHeight),
170
+ height: stripDefault(types.number, defaultHeight),
172
171
 
173
172
  /**
174
173
  * #property
175
174
  * height of each row, px
176
175
  */
177
- rowHeight: types.stripDefault(types.number, defaultRowHeight),
176
+ rowHeight: stripDefault(types.number, defaultRowHeight),
178
177
 
179
178
  /**
180
179
  * #property
181
180
  * scroll position, Y-offset, px
182
181
  */
183
- scrollY: types.stripDefault(types.number, defaultScrollY),
182
+ scrollY: stripDefault(types.number, defaultScrollY),
184
183
 
185
184
  /**
186
185
  * #property
187
186
  * scroll position, X-offset, px
188
187
  */
189
- scrollX: types.stripDefault(types.number, defaultScrollX),
188
+ scrollX: stripDefault(types.number, defaultScrollX),
190
189
 
191
190
  /**
192
191
  * #property
193
192
  * width of columns, px
194
193
  */
195
- colWidth: types.stripDefault(types.number, defaultColWidth),
194
+ colWidth: stripDefault(types.number, defaultColWidth),
196
195
 
197
196
  /**
198
197
  * #property
@@ -222,17 +221,14 @@ function stateModelFactory() {
222
221
  /**
223
222
  * #property
224
223
  */
225
- currentAlignment: types.stripDefault(
226
- types.number,
227
- defaultCurrentAlignment,
228
- ),
224
+ currentAlignment: stripDefault(types.number, defaultCurrentAlignment),
229
225
 
230
226
  /**
231
227
  * #property
232
228
  * array of tree parent nodes that are 'collapsed' (all children are
233
229
  * hidden)
234
230
  */
235
- collapsed: types.stripDefault(types.array(types.string), []),
231
+ collapsed: stripDefault(types.array(types.string), []),
236
232
 
237
233
  /**
238
234
  * #property
@@ -243,7 +239,7 @@ function stateModelFactory() {
243
239
  * #property
244
240
  * turned off tracks
245
241
  */
246
- turnedOffTracks: types.stripDefault(types.map(types.boolean), {}),
242
+ turnedOffTracks: stripDefault(types.map(types.boolean), {}),
247
243
 
248
244
  /**
249
245
  * #property
@@ -259,7 +255,7 @@ function stateModelFactory() {
259
255
  /**
260
256
  * #property
261
257
  */
262
- featureFilters: types.stripDefault(types.map(types.boolean), {}),
258
+ featureFilters: stripDefault(types.map(types.boolean), {}),
263
259
  /**
264
260
  * #property
265
261
  */
@@ -826,15 +822,19 @@ function stateModelFactory() {
826
822
  if (strs.length === 0) {
827
823
  return []
828
824
  }
829
- 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)
830
829
  const numRows = strs.length
831
830
  const threshold = Math.ceil((realAllowedGappyness / 100) * numRows)
832
- const blankCounts = new Uint16Array(numCols)
831
+ const blankCounts = new Uint32Array(numCols)
833
832
  for (let j = 0; j < numRows; j++) {
834
833
  const str = strs[j]!
834
+ const len = str.length
835
835
  for (let i = 0; i < numCols; i++) {
836
836
  // bit trick: (code - 45) >>> 0 <= 1 checks for '-' (45) or '.' (46)
837
- if ((str.charCodeAt(i) - 45) >>> 0 <= 1) {
837
+ if (i >= len || (str.charCodeAt(i) - 45) >>> 0 <= 1) {
838
838
  blankCounts[i]!++
839
839
  }
840
840
  }
@@ -882,9 +882,27 @@ function stateModelFactory() {
882
882
  },
883
883
  /**
884
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.
885
889
  */
886
890
  get numRows() {
887
- 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
+ )
888
906
  },
889
907
 
890
908
  /**
@@ -924,23 +942,15 @@ function stateModelFactory() {
924
942
  * #getter
925
943
  */
926
944
  get colStats() {
927
- const r: Record<string, number>[] = []
928
- const columns = this.columns2d
929
- for (const column of columns) {
930
- for (let j = 0; j < column.length; j++) {
931
- const l = (r[j] ??= {})
932
- const cj = column[j]!
933
- l[cj] = (l[cj] ?? 0) + 1
934
- }
935
- }
936
- return r
945
+ return columnCountsFromRows(this.columns2d)
937
946
  },
938
947
 
939
948
  /**
940
949
  * #getter
950
+ * per-column residue totals (gaps included)
941
951
  */
942
952
  get colStatsSums() {
943
- return this.colStats.map(col => sum(Object.values(col)))
953
+ return this.colStats.totals
944
954
  },
945
955
 
946
956
  /**
@@ -949,14 +959,7 @@ function stateModelFactory() {
949
959
  * Returns 'dna', 'rna', or 'amino'.
950
960
  */
951
961
  get sequenceType(): 'dna' | 'rna' | 'amino' {
952
- const letters = new Set<string>()
953
- for (const stats of this.colStats) {
954
- for (const letter of Object.keys(stats)) {
955
- if (letter !== '-' && letter !== '.') {
956
- letters.add(letter)
957
- }
958
- }
959
- }
962
+ const letters = this.colStats.lettersPresent
960
963
  // isDna already excludes U (not in the DNA set) and isRna excludes T,
961
964
  // so the set membership alone disambiguates the two
962
965
  const dna = new Set(['A', 'C', 'G', 'T', 'N'])
@@ -972,21 +975,18 @@ function stateModelFactory() {
972
975
  * Used by percent_identity_dynamic color scheme.
973
976
  */
974
977
  get colConsensus() {
975
- const { colStats, colStatsSums } = this
976
- return colStats.map((stats, i) => {
977
- const total = colStatsSums[i]
978
- if (!total) {
979
- return { letter: '', color: undefined }
980
- }
978
+ const { colStats } = this
979
+ return Array.from({ length: colStats.numColumns }, (_, col) => {
980
+ const total = colStats.total(col)
981
981
  let maxCount = 0
982
982
  let letter = ''
983
- for (const [key, val] of Object.entries(stats)) {
984
- if (val > maxCount && key !== '-' && key !== '.') {
985
- maxCount = val
986
- letter = key
983
+ colStats.forEachResidue(col, (slot, count) => {
984
+ if (count > maxCount) {
985
+ maxCount = count
986
+ letter = letterOfResidueSlot(slot)
987
987
  }
988
- }
989
- const proportion = maxCount / total
988
+ })
989
+ const proportion = total ? maxCount / total : 0
990
990
  return {
991
991
  letter,
992
992
  color:
@@ -1004,9 +1004,9 @@ function stateModelFactory() {
1004
1004
  * ref http://www.jalview.org/help/html/colourSchemes/clustal.html
1005
1005
  */
1006
1006
  get colClustalX() {
1007
- const { colStats, colStatsSums } = this
1008
- return colStats.map((stats, i) =>
1009
- clustalXColumnColors(stats, colStatsSums[i]!),
1007
+ const { colStats } = this
1008
+ return Array.from({ length: colStats.numColumns }, (_, col) =>
1009
+ clustalXColumnColors(colStats, col),
1010
1010
  )
1011
1011
  },
1012
1012
 
@@ -1017,33 +1017,25 @@ function stateModelFactory() {
1017
1017
  * Returns values 0-1 where 1 = fully conserved, 0 = no conservation.
1018
1018
  */
1019
1019
  get conservation() {
1020
- const { colStats, colStatsSums, sequenceType } = this
1020
+ const { colStats, sequenceType } = this
1021
1021
  const alphabetSize = sequenceType === 'amino' ? 20 : 4
1022
1022
  const maxEntropy = Math.log2(alphabetSize)
1023
1023
 
1024
- return colStats.map((stats, i) => {
1025
- const total = colStatsSums[i]
1026
- if (!total) {
1027
- return 0
1028
- }
1029
-
1030
- 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)
1031
1027
  const nonGapTotal = total - gapCount
1032
- if (nonGapTotal === 0) {
1033
- return 0
1034
- }
1035
-
1036
- let entropy = 0
1037
- for (const [letter, count] of Object.entries(stats)) {
1038
- if (letter !== '-' && letter !== '.') {
1028
+ let score = 0
1029
+ if (nonGapTotal > 0) {
1030
+ let entropy = 0
1031
+ colStats.forEachResidue(col, (_slot, count) => {
1039
1032
  const freq = count / nonGapTotal
1040
1033
  entropy -= freq * Math.log2(freq)
1041
- }
1034
+ })
1035
+ score =
1036
+ Math.max(0, 1 - entropy / maxEntropy) * (1 - gapCount / total)
1042
1037
  }
1043
-
1044
- const gapFraction = gapCount / total
1045
- const conservation = Math.max(0, 1 - entropy / maxEntropy)
1046
- return conservation * (1 - gapFraction)
1038
+ return score
1047
1039
  })
1048
1040
  },
1049
1041
  /**
@@ -1054,7 +1046,7 @@ function stateModelFactory() {
1054
1046
  */
1055
1047
  get propertyConservation() {
1056
1048
  return this.sequenceType === 'amino'
1057
- ? calculatePropertyConservation(this.colStats, this.colStatsSums)
1049
+ ? calculatePropertyConservation(this.colStats)
1058
1050
  : []
1059
1051
  },
1060
1052
  /**
@@ -1590,7 +1582,13 @@ function stateModelFactory() {
1590
1582
  */
1591
1583
  seqPosToGlobalCol(rowName: string, seqPos: number) {
1592
1584
  const seq = self.rowMap.get(rowName)
1593
- 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)
1594
1592
  },
1595
1593
 
1596
1594
  /**
@@ -1746,6 +1744,60 @@ function stateModelFactory() {
1746
1744
  .toSorted((a, b) => a.start - b.start)
1747
1745
  },
1748
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
+
1749
1801
  /**
1750
1802
  * #getter
1751
1803
  * domain annotations under the mouse, hit-tested against the exact visible
@@ -1754,20 +1806,60 @@ function stateModelFactory() {
1754
1806
  get mouseOverDomains() {
1755
1807
  const { mouseCol } = self
1756
1808
  const name = self.mouseOverRowName
1757
- if (name !== undefined && mouseCol !== undefined) {
1758
- const entry = self.tidyFilteredGatheredInterProAnnotations[name] ?? []
1759
- return entry.filter(d => {
1760
- const m1 = self.seqPosToVisibleCol(name, d.start - 1)
1761
- const m2 = self.seqPosToVisibleCol(name, d.end)
1762
- return (
1763
- m1 !== undefined &&
1764
- m2 !== undefined &&
1765
- mouseCol >= m1 &&
1766
- mouseCol < m2
1767
- )
1768
- })
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
+ }
1769
1861
  }
1770
- return []
1862
+ return runs
1771
1863
  },
1772
1864
 
1773
1865
  /**
@@ -1781,14 +1873,14 @@ function stateModelFactory() {
1781
1873
  if (mouseCol === undefined) {
1782
1874
  return undefined
1783
1875
  }
1784
- const stats = self.colStats[mouseCol]
1785
- const total = self.colStatsSums[mouseCol]
1786
- if (!stats || !total) {
1876
+ const { colStats } = self
1877
+ const total = colStats.total(mouseCol)
1878
+ if (mouseCol >= colStats.numColumns || !total) {
1787
1879
  return undefined
1788
1880
  }
1789
- const gaps = (stats['-'] ?? 0) + (stats['.'] ?? 0)
1790
- const distribution = Object.entries(stats)
1791
- .filter(([letter]) => letter !== '-' && letter !== '.')
1881
+ const gaps = colStats.gapCount(mouseCol)
1882
+ const distribution = colStats
1883
+ .residueEntries(mouseCol)
1792
1884
  .sort((a, b) => b[1] - a[1])
1793
1885
  const consensus = distribution[0]
1794
1886
  return {
@@ -1920,6 +2012,29 @@ function stateModelFactory() {
1920
2012
  }),
1921
2013
  )
1922
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
+
1923
2038
  // autorun opens treeFilehandle. generation guard: if the filehandle
1924
2039
  // changes mid-fetch, a slower earlier request must not clobber the data
1925
2040
  // (or loading flag) set by the newer one
@@ -2090,18 +2205,32 @@ function stateModelFactory() {
2090
2205
 
2091
2206
  // Keep the parse chain warm: reading self.columns transitively holds
2092
2207
  // self.MSA (parseMSA) computed alive, so it is parsed once per data
2093
- // change rather than re-parsed on every non-reactive access. colStats is
2094
- // additionally held for dynamic color schemes. Do not remove.
2208
+ // change rather than re-parsed on every non-reactive access. Do not
2209
+ // remove.
2095
2210
  // xref solution https://github.com/mobxjs/mobx/issues/266#issuecomment-222007278
2096
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.
2097
2219
  addDisposer(
2098
2220
  self,
2099
2221
  autorun(() => {
2100
2222
  if (self.colorSchemeName.includes('dynamic')) {
2101
2223
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2102
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
2103
2232
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2104
- self.colStatsSums
2233
+ self.propertyConservation
2105
2234
  }
2106
2235
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
2107
2236
  self.columns
@@ -2140,7 +2269,7 @@ function stateModelFactory() {
2140
2269
  },
2141
2270
  }))
2142
2271
  .postProcessSnapshot(({ data, ...rest }) => ({
2143
- // per-property defaults are stripped by types.stripDefault; the only thing
2272
+ // per-property defaults are stripped by the stripDefault helper; the only thing
2144
2273
  // it can't express is this cross-field rule: drop inline tree/msa/metadata
2145
2274
  // when a sibling filehandle can refetch them, keeping sessions/URLs small
2146
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', () => {