react-msaview 5.9.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 (196) 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/ConservationTrack.js +21 -16
  6. package/dist/components/ConservationTrack.js.map +1 -1
  7. package/dist/components/CopyButton.d.ts +9 -0
  8. package/dist/components/CopyButton.js +23 -0
  9. package/dist/components/CopyButton.js.map +1 -0
  10. package/dist/components/Loading.js +2 -2
  11. package/dist/components/Loading.js.map +1 -1
  12. package/dist/components/MSAView.js +18 -24
  13. package/dist/components/MSAView.js.map +1 -1
  14. package/dist/components/ResizeHandles.js +24 -40
  15. package/dist/components/ResizeHandles.js.map +1 -1
  16. package/dist/components/ScrollbarThumb.d.ts +14 -0
  17. package/dist/components/ScrollbarThumb.js +28 -0
  18. package/dist/components/ScrollbarThumb.js.map +1 -0
  19. package/dist/components/SequenceTextArea.js +3 -10
  20. package/dist/components/SequenceTextArea.js.map +1 -1
  21. package/dist/components/TextTrack.js +26 -21
  22. package/dist/components/TextTrack.js.map +1 -1
  23. package/dist/components/VerticalScrollbar.js +11 -25
  24. package/dist/components/VerticalScrollbar.js.map +1 -1
  25. package/dist/components/dialogs/FeatureDialog.js +3 -3
  26. package/dist/components/dialogs/FeatureDialog.js.map +1 -1
  27. package/dist/components/dialogs/{UserProvidedDomainsDialog.d.ts → InterProScanFileDialog.d.ts} +2 -2
  28. package/dist/components/dialogs/{UserProvidedDomainsDialog.js → InterProScanFileDialog.js} +6 -10
  29. package/dist/components/dialogs/InterProScanFileDialog.js.map +1 -0
  30. package/dist/components/dialogs/TrackInfoDialog.d.ts +2 -4
  31. package/dist/components/dialogs/TrackInfoDialog.js +8 -14
  32. package/dist/components/dialogs/TrackInfoDialog.js.map +1 -1
  33. package/dist/components/header/FileMenu.js +3 -3
  34. package/dist/components/header/FileMenu.js.map +1 -1
  35. package/dist/components/header/GappynessSlider.js +1 -1
  36. package/dist/components/header/GappynessSlider.js.map +1 -1
  37. package/dist/components/header/{getDomainsMenu.d.ts → getAnnotationsMenu.d.ts} +1 -1
  38. package/dist/components/header/{getDomainsMenu.js → getAnnotationsMenu.js} +10 -9
  39. package/dist/components/header/getAnnotationsMenu.js.map +1 -0
  40. package/dist/components/header/settingsMenuItems.js +39 -72
  41. package/dist/components/header/settingsMenuItems.js.map +1 -1
  42. package/dist/components/import/ImportForm.js +1 -1
  43. package/dist/components/import/ImportForm.js.map +1 -1
  44. package/dist/components/import/ImportFormExamples.js +66 -57
  45. package/dist/components/import/ImportFormExamples.js.map +1 -1
  46. package/dist/components/minimap/Minimap.js +11 -22
  47. package/dist/components/minimap/Minimap.js.map +1 -1
  48. package/dist/components/minimap/MinimapSVG.d.ts +2 -1
  49. package/dist/components/minimap/MinimapSVG.js +3 -4
  50. package/dist/components/minimap/MinimapSVG.js.map +1 -1
  51. package/dist/components/minimap/minimapLayout.d.ts +10 -1
  52. package/dist/components/minimap/minimapLayout.js +15 -10
  53. package/dist/components/minimap/minimapLayout.js.map +1 -1
  54. package/dist/components/msa/{DomainLegend.d.ts → AnnotationLegend.d.ts} +2 -2
  55. package/dist/components/msa/{DomainLegend.js → AnnotationLegend.js} +4 -4
  56. package/dist/components/msa/AnnotationLegend.js.map +1 -0
  57. package/dist/components/msa/MSACanvas.js +2 -2
  58. package/dist/components/msa/MSACanvas.js.map +1 -1
  59. package/dist/components/msa/MSACanvasBlock.js +19 -16
  60. package/dist/components/msa/MSACanvasBlock.js.map +1 -1
  61. package/dist/components/msa/MSAMouseoverCanvas.js +14 -8
  62. package/dist/components/msa/MSAMouseoverCanvas.js.map +1 -1
  63. package/dist/components/msa/MSAPanel.js +2 -2
  64. package/dist/components/msa/MSAPanel.js.map +1 -1
  65. package/dist/components/msa/domainBandCursor.d.ts +17 -0
  66. package/dist/components/msa/domainBandCursor.js +39 -0
  67. package/dist/components/msa/domainBandCursor.js.map +1 -0
  68. package/dist/components/msa/renderBoxFeatureCanvasBlock.js +9 -6
  69. package/dist/components/msa/renderBoxFeatureCanvasBlock.js.map +1 -1
  70. package/dist/components/msa/renderMSABlock.js +14 -20
  71. package/dist/components/msa/renderMSABlock.js.map +1 -1
  72. package/dist/components/msa/renderMSAMouseover.js +32 -33
  73. package/dist/components/msa/renderMSAMouseover.js.map +1 -1
  74. package/dist/components/overlayColors.d.ts +2 -0
  75. package/dist/components/overlayColors.js +5 -0
  76. package/dist/components/overlayColors.js.map +1 -1
  77. package/dist/components/tree/TreeCanvas.js +29 -24
  78. package/dist/components/tree/TreeCanvas.js.map +1 -1
  79. package/dist/components/tree/TreeCanvasBlock.js +19 -12
  80. package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
  81. package/dist/components/tree/TreeRuler.js +5 -2
  82. package/dist/components/tree/TreeRuler.js.map +1 -1
  83. package/dist/components/tree/renderTreeCanvas.d.ts +0 -38
  84. package/dist/components/tree/renderTreeCanvas.js +17 -17
  85. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  86. package/dist/ggplotPalettes.d.ts +0 -1
  87. package/dist/ggplotPalettes.js +0 -3
  88. package/dist/ggplotPalettes.js.map +1 -1
  89. package/dist/hierarchy.d.ts +2 -6
  90. package/dist/hierarchy.js +4 -6
  91. package/dist/hierarchy.js.map +1 -1
  92. package/dist/index.d.ts +2 -2
  93. package/dist/launchInterProScan.d.ts +0 -23
  94. package/dist/launchInterProScan.js +2 -5
  95. package/dist/launchInterProScan.js.map +1 -1
  96. package/dist/measureTextCanvas.js +21 -6
  97. package/dist/measureTextCanvas.js.map +1 -1
  98. package/dist/model.d.ts +50 -100
  99. package/dist/model.js +209 -219
  100. package/dist/model.js.map +1 -1
  101. package/dist/neighborJoining.js +12 -19
  102. package/dist/neighborJoining.js.map +1 -1
  103. package/dist/parseAsn1.js +32 -43
  104. package/dist/parseAsn1.js.map +1 -1
  105. package/dist/renderToSvg.js +5 -2
  106. package/dist/renderToSvg.js.map +1 -1
  107. package/dist/rowCoordinateCalculations.d.ts +13 -17
  108. package/dist/rowCoordinateCalculations.js +45 -39
  109. package/dist/rowCoordinateCalculations.js.map +1 -1
  110. package/dist/seqPosToGlobalCol.d.ts +6 -19
  111. package/dist/seqPosToGlobalCol.js +6 -33
  112. package/dist/seqPosToGlobalCol.js.map +1 -1
  113. package/dist/types.d.ts +4 -11
  114. package/dist/useCanvasAutorun.d.ts +23 -1
  115. package/dist/useCanvasAutorun.js +20 -8
  116. package/dist/useCanvasAutorun.js.map +1 -1
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/package.json +5 -5
  120. package/src/clustalX.ts +22 -22
  121. package/src/colSpace.test.ts +27 -0
  122. package/src/components/ConservationTrack.tsx +7 -5
  123. package/src/components/CopyButton.tsx +44 -0
  124. package/src/components/Loading.tsx +10 -1
  125. package/src/components/MSAView.tsx +25 -32
  126. package/src/components/ResizeHandles.tsx +60 -69
  127. package/src/components/ScrollbarThumb.tsx +48 -0
  128. package/src/components/SequenceTextArea.tsx +3 -16
  129. package/src/components/TextTrack.tsx +7 -12
  130. package/src/components/VerticalScrollbar.tsx +19 -33
  131. package/src/components/dialogs/FeatureDialog.tsx +3 -4
  132. package/src/components/dialogs/{UserProvidedDomainsDialog.tsx → InterProScanFileDialog.tsx} +6 -14
  133. package/src/components/dialogs/TrackInfoDialog.tsx +18 -20
  134. package/src/components/header/FileMenu.tsx +3 -3
  135. package/src/components/header/GappynessSlider.tsx +4 -1
  136. package/src/components/header/{getDomainsMenu.ts → getAnnotationsMenu.ts} +10 -9
  137. package/src/components/header/settingsMenuItems.ts +40 -72
  138. package/src/components/import/ImportForm.tsx +1 -1
  139. package/src/components/import/ImportFormExamples.tsx +82 -133
  140. package/src/components/minimap/Minimap.tsx +23 -31
  141. package/src/components/minimap/MinimapSVG.tsx +32 -31
  142. package/src/components/minimap/minimapLayout.test.ts +60 -0
  143. package/src/components/minimap/minimapLayout.ts +15 -10
  144. package/src/components/msa/{DomainLegend.tsx → AnnotationLegend.tsx} +7 -3
  145. package/src/components/msa/MSACanvas.tsx +3 -10
  146. package/src/components/msa/MSACanvasBlock.tsx +16 -21
  147. package/src/components/msa/MSAMouseoverCanvas.tsx +11 -9
  148. package/src/components/msa/MSAPanel.tsx +2 -2
  149. package/src/components/msa/domainBandCursor.ts +41 -0
  150. package/src/components/msa/renderBoxFeatureCanvasBlock.test.ts +51 -0
  151. package/src/components/msa/renderBoxFeatureCanvasBlock.ts +13 -8
  152. package/src/components/msa/renderMSABlock.ts +19 -23
  153. package/src/components/msa/renderMSAMouseover.ts +38 -35
  154. package/src/components/overlayColors.ts +6 -0
  155. package/src/components/tree/TreeCanvas.tsx +6 -4
  156. package/src/components/tree/TreeCanvasBlock.tsx +11 -12
  157. package/src/components/tree/TreeRuler.tsx +7 -2
  158. package/src/components/tree/renderTreeCanvas.test.ts +15 -7
  159. package/src/components/tree/renderTreeCanvas.ts +20 -17
  160. package/src/domainBands.test.ts +24 -0
  161. package/src/domainLetterColor.test.ts +80 -0
  162. package/src/ggplotPalettes.ts +0 -4
  163. package/src/hierarchy.test.ts +7 -7
  164. package/src/hierarchy.ts +6 -9
  165. package/src/index.ts +4 -1
  166. package/src/largeTree.test.ts +23 -2
  167. package/src/launchInterProScan.ts +3 -30
  168. package/src/loadErrorDisplay.test.tsx +102 -0
  169. package/src/measureTextCanvas.ts +22 -6
  170. package/src/model.ts +233 -244
  171. package/src/modelFilehandleLoaders.test.ts +137 -0
  172. package/src/neighborJoining.test.ts +12 -0
  173. package/src/neighborJoining.ts +12 -20
  174. package/src/parseAsn1.ts +32 -40
  175. package/src/{renderDomainLegendSvg.test.tsx → renderDomainsSvg.test.tsx} +44 -20
  176. package/src/renderMinimapSvg.test.tsx +109 -0
  177. package/src/renderToSvg.tsx +5 -2
  178. package/src/rowCoordinateCalculations.ts +48 -44
  179. package/src/seqPosToGlobalCol.test.ts +39 -51
  180. package/src/seqPosToGlobalCol.ts +6 -41
  181. package/src/types.ts +10 -15
  182. package/src/useCanvasAutorun.test.tsx +98 -0
  183. package/src/useCanvasAutorun.ts +30 -11
  184. package/src/version.ts +1 -1
  185. package/src/viewChrome.test.tsx +134 -0
  186. package/dist/components/dialogs/DomainDialog.d.ts +0 -6
  187. package/dist/components/dialogs/DomainDialog.js +0 -19
  188. package/dist/components/dialogs/DomainDialog.js.map +0 -1
  189. package/dist/components/dialogs/UserProvidedDomainsDialog.js.map +0 -1
  190. package/dist/components/header/SettingsMenu.d.ts +0 -6
  191. package/dist/components/header/SettingsMenu.js +0 -40
  192. package/dist/components/header/SettingsMenu.js.map +0 -1
  193. package/dist/components/header/getDomainsMenu.js.map +0 -1
  194. package/dist/components/msa/DomainLegend.js.map +0 -1
  195. package/src/components/dialogs/DomainDialog.tsx +0 -45
  196. package/src/components/header/SettingsMenu.tsx +0 -57
