react-msaview 5.2.1 → 5.4.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 (41) hide show
  1. package/bundle/index.js +2 -2
  2. package/bundle/index.js.map +1 -1
  3. package/dist/components/Loading.js +6 -4
  4. package/dist/components/Loading.js.map +1 -1
  5. package/dist/components/MSAViewer.d.ts +3 -1
  6. package/dist/components/MSAViewer.js +2 -1
  7. package/dist/components/MSAViewer.js.map +1 -1
  8. package/dist/components/renderCtx.d.ts +1 -1
  9. package/dist/components/tree/TreeNodeMenu.js +1 -1
  10. package/dist/components/tree/TreeNodeMenu.js.map +1 -1
  11. package/dist/components/tree/renderTreeCanvas.d.ts +10 -0
  12. package/dist/components/tree/renderTreeCanvas.js +81 -12
  13. package/dist/components/tree/renderTreeCanvas.js.map +1 -1
  14. package/dist/fetchUtils.d.ts +19 -0
  15. package/dist/fetchUtils.js +28 -0
  16. package/dist/fetchUtils.js.map +1 -1
  17. package/dist/hierarchy.d.ts +9 -1
  18. package/dist/hierarchy.js +34 -0
  19. package/dist/hierarchy.js.map +1 -1
  20. package/dist/model/msaModel.d.ts +7 -0
  21. package/dist/model/msaModel.js +14 -0
  22. package/dist/model/msaModel.js.map +1 -1
  23. package/dist/model.d.ts +5 -2
  24. package/dist/model.js +65 -16
  25. package/dist/model.js.map +1 -1
  26. package/dist/version.d.ts +1 -1
  27. package/dist/version.js +1 -1
  28. package/package.json +3 -3
  29. package/src/components/Loading.tsx +15 -5
  30. package/src/components/MSAViewer.tsx +4 -0
  31. package/src/components/renderCtx.ts +1 -0
  32. package/src/components/tree/TreeNodeMenu.tsx +1 -1
  33. package/src/components/tree/renderTreeCanvas.ts +113 -11
  34. package/src/fetchUtils.test.ts +66 -0
  35. package/src/fetchUtils.ts +48 -0
  36. package/src/hierarchy.test.ts +56 -0
  37. package/src/hierarchy.ts +41 -1
  38. package/src/impgRender.test.tsx +125 -0
  39. package/src/model/msaModel.ts +19 -0
  40. package/src/model.ts +67 -21
  41. package/src/version.ts +1 -1
