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.
- package/bundle/index.js +2 -2
- package/bundle/index.js.map +1 -1
- package/dist/components/Loading.js +6 -4
- package/dist/components/Loading.js.map +1 -1
- package/dist/components/MSAViewer.d.ts +3 -1
- package/dist/components/MSAViewer.js +2 -1
- package/dist/components/MSAViewer.js.map +1 -1
- package/dist/components/renderCtx.d.ts +1 -1
- package/dist/components/tree/TreeNodeMenu.js +1 -1
- package/dist/components/tree/TreeNodeMenu.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.d.ts +10 -0
- package/dist/components/tree/renderTreeCanvas.js +81 -12
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/fetchUtils.d.ts +19 -0
- package/dist/fetchUtils.js +28 -0
- package/dist/fetchUtils.js.map +1 -1
- package/dist/hierarchy.d.ts +9 -1
- package/dist/hierarchy.js +34 -0
- package/dist/hierarchy.js.map +1 -1
- package/dist/model/msaModel.d.ts +7 -0
- package/dist/model/msaModel.js +14 -0
- package/dist/model/msaModel.js.map +1 -1
- package/dist/model.d.ts +5 -2
- package/dist/model.js +65 -16
- package/dist/model.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/components/Loading.tsx +15 -5
- package/src/components/MSAViewer.tsx +4 -0
- package/src/components/renderCtx.ts +1 -0
- package/src/components/tree/TreeNodeMenu.tsx +1 -1
- package/src/components/tree/renderTreeCanvas.ts +113 -11
- package/src/fetchUtils.test.ts +66 -0
- package/src/fetchUtils.ts +48 -0
- package/src/hierarchy.test.ts +56 -0
- package/src/hierarchy.ts +41 -1
- package/src/impgRender.test.tsx +125 -0
- package/src/model/msaModel.ts +19 -0
- package/src/model.ts +67 -21
- 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
|
|
package/src/hierarchy.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/model/msaModel.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1777
|
-
|
|
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
|
|
1816
|
+
await fetchTextWithProgress(
|
|
1796
1817
|
openLocation(treeMetadataFilehandle),
|
|
1818
|
+
status => {
|
|
1819
|
+
self.setStatus(status)
|
|
1820
|
+
},
|
|
1797
1821
|
),
|
|
1798
1822
|
)
|
|
1799
1823
|
} catch (e) {
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
|
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
|
-
|
|
1839
|
-
|
|
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
|
|
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
|
-
|
|
1871
|
-
|
|
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.
|
|
1
|
+
export const version = '5.4.0'
|