@@ -45,7 +45,7 @@ export function getNodeX(
45
45
  return ((maxDepthToLeaf - depthToLeaf) / maxDepthToLeaf) * tipX
46
46
  }
47
47
 
48
- export function renderTree({
48
+ function renderTree({
49
49
  offsetY,
50
50
  ctx,
51
51
  model,
@@ -89,7 +89,7 @@ export function renderTree({
89
89
  })
90
90
  }
91
91
 
92
- export function renderCollapsedTriangles({
92
+ function renderCollapsedTriangles({
93
93
  ctx,
94
94
  clickMap,
95
95
  offsetY,
@@ -98,6 +98,7 @@ export function renderCollapsedTriangles({
98
98
  tipX,
99
99
  maxDepthToLeaf,
100
100
  blockSizeYOverride,
101
+ collapsedSet,
101
102
  }: {
102
103
  ctx: RenderCtx
103
104
  clickMap?: ClickMapIndex
@@ -107,11 +108,11 @@ export function renderCollapsedTriangles({
107
108
  tipX: number
108
109
  maxDepthToLeaf: number
109
110
  blockSizeYOverride?: number
111
+ collapsedSet: Set<string>
110
112
  }) {
111
113
  const {
112
114
  hierarchy,
113
115
  showBranchLenEffective: showBranchLen,
114
- collapsed,
115
116
  blockSize,
116
117
  rowHeight,
117
118
  fontSize,
@@ -119,10 +120,9 @@ export function renderCollapsedTriangles({
119
120
  } = model
120
121
  // nothing collapsed is the common case, and the traversal below visits every
121
122
  // node in the tree on every block of every redraw
122
- if (collapsed.length === 0) {
123
+ if (collapsedSet.size === 0) {
123
124
  return
124
125
  }
125
- const collapsedSet = new Set(collapsed)
126
126
  const by = blockSizeYOverride ?? blockSize
127
127
  const halfHeight = Math.max(2, rowHeight * 0.42)
128
128
  forEachDescendant(hierarchy, node => {
@@ -171,7 +171,7 @@ export function renderCollapsedTriangles({
171
171
  })
172
172
  }
173
173
 
174
- export function renderNodeBubbles({
174
+ function renderNodeBubbles({
175
175
  ctx,
176
176
  clickMap,
177
177
  offsetY,
@@ -179,6 +179,7 @@ export function renderNodeBubbles({
179
179
  tipX,
180
180
  maxDepthToLeaf,
181
181
  blockSizeYOverride,
182
+ collapsedSet,
182
183
  }: {
183
184
  ctx: RenderCtx
184
185
  clickMap?: ClickMapIndex
@@ -187,16 +188,15 @@ export function renderNodeBubbles({
187
188
  tipX: number
188
189
  maxDepthToLeaf: number
189
190
  blockSizeYOverride?: number
191
+ collapsedSet: Set<string>
190
192
  }) {
191
193
  const {
192
194
  hierarchy,
193
195
  showBranchLenEffective: showBranchLen,
194
- collapsed,
195
196
  blockSize,
196
197
  marginLeft: ml,
197
198
  } = model
198
199
  const by = blockSizeYOverride ?? blockSize
199
- const collapsedSet = new Set(collapsed)
200
200
  forEachDescendant(hierarchy, node => {
201
201
  const x = getNodeX(node, showBranchLen, tipX, maxDepthToLeaf)
202
202
  if (x === undefined) {
@@ -231,7 +231,7 @@ export function renderNodeBubbles({
231
231
  })
232
232
  }
233
233
 
234
- export function renderTreeLabels({
234
+ function renderTreeLabels({
235
235
  theme,
236
236
  model,
237
237
  offsetY,
@@ -240,6 +240,7 @@ export function renderTreeLabels({
240
240
  tipX,
241
241
  maxDepthToLeaf,
242
242
  blockSizeYOverride,
243
+ collapsedSet,
243
244
  }: {
244
245
  model: MsaViewModel
245
246
  offsetY: number
@@ -249,6 +250,7 @@ export function renderTreeLabels({
249
250
  tipX: number
250
251
  maxDepthToLeaf: number
251
252
  blockSizeYOverride?: number
253
+ collapsedSet: Set<string>
252
254
  }) {
253
255
  const {
254
256
  fontSize,
@@ -262,16 +264,11 @@ export function renderTreeLabels({
262
264
  marginLeft,
263
265
  noTree,
264
266
  labelWidthMap,
265
- collapsed,
266
267
  } = model
268
+ const by = blockSizeYOverride ?? blockSize
267
269
  // labels only exist for leaves, which are laid out top to bottom, so take the
268
270
  // same slice the MSA renderer uses instead of walking every tip per block
269
- const visibleLeaves = getVisibleLeaves({
270
- model,
271
- offsetY,
272
- blockSizeY: blockSizeYOverride ?? model.blockSize,
273
- })
274
- const by = blockSizeYOverride ?? blockSize
271
+ const visibleLeaves = getVisibleLeaves({ model, offsetY, blockSizeY: by })
275
272
  const emHeight = ctx.measureText('M').width
276
273
  if (labelsAlignRight) {
277
274
  ctx.textAlign = 'right'
@@ -289,7 +286,7 @@ export function renderTreeLabels({
289
286
  const displayName = treeMetadata[name]?.genome || name
290
287
  // a collapsed clade is drawn as a triangle + tip count; suppress its leaf
291
288
  // label when the "name" is just the auto-generated internal-node id
292
- const isAnonymousCollapsed = collapsed.includes(id) && name === id
289
+ const isAnonymousCollapsed = collapsedSet.has(id) && name === id
293
290
  if (!isAnonymousCollapsed && inYBlock(y, offsetY, by)) {
294
291
  // note: +rowHeight/4 matches with -rowHeight/4 in msa
295
292
  const yp = y + fontSize / 4
@@ -389,6 +386,9 @@ export function renderTreeCanvas({
389
386
  // maxBranchLength for the cladogram would pin every node to x=0 for a tree
390
387
  // with no branch lengths -- which is exactly what forces cladogram mode.
391
388
  const tipX = showBranchLenEffective ? maxBranchLength : treeWidth
389
+ // built once for all three passes: each of them asks about a different node on
390
+ // every block of every redraw, and `collapsed` is a list
391
+ const collapsedSet = new Set(model.collapsed)
392
392
 
393
393
  if (!noTree && drawTree) {
394
394
  renderTree({
@@ -410,6 +410,7 @@ export function renderTreeCanvas({
410
410
  tipX,
411
411
  maxDepthToLeaf,
412
412
  blockSizeYOverride,
413
+ collapsedSet,
413
414
  })
414
415
 
415
416
  if (drawNodeBubbles) {
@@ -421,6 +422,7 @@ export function renderTreeCanvas({
421
422
  tipX,
422
423
  maxDepthToLeaf,
423
424
  blockSizeYOverride,
425
+ collapsedSet,
424
426
  })
425
427
  }
426
428
  }
@@ -435,6 +437,7 @@ export function renderTreeCanvas({
435
437
  tipX,
436
438
  maxDepthToLeaf,
437
439
  blockSizeYOverride,
440
+ collapsedSet,
438
441
  })
439
442
  }
440
443
 
@@ -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