@@ -0,0 +1,66 @@
1
+ import { openLocation } from '@jbrowse/core/util/io'
2
+ import { describe, expect, test } from 'vitest'
3
+
4
+ import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
5
+
6
+ import type { FetchStatus } from './fetchUtils.ts'
7
+
8
+ const loc = openLocation({
9
+ uri: 'http://example.com/data.fa',
10
+ locationType: 'UriLocation',
11
+ })
12
+
13
+ describe('fetchTextWithProgress', () => {
14
+ test('reports progress messages then clears status', async () => {
15
+ const statuses: (FetchStatus | undefined)[] = []
16
+ const result = await fetchTextWithProgress(
17
+ loc,
18
+ s => statuses.push(s),
19
+ async (_loc, opts) => {
20
+ opts.statusCallback('Downloading file')
21
+ opts.statusCallback('')
22
+ return 'GREETINGS'
23
+ },
24
+ )
25
+ expect(result).toBe('GREETINGS')
26
+ expect(statuses.map(s => s?.msg)).toEqual([
27
+ 'Downloading file',
28
+ undefined,
29
+ undefined,
30
+ ])
31
+ })
32
+
33
+ test('clears status even when the fetch throws', async () => {
34
+ const statuses: (FetchStatus | undefined)[] = []
35
+ await expect(
36
+ fetchTextWithProgress(
37
+ loc,
38
+ s => statuses.push(s),
39
+ async () => {
40
+ throw new Error('network down')
41
+ },
42
+ ),
43
+ ).rejects.toThrow('network down')
44
+ expect(statuses.at(-1)).toBeUndefined()
45
+ })
46
+
47
+ test('onCancel aborts the underlying request', async () => {
48
+ const statuses: (FetchStatus | undefined)[] = []
49
+ const promise = fetchTextWithProgress(
50
+ loc,
51
+ s => statuses.push(s),
52
+ (_loc, opts) =>
53
+ new Promise<string>((_resolve, reject) => {
54
+ opts.statusCallback('Downloading file')
55
+ opts.signal.addEventListener('abort', () => {
56
+ reject(new DOMException('Aborted', 'AbortError'))
57
+ })
58
+ }),
59
+ )
60
+
61
+ statuses.find(s => s?.onCancel)?.onCancel?.()
62
+
63
+ await expect(promise).rejects.toSatisfy(isAbortError)
64
+ expect(statuses.at(-1)).toBeUndefined()
65
+ })
66
+ })
package/src/fetchUtils.ts CHANGED
@@ -1,3 +1,51 @@
1
+ import { fetchAndMaybeUnzipText } from '@jbrowse/core/util'
2
+
3
+ export interface FetchStatus {
4
+ msg: string
5
+ url?: string
6
+ onCancel?: () => void
7
+ }
8
+
9
+ type Filehandle = Parameters<typeof fetchAndMaybeUnzipText>[0]
10
+
11
+ type ProgressFetcher = (
12
+ loc: Filehandle,
13
+ opts: { signal: AbortSignal; statusCallback: (msg: string) => void },
14
+ ) => Promise<string>
15
+
16
+ /**
17
+ * Fetch text from a filehandle while reporting download/unzip progress through
18
+ * setStatus, and wiring a Cancel handler that aborts the underlying request.
19
+ * The status is always cleared once the fetch settles. The fetcher is injectable
20
+ * for testing.
21
+ */
22
+ export async function fetchTextWithProgress(
23
+ loc: Filehandle,
24
+ setStatus: (status?: FetchStatus) => void,
25
+ fetcher: ProgressFetcher = fetchAndMaybeUnzipText,
26
+ ) {
27
+ const controller = new AbortController()
28
+ try {
29
+ return await fetcher(loc, {
30
+ signal: controller.signal,
31
+ statusCallback: msg => {
32
+ setStatus(
33
+ msg
34
+ ? {
35
+ msg,
36
+ onCancel: () => {
37
+ controller.abort()
38
+ },
39
+ }
40
+ : undefined,
41
+ )
42
+ },
43
+ })
44
+ } finally {
45
+ setStatus(undefined)
46
+ }
47
+ }
48
+
1
49
  export async function myfetch(url: string, args?: RequestInit) {
2
50
  const response = await fetch(url, args)
3
51
 
@@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'
3
3
  import {
4
4
  calcDepthToLeaf,
5
5
  collapse,
6
+ collapsedSubtreeLengthExtent,
6
7
  find,
7
8
  findMaxBranchLen,
8
9
  hierarchy,
@@ -109,6 +110,61 @@ describe('collapse', () => {
109
110
  collapse(leaf)
110
111
  expect(leaves(h).map(n => n.data.name)).toEqual(['A1', 'A2', 'B1', 'B2'])
111
112
  })
113
+
114
+ test('preserves the subtree depth so the layout stays stable', () => {
115
+ const h = hierarchy(makeTree(), d => d.children)
116
+ const nodeA = find(h, n => n.data.id === 'A')!
117
+ collapse(nodeA)
118
+ // the collapsed node keeps its real depth (1) rather than becoming a depth-0
119
+ // leaf, so the cladogram apex sits at the true branch point
120
+ expect(nodeA.depthToLeaf).toBe(1)
121
+ // and the root still measures the tree as 2 deep, so collapsing A does not
122
+ // horizontally shift the rest of the tree
123
+ expect(calcDepthToLeaf(h)).toBe(2)
124
+ })
125
+ })
126
+
127
+ describe('collapsedSubtreeLengthExtent', () => {
128
+ // root → A(1) → [ A1(2), C(3) → [ C1(1), C2(10) ] ]
129
+ function makeLenTree(): NodeWithIds {
130
+ return {
131
+ id: 'root',
132
+ name: 'root',
133
+ children: [
134
+ {
135
+ id: 'A',
136
+ name: 'A',
137
+ length: 1,
138
+ children: [
139
+ { id: 'A1', name: 'A1', length: 2, children: [] },
140
+ {
141
+ id: 'C',
142
+ name: 'C',
143
+ length: 3,
144
+ children: [
145
+ { id: 'C1', name: 'C1', length: 1, children: [] },
146
+ { id: 'C2', name: 'C2', length: 10, children: [] },
147
+ ],
148
+ },
149
+ ],
150
+ },
151
+ ],
152
+ }
153
+ }
154
+
155
+ test('min/max cumulative branch length to the tips below a node', () => {
156
+ const h = hierarchy(makeLenTree(), d => d.children)
157
+ const nodeA = find(h, n => n.data.id === 'A')!
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 })
161
+ })
162
+
163
+ test('returns zero extent for a node with no descendants', () => {
164
+ const h = hierarchy(makeLenTree(), d => d.children)
165
+ const leaf = find(h, n => n.data.id === 'A1')!
166
+ expect(collapsedSubtreeLengthExtent(leaf)).toEqual({ min: 0, max: 0 })
167
+ })
112
168
  })
