react-msaview 5.10.0 → 6.0.0

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 (148) hide show
  1. package/bundle/index.js +99 -99
  2. package/bundle/index.js.map +4 -4
  3. package/dist/clustalX.js +22 -22
  4. package/dist/clustalX.js.map +1 -1
  5. package/dist/components/CopyButton.d.ts +9 -0
  6. package/dist/components/CopyButton.js +23 -0
  7. package/dist/components/CopyButton.js.map +1 -0
  8. package/dist/components/Loading.js +2 -2
  9. package/dist/components/Loading.js.map +1 -1
  10. package/dist/components/MSAView.js +2 -2
  11. package/dist/components/MSAView.js.map +1 -1
  12. package/dist/components/ResizeHandles.js +24 -40
  13. package/dist/components/ResizeHandles.js.map +1 -1
  14. package/dist/components/ScrollbarThumb.d.ts +14 -0
  15. package/dist/components/ScrollbarThumb.js +28 -0
  16. package/dist/components/ScrollbarThumb.js.map +1 -0
  17. package/dist/components/SequenceTextArea.js +3 -10
  18. package/dist/components/SequenceTextArea.js.map +1 -1
  19. package/dist/components/VerticalScrollbar.js +11 -25
  20. package/dist/components/VerticalScrollbar.js.map +1 -1
  21. package/dist/components/dialogs/FeatureDialog.js +3 -3
  22. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  23. package/dist/components/dialogs/{UserProvidedDomainsDialog.d.ts → InterProScanFileDialog.d.ts} +2 -2
  24. package/dist/components/dialogs/{UserProvidedDomainsDialog.js → InterProScanFileDialog.js} +6 -10
  25. package/dist/components/dialogs/InterProScanFileDialog.js.map +1 -0
  26. package/dist/components/dialogs/TrackInfoDialog.d.ts +2 -4
  27. package/dist/components/dialogs/TrackInfoDialog.js +8 -14
  28. package/dist/components/dialogs/TrackInfoDialog.js.map +1 -1
  29. package/dist/components/header/FileMenu.js +3 -3
  30. package/dist/components/header/FileMenu.js.map +1 -1
  31. package/dist/components/header/GappynessSlider.js +1 -1
  32. package/dist/components/header/GappynessSlider.js.map +1 -1
  33. package/dist/components/header/{getDomainsMenu.d.ts → getAnnotationsMenu.d.ts} +1 -1
  34. package/dist/components/header/{getDomainsMenu.js → getAnnotationsMenu.js} +10 -9
  35. package/dist/components/header/getAnnotationsMenu.js.map +1 -0
  36. package/dist/components/import/ImportForm.js +1 -1
  37. package/dist/components/import/ImportForm.js.map +1 -1
  38. package/dist/components/import/ImportFormExamples.js +66 -57
  39. package/dist/components/import/ImportFormExamples.js.map +1 -1
  40. package/dist/components/minimap/Minimap.js +11 -22
  41. package/dist/components/minimap/Minimap.js.map +1 -1
  42. package/dist/components/minimap/MinimapSVG.d.ts +2 -1
  43. package/dist/components/minimap/MinimapSVG.js +3 -4
  44. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  45. package/dist/components/minimap/minimapLayout.d.ts +10 -1
  46. package/dist/components/minimap/minimapLayout.js +15 -10
  47. package/dist/components/minimap/minimapLayout.js.map +1 -1
  48. package/dist/components/msa/{DomainLegend.d.ts → AnnotationLegend.d.ts} +2 -2
  49. package/dist/components/msa/{DomainLegend.js → AnnotationLegend.js} +4 -4
  50. package/dist/components/msa/AnnotationLegend.js.map +1 -0
  51. package/dist/components/msa/MSACanvas.js +2 -2
  52. package/dist/components/msa/MSACanvas.js.map +1 -1
  53. package/dist/components/msa/MSAMouseoverCanvas.js +1 -2
  54. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  55. package/dist/components/msa/MSAPanel.js +2 -2
  56. package/dist/components/msa/MSAPanel.js.map +1 -1
  57. package/dist/components/msa/domainBandCursor.d.ts +17 -0
  58. package/dist/components/msa/domainBandCursor.js +39 -0
  59. package/dist/components/msa/domainBandCursor.js.map +1 -0
  60. package/dist/components/msa/renderMSABlock.js +14 -20
  61. package/dist/components/msa/renderMSABlock.js.map +1 -1
  62. package/dist/components/msa/renderMSAMouseover.js +1 -2
  63. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  64. package/dist/components/tree/TreeRuler.js +5 -2
  65. package/dist/components/tree/TreeRuler.js.map +1 -1
  66. package/dist/ggplotPalettes.d.ts +0 -1
  67. package/dist/ggplotPalettes.js +0 -3
  68. package/dist/ggplotPalettes.js.map +1 -1
  69. package/dist/hierarchy.d.ts +2 -6
  70. package/dist/hierarchy.js +4 -6
  71. package/dist/hierarchy.js.map +1 -1
  72. package/dist/index.d.ts +2 -2
  73. package/dist/launchInterProScan.d.ts +0 -23
  74. package/dist/launchInterProScan.js +2 -5
  75. package/dist/launchInterProScan.js.map +1 -1
  76. package/dist/measureTextCanvas.js +21 -6
  77. package/dist/measureTextCanvas.js.map +1 -1
  78. package/dist/model.d.ts +50 -100
  79. package/dist/model.js +128 -107
  80. package/dist/model.js.map +1 -1
  81. package/dist/renderToSvg.js +2 -2
  82. package/dist/renderToSvg.js.map +1 -1
  83. package/dist/rowCoordinateCalculations.d.ts +13 -17
  84. package/dist/rowCoordinateCalculations.js +45 -39
  85. package/dist/rowCoordinateCalculations.js.map +1 -1
  86. package/dist/types.d.ts +4 -11
  87. package/dist/version.d.ts +1 -1
  88. package/dist/version.js +1 -1
  89. package/dist/version.js.map +1 -1
  90. package/package.json +5 -5
  91. package/src/clustalX.ts +22 -22
  92. package/src/colSpace.test.ts +27 -0
  93. package/src/components/CopyButton.tsx +44 -0
  94. package/src/components/Loading.tsx +10 -1
  95. package/src/components/MSAView.tsx +2 -3
  96. package/src/components/ResizeHandles.tsx +60 -69
  97. package/src/components/ScrollbarThumb.tsx +48 -0
  98. package/src/components/SequenceTextArea.tsx +3 -16
  99. package/src/components/VerticalScrollbar.tsx +19 -33
  100. package/src/components/dialogs/FeatureDialog.tsx +3 -4
  101. package/src/components/dialogs/{UserProvidedDomainsDialog.tsx → InterProScanFileDialog.tsx} +6 -14
  102. package/src/components/dialogs/TrackInfoDialog.tsx +18 -20
  103. package/src/components/header/FileMenu.tsx +3 -3
  104. package/src/components/header/GappynessSlider.tsx +4 -1
  105. package/src/components/header/{getDomainsMenu.ts → getAnnotationsMenu.ts} +10 -9
  106. package/src/components/import/ImportForm.tsx +1 -1
  107. package/src/components/import/ImportFormExamples.tsx +82 -133
  108. package/src/components/minimap/Minimap.tsx +23 -31
  109. package/src/components/minimap/MinimapSVG.tsx +32 -31
  110. package/src/components/minimap/minimapLayout.test.ts +60 -0
  111. package/src/components/minimap/minimapLayout.ts +15 -10
  112. package/src/components/msa/{DomainLegend.tsx → AnnotationLegend.tsx} +7 -3
  113. package/src/components/msa/MSACanvas.tsx +3 -10
  114. package/src/components/msa/MSAMouseoverCanvas.tsx +1 -3
  115. package/src/components/msa/MSAPanel.tsx +2 -2
  116. package/src/components/msa/domainBandCursor.ts +41 -0
  117. package/src/components/msa/renderMSABlock.ts +19 -23
  118. package/src/components/msa/renderMSAMouseover.ts +1 -3
  119. package/src/components/tree/TreeRuler.tsx +7 -2
  120. package/src/components/tree/renderTreeCanvas.test.ts +15 -7
  121. package/src/domainBands.test.ts +24 -0
  122. package/src/domainLetterColor.test.ts +80 -0
  123. package/src/ggplotPalettes.ts +0 -4
  124. package/src/hierarchy.test.ts +7 -7
  125. package/src/hierarchy.ts +6 -9
  126. package/src/index.ts +4 -1
  127. package/src/largeTree.test.ts +23 -2
  128. package/src/launchInterProScan.ts +3 -30
  129. package/src/loadErrorDisplay.test.tsx +102 -0
  130. package/src/measureTextCanvas.ts +22 -6
  131. package/src/model.ts +137 -115
  132. package/src/renderMinimapSvg.test.tsx +109 -0
  133. package/src/renderToSvg.tsx +2 -2
  134. package/src/rowCoordinateCalculations.ts +48 -44
  135. package/src/types.ts +10 -15
  136. package/src/version.ts +1 -1
  137. package/src/viewChrome.test.tsx +134 -0
  138. package/dist/components/dialogs/DomainDialog.d.ts +0 -6
  139. package/dist/components/dialogs/DomainDialog.js +0 -19
  140. package/dist/components/dialogs/DomainDialog.js.map +0 -1
  141. package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +0 -1
  142. package/dist/components/header/SettingsMenu.d.ts +0 -6
  143. package/dist/components/header/SettingsMenu.js +0 -40
  144. package/dist/components/header/SettingsMenu.js.map +0 -1
  145. package/dist/components/header/getDomainsMenu.js.map +0 -1
  146. package/dist/components/msa/DomainLegend.js.map +0 -1
  147. package/src/components/dialogs/DomainDialog.tsx +0 -45
  148. package/src/components/header/SettingsMenu.tsx +0 -57
@@ -1,6 +1,7 @@
1
1
  import { setFontSize } from '../../setFontSize.ts'
2
2
  import { adjustColorForContrast } from '../../util.ts'
3
3
  import { getVisibleLeaves } from '../getVisibleLeaves.ts'
4
+ import { domainBandCursor } from './domainBandCursor.ts'
4
5
  import { visibleColRange } from './visibleColRange.ts'
5
6
 
6
7
  import type { HierarchyNode } from '../../hierarchy.ts'
@@ -167,8 +168,9 @@ function drawTilesAndText({
167
168
  if (str) {
168
169
  const tileY = y - rowHeight
169
170
  const textY = y - quarterRowHeight
170
- const bands = overDomains ? domainBandsByStart.get(name) : undefined
171
- let band = 0
171
+ const bandAt = domainBandCursor(
172
+ overDomains ? domainBandsByStart.get(name) : undefined,
173
+ )
172
174
 
173
175
  for (let j = 0, l2 = str.length; j < l2; j++) {
174
176
  const col = xStart + j
@@ -186,27 +188,21 @@ function drawTilesAndText({
186
188
  }
187
189
 
188
190
  if (showMsaLetters) {
189
- // bands are sorted by start column and the sweep is left to right,
190
- // so one forward-only cursor finds the band covering each column
191
- while (bands && band < bands.length && bands[band]!.endCol <= col) {
192
- band++
193
- }
194
- const covering = bands?.[band]
195
- ctx.fillStyle =
196
- covering && covering.startCol <= col
197
- ? // on top of a domain box: contrast against the box fill
198
- domainColors!.get(covering.annotation.accession)!
199
- : tiles
200
- ? // on top of a colored tile
201
- (contrastScheme[letter] ?? 'black')
202
- : !drawTiles || !color
203
- ? // plain background, uncolored letters
204
- theme.palette.text.primary
205
- : // letter-color mode: darken/lighten to stay readable
206
- adjustColorForContrast(
207
- color,
208
- theme.palette.background.default,
209
- )
191
+ const covering = bandAt(col)
192
+ ctx.fillStyle = covering
193
+ ? // on top of a domain box: contrast against the box fill
194
+ domainColors!.get(covering.annotation.accession)!
195
+ : tiles
196
+ ? // on top of a colored tile
197
+ (contrastScheme[letter] ?? 'black')
198
+ : !drawTiles || !color
199
+ ? // plain background, uncolored letters
200
+ theme.palette.text.primary
201
+ : // letter-color mode: darken/lighten to stay readable
202
+ adjustColorForContrast(
203
+ color,
204
+ theme.palette.background.default,
205
+ )
210
206
  ctx.fillText(
211
207
  isMatchingReference ? '.' : letter,
212
208
  x + halfColWidth,
@@ -23,8 +23,7 @@ export function renderMouseover({
23
23
  const {
24
24
  mouseCol,
25
25
  colWidth,
26
- msaAreaWidth,
27
- verticalScrollbarWidth,
26
+ msaCanvasWidth: width,
28
27
  height,
29
28
  rowHeight,
30
29
  scrollX,
@@ -37,7 +36,6 @@ export function renderMouseover({
37
36
  hoveredRowIndices,
38
37
  highlightedColumnRuns,
39
38
  } = model
40
- const width = msaAreaWidth - verticalScrollbarWidth
41
39
  ctx.resetTransform()
42
40
  ctx.clearRect(0, 0, width * highResScaleFactor, height * highResScaleFactor)
43
41
  ctx.scale(highResScaleFactor, highResScaleFactor)
@@ -4,9 +4,14 @@ import { observer } from 'mobx-react'
4
4
 
5
5
  import type { MsaViewModel } from '../../model.ts'
6
6
 
7
+ // The gutter above the tree panel. It spans the resize handle too, so whatever
8
+ // follows it in the top row starts exactly where the alignment canvas does --
9
+ // MainArea has TreePanel + VerticalResizeHandle before the alignment.
7
10
  const TreeRuler = observer(({ model }: { model: MsaViewModel }) => {
8
- const { treeAreaWidth } = model
9
- return <div style={{ flexShrink: 0, width: treeAreaWidth }} />
11
+ const { treeAreaWidth, resizeHandleWidth } = model
12
+ return (
13
+ <div style={{ flexShrink: 0, width: treeAreaWidth + resizeHandleWidth }} />
14
+ )
10
15
  })
11
16
 
12
17
  export default TreeRuler
@@ -117,8 +117,9 @@ describe('getNodeX cladogram positioning', () => {
117
117
  })
118
118
  })
119
119
 
120
- // the x values the tree pass actually strokes, for a model built from newick
121
- function drawnTreeXs(newick: string) {
120
+ // the x values the tree pass actually strokes, for a model built from newick,
121
+ // alongside the tree width they are expected to span
122
+ function drawnTree(newick: string) {
122
123
  const model = stateModelFactory().create({
123
124
  type: 'MsaView',
124
125
  data: { msa: '>a\nA\n>b\nA\n>c\nA\n>d\nA', tree: newick },
@@ -150,19 +151,26 @@ function drawnTreeXs(newick: string) {
150
151
  offsetY: 0,
151
152
  theme: { palette: { text: { primary: '#000' } } } as Theme,
152
153
  })
153
- return xs
154
+ return { xs, model }
154
155
  }
155
156
 
156
157
  describe('renderTreeCanvas horizontal extent', () => {
157
158
  it('spreads a cladogram across the tree area when branch lengths are absent', () => {
158
159
  // a lengthless newick forces cladogram mode; scaling it by the (zero) max
159
160
  // branch length would stack every node on x=0 as one vertical line
160
- const xs = drawnTreeXs('((a,b),(c,d));')
161
- expect(Math.max(...xs)).toBe(300)
161
+ const { xs, model } = drawnTree('((a,b),(c,d));')
162
+ expect(Math.max(...xs)).toBe(model.treeWidth)
162
163
  })
163
164
 
164
165
  it('scales a phylogram by branch length', () => {
165
- const xs = drawnTreeXs('((a:0.1,b:0.2):0.3,(c:0.4,d:0.5):0.6);')
166
- expect(Math.max(...xs)).toBeCloseTo(300)
166
+ const { xs, model } = drawnTree('((a:0.1,b:0.2):0.3,(c:0.4,d:0.5):0.6);')
167
+ expect(Math.max(...xs)).toBeCloseTo(model.treeWidth)
168
+ })
169
+
170
+ // labels off means no label gutter to reserve, so the tips reach the far edge
171
+ // of the tree area rather than stopping at the default treeWidth
172
+ it('hands the label gutter to the tree when labels are off', () => {
173
+ const { model } = drawnTree('((a,b),(c,d));')
174
+ expect(model.treeWidth).toBe(model.treeAreaWidth - 10 - model.marginLeft)
167
175
  })
168
176
  })
@@ -18,6 +18,12 @@ const gff = `##gff-version 3
18
18
  a Pfam protein_match 1 4 . . . Name=PF00001;signature_desc=First
19
19
  a Pfam protein_match 5 8 . . . Name=PF00002;signature_desc=Second`
20
20
 
21
+ // row `b` only: PF00003 ends inside the insertion, PF00004 lies entirely within
22
+ // it, so gap hiding puts one end and then both ends into hidden columns
23
+ const insertionGff = `##gff-version 3
24
+ b Pfam protein_match 4 8 . . . Name=PF00003;signature_desc=Overlapping
25
+ b Pfam protein_match 5 8 . . . Name=PF00004;signature_desc=Inside`
26
+
21
27
  function makeModel() {
22
28
  const model = MSAModelF().create({
23
29
  id: 'domain-bands-test',
@@ -56,6 +62,24 @@ test('bands follow the columns when gappy columns are hidden', () => {
56
62
  ])
57
63
  })
58
64
 
65
+ test('a band whose end lands in a hidden column keeps its visible part', () => {
66
+ const model = makeModel()
67
+ model.applyGFFText(insertionGff)
68
+ expect(model.domainBands.get('b')!.map(b => [b.startCol, b.endCol])).toEqual([
69
+ [3, 8],
70
+ [4, 8],
71
+ ])
72
+
73
+ model.setHideGaps(true)
74
+ model.setAllowedGappyness(50)
75
+ expect(model.blanks).toEqual([4, 5, 6, 7])
76
+ // PF00003 covers V + the 4 hidden Qs, so only the V column survives; PF00004
77
+ // is nothing but hidden columns and drops out entirely
78
+ expect(model.domainBands.get('b')!.map(b => [b.startCol, b.endCol])).toEqual([
79
+ [3, 4],
80
+ ])
81
+ })
82
+
59
83
  test('mouseOverDomains hit-tests against the drawn span', () => {
60
84
  const model = makeModel()
61
85
  const rowIndex = model.rowNames.indexOf('a')
@@ -0,0 +1,80 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Letters drawn on top of the domain overlay take their color from the box
4
+ // underneath them. The overlay paints bands largest-first, so with nested
5
+ // signatures -- routine in InterProScan output, where a family encloses a
6
+ // domain -- the box actually visible at a column is the *last* one painted,
7
+ // not the first one covering it.
8
+ import { expect, test } from 'vitest'
9
+
10
+ import { domainBandCursor } from './components/msa/domainBandCursor.ts'
11
+ import MSAModelF from './model.ts'
12
+
13
+ const row = 'ACDEFGHIKLMNPQRSTVWY'
14
+ const msa = `>seq1\n${row}\n>seq2\n${row}`
15
+
16
+ // PF_OUTER spans the whole row, PF_INNER sits inside it. tidyInterProAnnotations
17
+ // orders by length descending, so the outer band paints first and the inner one
18
+ // covers it.
19
+ const gff = `##gff-version 3
20
+ seq1\tPfam\tprotein_match\t1\t20\t.\t.\t.\tName=PF_OUTER;signature_desc=Outer
21
+ seq1\tPfam\tprotein_match\t8\t12\t.\t.\t.\tName=PF_INNER;signature_desc=Inner`
22
+
23
+ function makeModel() {
24
+ const model = MSAModelF().create({
25
+ type: 'MsaView',
26
+ msaFormat: 'fasta',
27
+ data: { msa },
28
+ })
29
+ model.setWidth(800)
30
+ model.applyGFFText(gff)
31
+ return model
32
+ }
33
+
34
+ function sweep(model: ReturnType<typeof makeModel>, from = 0) {
35
+ const bandAt = domainBandCursor(model.domainBandsByStart.get('seq1'))
36
+ const out: (string | undefined)[] = []
37
+ for (let col = from; col < 20; col++) {
38
+ out.push(bandAt(col)?.annotation.accession)
39
+ }
40
+ return out
41
+ }
42
+
43
+ test('the overlay paints largest-first, so stackIndex is paint order', () => {
44
+ const bands = makeModel().domainBands.get('seq1')!
45
+ expect(bands.map(b => [b.annotation.accession, b.stackIndex])).toEqual([
46
+ ['PF_OUTER', 0],
47
+ ['PF_INNER', 1],
48
+ ])
49
+ })
50
+
51
+ test('a nested band wins the columns it covers', () => {
52
+ const model = makeModel()
53
+ // PF_INNER is seq positions 8-12 (1-based), i.e. columns 7..11
54
+ expect(sweep(model)).toEqual([
55
+ ...Array.from({ length: 7 }, () => 'PF_OUTER'),
56
+ ...Array.from({ length: 5 }, () => 'PF_INNER'),
57
+ ...Array.from({ length: 8 }, () => 'PF_OUTER'),
58
+ ])
59
+ })
60
+
61
+ test('a sweep starting mid-block picks up bands opened before it', () => {
62
+ // canvas blocks start at arbitrary columns, so the cursor has to catch up on
63
+ // its first call rather than assuming it began at column 0
64
+ const model = makeModel()
65
+ expect(sweep(model, 9)).toEqual([
66
+ 'PF_INNER',
67
+ 'PF_INNER',
68
+ 'PF_INNER',
69
+ ...Array.from({ length: 8 }, () => 'PF_OUTER'),
70
+ ])
71
+ })
72
+
73
+ test('columns outside every band have no covering band', () => {
74
+ const model = makeModel()
75
+ model.setFilter('PF_OUTER', false)
76
+ const bandAt = domainBandCursor(model.domainBandsByStart.get('seq1'))
77
+ expect(bandAt(0)).toBeUndefined()
78
+ expect(bandAt(9)?.annotation.accession).toBe('PF_INNER')
79
+ expect(bandAt(19)).toBeUndefined()
80
+ })
@@ -18,8 +18,4 @@ const palettes = [
18
18
  ],
19
19
  ]
20
20
 
21
- export function getPalette(l: number) {
22
- return palettes[Math.min(l, palettes.length - 1)]!
23
- }
24
-
25
21
  export default palettes
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'
3
3
  import {
4
4
  calcDepthToLeaf,
5
5
  collapse,
6
- collapsedSubtreeLengthExtent,
6
+ collapsedSubtreeMaxLength,
7
7
  find,
8
8
  findMaxBranchLen,
9
9
  hierarchy,
@@ -124,7 +124,7 @@ describe('collapse', () => {
124
124
  })
125
125
  })
126
126
 
127
- describe('collapsedSubtreeLengthExtent', () => {
127
+ describe('collapsedSubtreeMaxLength', () => {
128
128
  // root → A(1) → [ A1(2), C(3) → [ C1(1), C2(10) ] ]
129
129
  function makeLenTree(): NodeWithIds {
130
130
  return {
@@ -152,18 +152,18 @@ describe('collapsedSubtreeLengthExtent', () => {
152
152
  }
153
153
  }
154
154
 
155
- test('min/max cumulative branch length to the tips below a node', () => {
155
+ test('cumulative branch length to the farthest tip below a node', () => {
156
156
  const h = hierarchy(makeLenTree(), d => d.children)
157
157
  const nodeA = find(h, n => n.data.id === 'A')!
158
158
  collapse(nodeA)
159
- // excludes A's own branch: nearest tip A1 = 2, farthest tip C2 = 3 + 10 = 13
160
- expect(collapsedSubtreeLengthExtent(nodeA)).toEqual({ min: 2, max: 13 })
159
+ // excludes A's own branch: farthest tip C2 = 3 + 10 = 13, vs A1 = 2
160
+ expect(collapsedSubtreeMaxLength(nodeA)).toBe(13)
161
161
  })
162
162
 
163
- test('returns zero extent for a node with no descendants', () => {
163
+ test('returns zero for a node with no descendants', () => {
164
164
  const h = hierarchy(makeLenTree(), d => d.children)
165
165
  const leaf = find(h, n => n.data.id === 'A1')!
166
- expect(collapsedSubtreeLengthExtent(leaf)).toEqual({ min: 0, max: 0 })
166
+ expect(collapsedSubtreeMaxLength(leaf)).toBe(0)
167
167
  })
168
168
  })
169
169
 
package/src/hierarchy.ts CHANGED
@@ -28,9 +28,8 @@ export interface HierarchyNode<T = NodeWithIds> extends CoreHierarchyNode<T> {
28
28
  len?: number
29
29
  depthToLeaf?: number
30
30
  _children?: HierarchyNode<T>[] | null
31
- // pixel x-positions of the nearest/farthest tips of a collapsed subtree, used
32
- // to draw the collapsed-clade triangle. Set in the model's hierarchy getter.
33
- collapsedTipXNear?: number
31
+ // pixel x-position of the farthest tip of a collapsed subtree, where the base
32
+ // of the collapsed-clade triangle goes. Set in the model's hierarchy getter.
34
33
  collapsedTipXFar?: number
35
34
  }
36
35
 
@@ -115,14 +114,13 @@ export function collapse<T>(node: HierarchyNode<T>) {
115
114
  }
116
115
  }
117
116
 
118
- // Min/max cumulative raw branch length from a collapsed node down to the tips of
119
- // its detached subtree (excludes the node's own branch). Used to size the
117
+ // Cumulative raw branch length from a collapsed node down to the farthest tip of
118
+ // its detached subtree (excludes the node's own branch). Sizes the base of the
120
119
  // collapsed-clade triangle in phylogram mode.
121
- export function collapsedSubtreeLengthExtent<T extends { length?: number }>(
120
+ export function collapsedSubtreeMaxLength<T extends { length?: number }>(
122
121
  node: HierarchyNode<T>,
123
122
  ) {
124
123
  const roots = node._children ?? node.children
125
- let min = Infinity
126
124
  let max = 0
127
125
  if (roots) {
128
126
  const stack = roots.map(child => ({
@@ -140,12 +138,11 @@ export function collapsedSubtreeLengthExtent<T extends { length?: number }>(
140
138
  })
141
139
  }
142
140
  } else {
143
- min = Math.min(min, acc)
144
141
  max = Math.max(max, acc)
145
142
  }
146
143
  }
147
144
  }
148
- return { min: min === Infinity ? 0 : min, max }
145
+ return max
149
146
  }
150
147
 
151
148
  // Cladogram positioning based on ape's plot.phylo: uses topological depth (max
package/src/index.ts CHANGED
@@ -11,7 +11,9 @@ export { default as MSAViewer } from './components/MSAViewer.tsx'
11
11
  export { type MsaViewModel, default as MSAModelF } from './model.ts'
12
12
  export type { MSAFormat, MSAParserType } from 'msa-parsers'
13
13
  export type { HierarchyNode } from './hierarchy.ts'
14
- export type { InterProScanResults } from './launchInterProScan.ts'
14
+ // InterProScanResults is the EBI wire format that setDomains still accepts;
15
+ // Annotation is the source-agnostic shape everything else is written against
16
+ export type { InterProScanResults } from 'msa-parsers'
15
17
  // ColumnCounts, DomainBand and TidyDomainAnnotation surface in the inferred type
16
18
  // of the composed state model, so a downstream plugin cannot emit declarations
17
19
  // for its own stateModelFactory without being able to name them (TS2883).
@@ -19,6 +21,7 @@ export type { InterProScanResults } from './launchInterProScan.ts'
19
21
  export type { ColumnCounts } from './columnCounts.ts'
20
22
  export type {
21
23
  Accession,
24
+ Annotation,
22
25
  BasicTrack,
23
26
  BasicTrackModel,
24
27
  DomainBand,
@@ -3,8 +3,8 @@ import { expect, test } from 'vitest'
3
3
 
4
4
  import MSAModelF from './model.ts'
5
5
 
6
- // jsdom has no 2d context, and measureTextCanvas throws without one. Only the
7
- // width matters here, so a stub keeps the test about tree size.
6
+ // jsdom has no 2d context, so measureTextCanvas falls back to an estimate. Only
7
+ // the width matters here, so a stub keeps the test about tree size.
8
8
  HTMLCanvasElement.prototype.getContext = (() => ({
9
9
  font: '12px sans-serif',
10
10
  measureText: (t: string) => ({ width: t.length * 7 }),
@@ -30,3 +30,24 @@ test('a tree far past the argument limit still lays out', () => {
30
30
  expect(model.labelsWidth).toBe(49)
31
31
  expect(model.treeWidth).toBeGreaterThan(0)
32
32
  })
33
+
34
+ test('re-hovering the same tree node does not re-walk the tree', () => {
35
+ const model = MSAModelF().create({
36
+ type: 'MsaView',
37
+ data: { tree: flatNewick(200_000) },
38
+ })
39
+ model.setWidth(1000)
40
+
41
+ const leafId = model.leaves[1000]!.data.id
42
+ model.setHoveredTreeNode(leafId)
43
+ const first = model.hoveredTreeNode
44
+
45
+ // the tree's mousemove handler fires this on every event, so an unchanged id
46
+ // has to leave the state object alone: rebuilding it walks all 200k nodes and
47
+ // invalidates every overlay that reads hoveredRowIndices
48
+ model.setHoveredTreeNode(leafId)
49
+ expect(model.hoveredTreeNode).toBe(first)
50
+
51
+ model.setHoveredTreeNode(undefined)
52
+ expect(model.hoveredTreeNode).toBeUndefined()
53
+ })
@@ -1,30 +1,13 @@
1
1
  import { getSession } from '@jbrowse/core/util'
2
+ import { interProScanResponseToAnnotations } from 'msa-parsers'
2
3
 
3
4
  import { isAbortError, jsonfetch, textfetch, timeout } from './fetchUtils.ts'
4
5
 
5
6
  import type { MsaViewModel } from './model.ts'
7
+ import type { InterProScanResponse } from 'msa-parsers'
6
8
 
7
9
  const base = 'https://www.ebi.ac.uk/Tools/services/rest'
8
10
 
9
- export interface InterProScanResults {
10
- matches: {
11
- signature: {
12
- entry?: {
13
- name: string
14
- description: string
15
- accession: string
16
- // original GFF feature type (exon, CDS, ...) when sourced from GFF
17
- featureType?: string
18
- }
19
- }
20
- locations: { start: number; end: number; strand?: number }[]
21
- }[]
22
- xref: { id: string }[]
23
- }
24
- export interface InterProScanResponse {
25
- results: InterProScanResults[]
26
- }
27
-
28
11
  async function runInterProScan({
29
12
  seq,
30
13
  onProgress,
@@ -169,17 +152,7 @@ async function loadInterProScanResultsWithStatus({
169
152
  url: `https://www.ebi.ac.uk/Tools/services/rest/iprscan5/result/${jobId}/json`,
170
153
  })
171
154
  const ret = await loadInterProScanResults(jobId, signal)
172
- // key each result by its xref id; skip any result lacking an xref entry
173
- // rather than crashing the whole load on one malformed result
174
- model.setDomains(
175
- Object.fromEntries(
176
- ret.results
177
- .map(r => [r.xref[0]?.id, r] as const)
178
- .filter(
179
- (e): e is [string, InterProScanResults] => e[0] !== undefined,
180
- ),
181
- ),
182
- )
155
+ model.setAnnotations(interProScanResponseToAnnotations(ret))
183
156
  getSession(model).notify(`Loaded interproscan ${jobId} results`, 'success')
184
157
  } catch (e) {
185
158
  if (isAbortError(e)) {
@@ -0,0 +1,102 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // What a failed load LOOKS like, which is a different question from what the
4
+ // model recorded. modelFilehandleLoaders.test.ts already pins the model half --
5
+ // a rejected fetch sets `error` and clears `loadingMSA`, under the name "a
6
+ // failed fetch surfaces as an error" -- and it passed throughout the whole time
7
+ // a failed url showed the reader a spinner and nothing else.
8
+ //
9
+ // The error display was never missing. ImportForm renders `model.error`, and
10
+ // the reader could not get to it: a non-abort failure KEEPS its filehandle
11
+ // (only an abort clears it), so `hasPendingFilehandle` stayed true and
12
+ // Loading.tsx took its spinner branch ahead of the import form, permanently.
13
+ // Found on AlphaFold's `files/msa/` prefix answering 403 to every key in August
14
+ // 2026 -- the view opened, said "Downloading file" with a Cancel button, and
15
+ // was still saying it at 105s.
16
+ import React, { act } from 'react'
17
+
18
+ import { createRoot } from 'react-dom/client'
19
+ import { afterEach, beforeEach, expect, test } from 'vitest'
20
+
21
+ import MSAView from './components/Loading.tsx'
22
+ import MSAModelF from './model.ts'
23
+
24
+ import type { MsaViewModel } from './model.ts'
25
+ import type { Root } from 'react-dom/client'
26
+
27
+ Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
28
+
29
+ let container: HTMLDivElement
30
+ let root: Root
31
+ let model: MsaViewModel
32
+
33
+ function uri(u: string) {
34
+ return { locationType: 'UriLocation' as const, uri: u }
35
+ }
36
+
37
+ function render() {
38
+ act(() => {
39
+ root.render(<MSAView model={model} />)
40
+ })
41
+ }
42
+
43
+ beforeEach(() => {
44
+ container = document.createElement('div')
45
+ document.body.append(container)
46
+ root = createRoot(container)
47
+ model = MSAModelF().create({ type: 'MsaView' })
48
+ model.setWidth(800)
49
+ })
50
+
51
+ afterEach(() => {
52
+ act(() => {
53
+ root.unmount()
54
+ })
55
+ container.remove()
56
+ })
57
+
58
+ test('a load that fails shows the error rather than a spinner forever', () => {
59
+ // the state the loader leaves behind on a 403: the filehandle it could not
60
+ // read is still set, nothing parsed, and the error recorded
61
+ model.setMSAFilehandle(uri('https://example.com/blocked.a3m'))
62
+ model.setLoadingMSA(false)
63
+ model.setError(new Error('HTTP 403 fetching blocked.a3m'))
64
+ render()
65
+
66
+ expect(container.textContent).toContain('403')
67
+ expect(container.textContent).not.toContain('Loading')
68
+ })
69
+
70
+ test('a load still in flight is a spinner, not an error', () => {
71
+ model.setMSAFilehandle(uri('https://example.com/slow.a3m'))
72
+ render()
73
+
74
+ expect(model.loadingMSA).toBe(true)
75
+ expect(container.textContent).not.toContain('Return to import form')
76
+ })
77
+
78
+ // A guard rather than a pin: this one passes without the fix too, because with
79
+ // no filehandle left set the view reaches the ImportForm and that already shows
80
+ // the error. It is here so the new branch cannot quietly swallow the case.
81
+ //
82
+ // It also records the surprise underneath: `dataInitialized` is itself
83
+ // `(msa || tree) && !error`, so ANY error hides a loaded alignment, including a
84
+ // tree or gff that 404s beside a good one -- setError is shared by every
85
+ // filehandle loader. Whether a failed OPTIONAL file should blank a good
86
+ // alignment is a real question and a wider one, since `dataInitialized` is read
87
+ // in several places; narrowing it is a design change rather than this fix.
88
+ test('an error over loaded data says so instead of a bare import form', () => {
89
+ const loaded = MSAModelF().create({
90
+ type: 'MsaView',
91
+ msaFormat: 'fasta',
92
+ height: 200,
93
+ data: { msa: '>seq1\nACDE\n>seq2\nACDF' },
94
+ })
95
+ loaded.setWidth(800)
96
+ loaded.setError(new Error('domains.gff 404'))
97
+ act(() => {
98
+ root.render(<MSAView model={loaded} />)
99
+ })
100
+
101
+ expect(container.textContent).toContain('domains.gff 404')
102
+ })
@@ -1,15 +1,31 @@
1
1
  import { setFontSize } from './setFontSize.ts'
2
2
 
3
- let canvasHandle: HTMLCanvasElement | undefined
3
+ // Cached across calls: labelWidthMap measures every leaf label on every layout,
4
+ // and both createElement and getContext are far more expensive than measureText.
5
+ // `null` records that this environment has no 2d context, so the lookup is not
6
+ // retried on every label.
7
+ let ctxHandle: CanvasRenderingContext2D | null | undefined
4
8
 
5
- export function measureTextCanvas(text: string, fontSize: number) {
6
- if (!canvasHandle) {
7
- canvasHandle = document.createElement('canvas')
9
+ function getCtx() {
10
+ if (ctxHandle === undefined) {
11
+ ctxHandle =
12
+ typeof document === 'undefined'
13
+ ? null
14
+ : document.createElement('canvas').getContext('2d')
8
15
  }
16
+ return ctxHandle
17
+ }
9
18
 
10
- const ctx = canvasHandle.getContext('2d')
19
+ // Rough average glyph width as a fraction of the font size for the sans-serif
20
+ // stack the tree labels use. Only reached where there is no canvas to measure
21
+ // with (jsdom, SSR); estimating the label gutter beats throwing out of the
22
+ // layout getters that read this on every render.
23
+ const fallbackGlyphWidthRatio = 0.6
24
+
25
+ export function measureTextCanvas(text: string, fontSize: number) {
26
+ const ctx = getCtx()
11
27
  if (!ctx) {
12
- throw new Error('no canvas context')
28
+ return text.length * fontSize * fallbackGlyphWidthRatio
13
29
  }
14
30
  setFontSize(ctx, fontSize)
15
31
  return ctx.measureText(text).width