113
169
 
114
170
  describe('leaf removal via parent.children filter', () => {
package/src/hierarchy.ts CHANGED
@@ -12,6 +12,10 @@ export interface HierarchyNode<T = NodeWithIds> {
12
12
  len?: number
13
13
  depthToLeaf?: number
14
14
  _children?: HierarchyNode<T>[] | null
15
+ // pixel x-positions of the nearest/farthest tips of a collapsed subtree, used
16
+ // to draw the collapsed-clade triangle. Set in the model's hierarchy getter.
17
+ collapsedTipXNear?: number
18
+ collapsedTipXFar?: number
15
19
  }
16
20
 
17
21
  export interface HierarchyLink<T = NodeWithIds> {
@@ -235,16 +239,52 @@ export function clusterLayout<T>(
235
239
 
236
240
  export function collapse<T>(node: HierarchyNode<T>) {
237
241
  if (node.children) {
242
+ // memoize the real subtree depth onto the node before detaching its
243
+ // children, so cladogram positioning keeps the node (now the apex of a
244
+ // collapsed-clade triangle) at its true branch point rather than snapping
245
+ // it to the tip-alignment line. This also keeps the rest of the tree's
246
+ // horizontal layout stable when a clade is collapsed.
247
+ calcDepthToLeaf(node)
238
248
  node._children = node.children
239
249
  node.children = null
240
250
  }
241
251
  }
242
252
 
253
+ // Min/max cumulative raw branch length from a collapsed node down to the tips of
254
+ // its detached subtree (excludes the node's own branch). Used to size the
255
+ // collapsed-clade triangle in phylogram mode.
256
+ export function collapsedSubtreeLengthExtent<T extends { length?: number }>(
257
+ node: HierarchyNode<T>,
258
+ ) {
259
+ const roots = node._children ?? node.children
260
+ let min = Infinity
261
+ let max = 0
262
+ if (roots) {
263
+ const stack = roots.map(child => ({
264
+ node: child,
265
+ acc: Math.max(child.data.length ?? 0, 0),
266
+ }))
267
+ while (stack.length > 0) {
268
+ const { node: n, acc } = stack.pop()!
269
+ const kids = n.children ?? n._children
270
+ if (kids?.length) {
271
+ for (const child of kids) {
272
+ stack.push({ node: child, acc: acc + Math.max(child.data.length ?? 0, 0) })
273
+ }
274
+ } else {
275
+ min = Math.min(min, acc)
276
+ max = Math.max(max, acc)
277
+ }
278
+ }
279
+ }
280
+ return { min: min === Infinity ? 0 : min, max }
281
+ }
282
+
243
283
  // Cladogram positioning based on ape's plot.phylo: uses topological depth (max
244
284
  // steps to a tip) instead of branch length so all leaves align at the rightmost
245
285
  // x. Memoizes onto node.depthToLeaf since the layout walks the tree repeatedly.
246
286
  // See https://github.com/emmanuelparadis/ape/blob/master/R/plot.phylo.R
247
- export function calcDepthToLeaf(node: HierarchyNode): number {
287
+ export function calcDepthToLeaf<T>(node: HierarchyNode<T>): number {
248
288
  const nodes = descendants(node)
249
289
  for (let i = nodes.length - 1; i >= 0; i--) {
250
290
  const n = nodes[i]!
@@ -0,0 +1,125 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // End-to-end check of the impg (https://github.com/pangenome/impg) integration:
4
+ // impg's scripts/faln2html.py drives the viewer exactly as below --
5
+ // MSAModelF().create({ type:'MsaView', data:{ msa } })
6
+ // model.setColorSchemeName('percent_identity_dynamic')
7
+ // model.setBgColor(true); model.setWidth(1300)
8
+ // then renders <MSAView model/>. This exercises that path headlessly via the
9
+ // public renderToSvg export. Polyfills mirror scripts/generateFigures.tsx.
10
+ import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
11
+ import { enableStaticRendering } from 'mobx-react'
12
+ import { beforeAll, expect, test } from 'vitest'
13
+
14
+ import MSAModelF from './model.ts'
15
+ import { renderToSvg } from './renderToSvg.tsx'
16
+
17
+ class Mat {
18
+ constructor(
19
+ public a = 1,
20
+ public b = 0,
21
+ public c = 0,
22
+ public d = 1,
23
+ public e = 0,
24
+ public f = 0,
25
+ ) {}
26
+ multiply(o: Mat) {
27
+ return new Mat(
28
+ this.a * o.a + this.c * o.b,
29
+ this.b * o.a + this.d * o.b,
30
+ this.a * o.c + this.c * o.d,
31
+ this.b * o.c + this.d * o.d,
32
+ this.a * o.e + this.c * o.f + this.e,
33
+ this.b * o.e + this.d * o.f + this.f,
34
+ )
35
+ }
36
+ translate(x: number, y = 0) {
37
+ return this.multiply(new Mat(1, 0, 0, 1, x, y))
38
+ }
39
+ scale(x: number, y = x) {
40
+ return this.multiply(new Mat(x, 0, 0, y, 0, 0))
41
+ }
42
+ }
43
+ class Pt {
44
+ constructor(
45
+ public x = 0,
46
+ public y = 0,
47
+ ) {}
48
+ matrixTransform(m: Mat) {
49
+ return new Pt(
50
+ m.a * this.x + m.c * this.y + m.e,
51
+ m.b * this.x + m.d * this.y + m.f,
52
+ )
53
+ }
54
+ }
55
+
56
+ beforeAll(() => {
57
+ enableStaticRendering(true)
58
+ const g = globalThis as Record<string, unknown>
59
+ g.DOMMatrix = Mat
60
+ g.DOMPoint = Pt
61
+ HTMLCanvasElement.prototype.getContext = function () {
62
+ let font = '10px sans-serif'
63
+ return {
64
+ get font() {
65
+ return font
66
+ },
67
+ set font(v: string) {
68
+ font = v
69
+ },
70
+ measureText(t: string) {
71
+ const size = Number.parseFloat(font) || 10
72
+ return { width: t.length * size * 0.6 } as TextMetrics
73
+ },
74
+ } as unknown as CanvasRenderingContext2D
75
+ } as unknown as typeof HTMLCanvasElement.prototype.getContext
76
+ })
77
+
78
+ // representative `impg query -o fasta-aln` block: PanSN names with region
79
+ // colons, dash gaps, mixed-case (soft-masked) bases
80
+ const impgFastaAln = `>HG002#1#chr1:1000-1040
81
+ ACGTACGT--ACGTNNNNacgtACGTACGTACGTACGT
82
+ >HG003#1#chr1:1000-1040
83
+ ACGTAC-TGGACGTNNNNACGTAC--ACGTacgtACGT
84
+ >CHM13#0#chr1:1000-1040
85
+ ACGTACGTGGACGTNNNNACGT----ACGTACGTACGT`
86
+
87
+ test('renders an impg fasta-aln block with declarative impg config', async () => {
88
+ // everything except width is a declarative MST property, so it can be set in
89
+ // the create() snapshot -- no setColorSchemeName/setBgColor/setMSAFormat
90
+ // action calls needed. msaFormat:'fasta' forces the parser and skips the
91
+ // heuristic fasta-vs-a3m sniffing. width is volatile (tracks container size)
92
+ // so it remains an action.
93
+ const model = MSAModelF().create({
94
+ id: 'impg-test',
95
+ type: 'MsaView',
96
+ height: 400,
97
+ colorSchemeName: 'percent_identity_dynamic',
98
+ bgColor: true,
99
+ msaFormat: 'fasta',
100
+ data: { msa: impgFastaAln },
101
+ })
102
+ model.setWidth(1300)
103
+
104
+ // the declarative config took effect
105
+ expect(model.colorSchemeName).toBe('percent_identity_dynamic')
106
+ expect(model.bgColor).toBe(true)
107
+ expect(model.msaFormat).toBe('fasta')
108
+
109
+ // the alignment parsed and all 3 rows are present at equal width
110
+ expect(model.rowNames).toEqual([
111
+ 'HG002#1#chr1:1000-1040',
112
+ 'HG003#1#chr1:1000-1040',
113
+ 'CHM13#0#chr1:1000-1040',
114
+ ])
115
+ expect(model.numColumns).toBe(38)
116
+
117
+ const svg = await renderToSvg(model, {
118
+ theme: createJBrowseTheme(),
119
+ exportType: 'entire',
120
+ includeMinimap: false,
121
+ includeTracks: false,
122
+ })
123
+ expect(svg).toContain('<svg')
124
+ expect(svg).toContain('HG002#1#chr1:1000-1040')
125
+ })
@@ -2,6 +2,10 @@ import { types } from '@jbrowse/mobx-state-tree'
2
2
 
3
3
  import { defaultBgColor, defaultColorSchemeName } from '../constants.ts'
4
4
 
5
+ import type { MSAFormat } from 'msa-parsers'
6
+
7
+ const msaFormats: MSAFormat[] = ['stockholm', 'a3m', 'fasta', 'emf', 'clustal']
8
+
5
9
  /**
6
10
  * #stateModel MSAModel
7
11
  */
@@ -19,6 +23,13 @@ export function MSAModelF() {
19
23
  * default color scheme name
20
24
  */
21
25
  colorSchemeName: defaultColorSchemeName,
26
+
27
+ /**
28
+ * #property
29
+ * force the MSA data to be parsed as a specific format instead of relying
30
+ * on auto-detection (which is ambiguous between e.g. fasta and a3m)
31
+ */
32
+ msaFormat: types.maybe(types.enumeration<MSAFormat>('MSAFormat', msaFormats)),
22
33
  })
23
34
  .actions(self => ({
24
35
  /**
@@ -35,5 +46,13 @@ export function MSAModelF() {
35
46
  setBgColor(arg: boolean) {
36
47
  self.bgColor = arg
37
48
  },
49
+
50
+ /**
51
+ * #action
52
+ * force a specific MSA parser, or pass undefined to auto-detect
53
+ */
54
+ setMSAFormat(arg?: MSAFormat) {
55
+ self.msaFormat = arg
56
+ },
38
57
  }))
39
58
  }
package/src/model.ts CHANGED
@@ -1,10 +1,4 @@
1
- import {
2
- clamp,
3
- fetchAndMaybeUnzipText,
4
- groupBy,
5
- notEmpty,
6
- sum,
7
- } from '@jbrowse/core/util'
1
+ import { clamp, groupBy, notEmpty, sum } from '@jbrowse/core/util'
8
2
  import { openLocation } from '@jbrowse/core/util/io'
9
3
  import { ElementId, FileLocation } from '@jbrowse/core/util/types/mst'
10
4
  import { addDisposer, cast, types } from '@jbrowse/mobx-state-tree'
@@ -51,13 +45,16 @@ import {
51
45
  minRowHeight,
52
46
  } from './constants.ts'
53
47
  import { createPaletteMap } from './createPaletteMap.ts'
48
+ import { fetchTextWithProgress, isAbortError } from './fetchUtils.ts'
54
49
  import { flatToTree } from './flatToTree.ts'
55
50
  import {
56
51
  calcDepthToLeaf,
57
52
  clusterLayout,
58
53
  collapse,
54
+ collapsedSubtreeLengthExtent,
59
55
  find,
60
56
  findMaxBranchLen,
57
+ forEachDescendant,
61
58
  hierarchy,
62
59
  leaves,
63
60
  links,
@@ -669,7 +666,9 @@ function stateModelFactory() {
669
666
  const text = self.data.msa
670
667
  // uses parseMSA so the named MSAParserType return type is portable
671
668
  // to downstream consumers (avoids TS2883 with default exports)
672
- return text ? parseMSA(text, self.currentAlignment) : null
669
+ return text
670
+ ? parseMSA(text, self.currentAlignment, self.msaFormat)
671
+ : null
673
672
  },
674
673
  /**
675
674
  * #getter
@@ -1023,7 +1022,18 @@ function stateModelFactory() {
1023
1022
  clusterLayout(r, this.totalHeight, self.treeWidth)
1024
1023
  r.data.length = 0
1025
1024
  const max = maxLength(r)
1026
- setBrLength(r, 0, max ? self.treeWidth / max : 0)
1025
+ const k = max ? self.treeWidth / max : 0
1026
+ setBrLength(r, 0, k)
1027
+ // for each collapsed clade, record the pixel x-positions of its
1028
+ // nearest/farthest tips so the renderer can draw a triangle that spans
1029
+ // the branch-length extent of the hidden subtree
1030
+ forEachDescendant(r, node => {
1031
+ if (node._children) {
1032
+ const { min, max: mx } = collapsedSubtreeLengthExtent(node)
1033
+ node.collapsedTipXNear = (node.len ?? 0) + min * k
1034
+ node.collapsedTipXFar = (node.len ?? 0) + mx * k
1035
+ }
1036
+ })
1027
1037
  return r as HierarchyNode<NodeWithIdsAndLength>
1028
1038
  },
1029
1039
 
@@ -1759,8 +1769,13 @@ function stateModelFactory() {
1759
1769
  const generation = ++treeGeneration
1760
1770
  try {
1761
1771
  self.setLoadingTree(true)
1762
- const text = await fetchAndMaybeUnzipText(
1772
+ const text = await fetchTextWithProgress(
1763
1773
  openLocation(treeFilehandle),
1774
+ status => {
1775
+ if (generation === treeGeneration) {
1776
+ self.setStatus(status)
1777
+ }
1778
+ },
1764
1779
  )
1765
1780
  if (generation === treeGeneration) {
1766
1781
  transaction(() => {
@@ -1773,8 +1788,14 @@ function stateModelFactory() {
1773
1788
  }
1774
1789
  } catch (e) {
1775
1790
  if (generation === treeGeneration) {
1776
- console.error(e)
1777
- self.setError(e)
1791
+ if (isAbortError(e)) {
1792
+ // cancelled by the user: drop the filehandle so the view
1793
+ // returns to the import form instead of a stuck spinner
1794
+ self.setTreeFilehandle(undefined)
1795
+ } else {
1796
+ console.error(e)
1797
+ self.setError(e)
1798
+ }
1778
1799
  }
1779
1800
  } finally {
1780
1801
  if (generation === treeGeneration) {
@@ -1792,13 +1813,19 @@ function stateModelFactory() {
1792
1813
  if (treeMetadataFilehandle) {
1793
1814
  try {
1794
1815
  self.setTreeMetadata(
1795
- await fetchAndMaybeUnzipText(
1816
+ await fetchTextWithProgress(
1796
1817
  openLocation(treeMetadataFilehandle),
1818
+ status => {
1819
+ self.setStatus(status)
1820
+ },
1797
1821
  ),
1798
1822
  )
1799
1823
  } catch (e) {
1800
- console.error(e)
1801
- self.setError(e)
1824
+ // ignore user cancel (isAbortError); treeMetadata is optional
1825
+ if (!isAbortError(e)) {
1826
+ console.error(e)
1827
+ self.setError(e)
1828
+ }
1802
1829
  }
1803
1830
  }
1804
1831
  }),
@@ -1827,16 +1854,24 @@ function stateModelFactory() {
1827
1854
  const { gffFilehandle } = self
1828
1855
  if (gffFilehandle) {
1829
1856
  try {
1830
- const gffText = await fetchAndMaybeUnzipText(
1857
+ const gffText = await fetchTextWithProgress(
1831
1858
  openLocation(gffFilehandle),
1859
+ status => {
1860
+ self.setStatus(status)
1861
+ },
1832
1862
  )
1833
1863
  self.applyGFFText(gffText)
1834
1864
  if (gffFilehandle.locationType === 'BlobLocation') {
1835
1865
  self.setGFFFilehandle(undefined)
1836
1866
  }
1837
1867
  } catch (e) {
1838
- console.error(e)
1839
- self.setError(e)
1868
+ // on user cancel (isAbortError) drop the filehandle
1869
+ if (isAbortError(e)) {
1870
+ self.setGFFFilehandle(undefined)
1871
+ } else {
1872
+ console.error(e)
1873
+ self.setError(e)
1874
+ }
1840
1875
  }
1841
1876
  }
1842
1877
  }),
@@ -1853,8 +1888,13 @@ function stateModelFactory() {
1853
1888
  try {
1854
1889
  self.setLoadingMSA(true)
1855
1890
  self.setError(undefined)
1856
- const txt = await fetchAndMaybeUnzipText(
1891
+ const txt = await fetchTextWithProgress(
1857
1892
  openLocation(msaFilehandle),
1893
+ status => {
1894
+ if (generation === msaGeneration) {
1895
+ self.setStatus(status)
1896
+ }
1897
+ },
1858
1898
  )
1859
1899
  if (generation === msaGeneration) {
1860
1900
  transaction(() => {
@@ -1867,8 +1907,14 @@ function stateModelFactory() {
1867
1907
  }
1868
1908
  } catch (e) {
1869
1909
  if (generation === msaGeneration) {
1870
- console.error(e)
1871
- self.setError(e)
1910
+ if (isAbortError(e)) {
1911
+ // cancelled by the user: drop the filehandle so the view
1912
+ // returns to the import form instead of a stuck spinner
1913
+ self.setMSAFilehandle(undefined)
1914
+ } else {
1915
+ console.error(e)
1916
+ self.setError(e)
1917
+ }
1872
1918
  }
1873
1919
  } finally {
1874
1920
  if (generation === msaGeneration) {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '5.2.1'
1
+ export const version = '5.4.0'