jbrowse-plugin-msaview 2.4.5 → 2.5.1
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/dist/LaunchMsaView/util.js +1 -1
- package/dist/LaunchMsaViewExtensionPoint/index.js +2 -1
- package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.js +53 -0
- package/dist/MsaViewPanel/loadProteinDomains.d.ts +16 -0
- package/dist/MsaViewPanel/loadProteinDomains.js +33 -0
- package/dist/MsaViewPanel/model.d.ts +69 -399
- package/dist/MsaViewPanel/model.js +17 -39
- package/dist/MsaViewPanel/pairwiseAlignment.js +2 -9
- package/dist/MsaViewPanel/structureConnection.d.ts +0 -6
- package/dist/MsaViewPanel/structureConnection.js +0 -16
- package/dist/MsaViewPanel/structureConnection.test.js +1 -51
- package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.d.ts +1 -0
- package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +92 -0
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +30 -30
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/domainCache.d.ts +8 -0
- package/dist/utils/domainCache.js +28 -0
- package/dist/utils/eutils.d.ts +3 -0
- package/dist/utils/eutils.js +14 -0
- package/dist/utils/msa.js +23 -19
- package/dist/utils/ncbiBlast.js +23 -25
- package/dist/utils/ncbiDomains.d.ts +39 -0
- package/dist/utils/ncbiDomains.js +154 -0
- package/dist/utils/poll.d.ts +11 -0
- package/dist/utils/poll.js +19 -0
- package/dist/utils/taxonomyNames.js +2 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +17 -14
- package/src/LaunchMsaView/util.ts +1 -3
- package/src/LaunchMsaViewExtensionPoint/index.ts +3 -0
- package/src/MsaViewPanel/afterCreateAutoruns.ts +54 -0
- package/src/MsaViewPanel/loadProteinDomains.ts +57 -0
- package/src/MsaViewPanel/model.ts +23 -45
- package/src/MsaViewPanel/pairwiseAlignment.ts +2 -7
- package/src/MsaViewPanel/structureConnection.test.ts +1 -61
- package/src/MsaViewPanel/structureConnection.ts +0 -22
- package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +112 -0
- package/src/utils/domainCache.ts +44 -0
- package/src/utils/eutils.ts +16 -0
- package/src/utils/msa.ts +23 -19
- package/src/utils/ncbiBlast.ts +28 -33
- package/src/utils/ncbiDomains.ts +171 -0
- package/src/utils/poll.ts +28 -0
- package/src/utils/taxonomyNames.ts +3 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fetchProteinDomains } from '../utils/ncbiDomains'
|
|
2
|
+
|
|
3
|
+
import type { InterProScanResults } from 'react-msaview'
|
|
4
|
+
|
|
5
|
+
// structural subset of the MSA model: the full model type can't be used here
|
|
6
|
+
// because it references this very action, creating a self-referential cycle
|
|
7
|
+
interface DomainModel {
|
|
8
|
+
data: { treeMetadata?: string }
|
|
9
|
+
setProgress: (arg: string) => void
|
|
10
|
+
setDomains: (data: Record<string, InterProScanResults>) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Overlay protein domains on the alignment using NCBI's pre-computed CDD
|
|
15
|
+
* annotations. The BLAST workflow stores each hit's accession in treeMetadata,
|
|
16
|
+
* so we look those up via efetch and key the results by MSA row name (which is
|
|
17
|
+
* what react-msaview matches domains against).
|
|
18
|
+
*/
|
|
19
|
+
export async function loadProteinDomains(self: DomainModel) {
|
|
20
|
+
const metadataJson = self.data.treeMetadata
|
|
21
|
+
if (!metadataJson) {
|
|
22
|
+
throw new Error('No sequence metadata available to look up domains')
|
|
23
|
+
}
|
|
24
|
+
const metadata = JSON.parse(metadataJson) as Record<
|
|
25
|
+
string,
|
|
26
|
+
Record<string, string>
|
|
27
|
+
>
|
|
28
|
+
|
|
29
|
+
const rowAccessions = Object.entries(metadata)
|
|
30
|
+
.map(([rowName, meta]) => ({ rowName, accession: meta.Accession }))
|
|
31
|
+
.filter((r): r is { rowName: string; accession: string } => !!r.accession)
|
|
32
|
+
|
|
33
|
+
if (rowAccessions.length === 0) {
|
|
34
|
+
throw new Error('No NCBI accessions found in alignment rows')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
self.setProgress(
|
|
38
|
+
`Fetching protein domains from NCBI for ${rowAccessions.length} sequences...`,
|
|
39
|
+
)
|
|
40
|
+
const byAccession = await fetchProteinDomains(
|
|
41
|
+
rowAccessions.map(r => r.accession),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const annotations: Record<string, InterProScanResults> = {}
|
|
45
|
+
for (const { rowName, accession } of rowAccessions) {
|
|
46
|
+
const matches = byAccession.get(accession)
|
|
47
|
+
if (matches && matches.length > 0) {
|
|
48
|
+
annotations[rowName] = { matches, xref: [{ id: rowName }] }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Object.keys(annotations).length === 0) {
|
|
53
|
+
throw new Error('No CDD domain annotations found for these proteins')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
self.setDomains(annotations)
|
|
57
|
+
}
|
|
@@ -7,8 +7,13 @@ import { genomeToTranscriptSeqMapping } from 'g2p_mapper'
|
|
|
7
7
|
import { autorun } from 'mobx'
|
|
8
8
|
import { MSAModelF } from 'react-msaview'
|
|
9
9
|
|
|
10
|
+
// re-exported so the inferred (composed) state-model type can name MSAFormat
|
|
11
|
+
// from msa-parsers when emitting declarations (avoids TS2883 portability error)
|
|
12
|
+
export type { MSAFormat } from 'msa-parsers'
|
|
13
|
+
|
|
10
14
|
import {
|
|
11
15
|
autoConnectStructures,
|
|
16
|
+
autoLoadProteinDomains,
|
|
12
17
|
highlightConnectedStructures,
|
|
13
18
|
launchBlastIfNeeded,
|
|
14
19
|
loadStoredData,
|
|
@@ -16,14 +21,11 @@ import {
|
|
|
16
21
|
processInit,
|
|
17
22
|
runCleanup,
|
|
18
23
|
storeDataToIndexedDB,
|
|
24
|
+
syncGenomeHoverToMsaColumn,
|
|
19
25
|
} from './afterCreateAutoruns'
|
|
20
|
-
import { genomeToMSA } from './genomeToMSA'
|
|
21
26
|
import { msaCoordToGenomeCoord } from './msaCoordToGenomeCoord'
|
|
22
27
|
import { buildAlignmentMaps, runPairwiseAlignment } from './pairwiseAlignment'
|
|
23
|
-
import {
|
|
24
|
-
getProteinViews,
|
|
25
|
-
ungappedToGappedPosition,
|
|
26
|
-
} from './structureConnection'
|
|
28
|
+
import { getProteinViews } from './structureConnection'
|
|
27
29
|
import { getCanonicalRefName } from './util'
|
|
28
30
|
|
|
29
31
|
import type { ProteinView, StructureConnection } from './structureConnection'
|
|
@@ -80,9 +82,6 @@ export default function stateModelFactory() {
|
|
|
80
82
|
* #property
|
|
81
83
|
*/
|
|
82
84
|
connectedFeature: types.frozen(),
|
|
83
|
-
/**
|
|
84
|
-
* #property
|
|
85
|
-
*/
|
|
86
85
|
/**
|
|
87
86
|
* #property
|
|
88
87
|
*/
|
|
@@ -131,6 +130,7 @@ export default function stateModelFactory() {
|
|
|
131
130
|
error: unknown
|
|
132
131
|
loadingStoredData: boolean
|
|
133
132
|
isStoringData: boolean
|
|
133
|
+
domainsRequested: boolean
|
|
134
134
|
} => ({
|
|
135
135
|
/**
|
|
136
136
|
* #volatile
|
|
@@ -152,6 +152,12 @@ export default function stateModelFactory() {
|
|
|
152
152
|
* #volatile
|
|
153
153
|
*/
|
|
154
154
|
isStoringData: false,
|
|
155
|
+
/**
|
|
156
|
+
* #volatile
|
|
157
|
+
* guards the one-shot auto-fetch of protein domains so it doesn't refire
|
|
158
|
+
* when NCBI returns no domains (leaving interProAnnotations undefined)
|
|
159
|
+
*/
|
|
160
|
+
domainsRequested: false,
|
|
155
161
|
}),
|
|
156
162
|
)
|
|
157
163
|
|
|
@@ -209,41 +215,6 @@ export default function stateModelFactory() {
|
|
|
209
215
|
}))
|
|
210
216
|
|
|
211
217
|
.views(self => ({
|
|
212
|
-
/**
|
|
213
|
-
* #getter
|
|
214
|
-
*/
|
|
215
|
-
get structureHoverCol(): number | undefined {
|
|
216
|
-
for (const conn of self.connectedProteinViews) {
|
|
217
|
-
const structure = conn.proteinView.structures[conn.structureIdx]
|
|
218
|
-
const structurePos = structure?.hoverPosition?.structureSeqPos
|
|
219
|
-
if (structurePos !== undefined) {
|
|
220
|
-
const msaUngapped = conn.structureToMsa[structurePos]
|
|
221
|
-
if (msaUngapped !== undefined) {
|
|
222
|
-
const seq = self.getSequenceByRowName(conn.msaRowName)
|
|
223
|
-
if (seq) {
|
|
224
|
-
const globalCol = ungappedToGappedPosition(seq, msaUngapped)
|
|
225
|
-
if (globalCol !== undefined) {
|
|
226
|
-
return self.globalColToVisibleCol(globalCol)
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return undefined
|
|
233
|
-
},
|
|
234
|
-
}))
|
|
235
|
-
|
|
236
|
-
.views(self => ({
|
|
237
|
-
/**
|
|
238
|
-
* #getter
|
|
239
|
-
*/
|
|
240
|
-
get mouseCol2(): number | undefined {
|
|
241
|
-
return (
|
|
242
|
-
self.structureHoverCol ??
|
|
243
|
-
genomeToMSA({ model: self as JBrowsePluginMsaViewModel })
|
|
244
|
-
)
|
|
245
|
-
},
|
|
246
|
-
|
|
247
218
|
/**
|
|
248
219
|
* #getter
|
|
249
220
|
*/
|
|
@@ -333,6 +304,12 @@ export default function stateModelFactory() {
|
|
|
333
304
|
setIsStoringData(arg: boolean) {
|
|
334
305
|
self.isStoringData = arg
|
|
335
306
|
},
|
|
307
|
+
/**
|
|
308
|
+
* #action
|
|
309
|
+
*/
|
|
310
|
+
setDomainsRequested(arg: boolean) {
|
|
311
|
+
self.domainsRequested = arg
|
|
312
|
+
},
|
|
336
313
|
/**
|
|
337
314
|
* #action
|
|
338
315
|
*/
|
|
@@ -394,14 +371,13 @@ export default function stateModelFactory() {
|
|
|
394
371
|
ungappedMsaSequence,
|
|
395
372
|
structureSequence,
|
|
396
373
|
)
|
|
397
|
-
const { seq1ToSeq2
|
|
374
|
+
const { seq1ToSeq2 } = buildAlignmentMaps(alignment)
|
|
398
375
|
|
|
399
376
|
const connection: StructureConnection = {
|
|
400
377
|
proteinViewId,
|
|
401
378
|
structureIdx,
|
|
402
379
|
msaRowName: rowName,
|
|
403
380
|
msaToStructure: Object.fromEntries(seq1ToSeq2),
|
|
404
|
-
structureToMsa: Object.fromEntries(seq2ToSeq1),
|
|
405
381
|
}
|
|
406
382
|
|
|
407
383
|
self.connectedStructures.push(connection)
|
|
@@ -494,6 +470,7 @@ export default function stateModelFactory() {
|
|
|
494
470
|
processInit,
|
|
495
471
|
highlightConnectedStructures,
|
|
496
472
|
autoConnectStructures,
|
|
473
|
+
autoLoadProteinDomains,
|
|
497
474
|
observeProteinHighlights,
|
|
498
475
|
]) {
|
|
499
476
|
addDisposer(
|
|
@@ -503,6 +480,7 @@ export default function stateModelFactory() {
|
|
|
503
480
|
}),
|
|
504
481
|
)
|
|
505
482
|
}
|
|
483
|
+
addDisposer(self, autorun(syncGenomeHoverToMsaColumn(self)))
|
|
506
484
|
},
|
|
507
485
|
}))
|
|
508
486
|
}
|
|
@@ -128,13 +128,8 @@ function buildConsensus(alignedSeq1: string, alignedSeq2: string) {
|
|
|
128
128
|
for (let i = 0; i < alignedSeq1.length; i++) {
|
|
129
129
|
const a = alignedSeq1[i]!
|
|
130
130
|
const b = alignedSeq2[i]!
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
} else if (a.toUpperCase() === b.toUpperCase()) {
|
|
134
|
-
consensus += '|'
|
|
135
|
-
} else {
|
|
136
|
-
consensus += ' '
|
|
137
|
-
}
|
|
131
|
+
const match = a !== '-' && b !== '-' && a.toUpperCase() === b.toUpperCase()
|
|
132
|
+
consensus += match ? '|' : ' '
|
|
138
133
|
}
|
|
139
134
|
return consensus
|
|
140
135
|
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
gappedToUngappedPosition,
|
|
5
|
-
ungappedToGappedPosition,
|
|
6
|
-
} from './structureConnection'
|
|
3
|
+
import { gappedToUngappedPosition } from './structureConnection'
|
|
7
4
|
|
|
8
5
|
describe('gappedToUngappedPosition', () => {
|
|
9
6
|
test('returns correct ungapped position for non-gap character', () => {
|
|
@@ -63,60 +60,3 @@ describe('gappedToUngappedPosition', () => {
|
|
|
63
60
|
expect(gappedToUngappedPosition(seq, 2)).toBeUndefined()
|
|
64
61
|
})
|
|
65
62
|
})
|
|
66
|
-
|
|
67
|
-
describe('ungappedToGappedPosition', () => {
|
|
68
|
-
test('returns correct gapped position', () => {
|
|
69
|
-
const seq = 'M-KA-A'
|
|
70
|
-
// 0 12 34 (gapped)
|
|
71
|
-
// 0 1 23 (ungapped)
|
|
72
|
-
expect(ungappedToGappedPosition(seq, 0)).toBe(0) // M
|
|
73
|
-
expect(ungappedToGappedPosition(seq, 1)).toBe(2) // K
|
|
74
|
-
expect(ungappedToGappedPosition(seq, 2)).toBe(3) // A
|
|
75
|
-
expect(ungappedToGappedPosition(seq, 3)).toBe(5) // A
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
test('returns undefined for out-of-bounds ungapped position', () => {
|
|
79
|
-
const seq = 'M-KA'
|
|
80
|
-
expect(ungappedToGappedPosition(seq, 4)).toBeUndefined()
|
|
81
|
-
expect(ungappedToGappedPosition(seq, 100)).toBeUndefined()
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
test('handles sequence with no gaps', () => {
|
|
85
|
-
const seq = 'MKAA'
|
|
86
|
-
expect(ungappedToGappedPosition(seq, 0)).toBe(0)
|
|
87
|
-
expect(ungappedToGappedPosition(seq, 1)).toBe(1)
|
|
88
|
-
expect(ungappedToGappedPosition(seq, 2)).toBe(2)
|
|
89
|
-
expect(ungappedToGappedPosition(seq, 3)).toBe(3)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('handles sequence with leading gaps', () => {
|
|
93
|
-
const seq = '--MKA'
|
|
94
|
-
expect(ungappedToGappedPosition(seq, 0)).toBe(2) // M
|
|
95
|
-
expect(ungappedToGappedPosition(seq, 1)).toBe(3) // K
|
|
96
|
-
expect(ungappedToGappedPosition(seq, 2)).toBe(4) // A
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('handles empty sequence', () => {
|
|
100
|
-
expect(ungappedToGappedPosition('', 0)).toBeUndefined()
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
test('handles all-gap sequence', () => {
|
|
104
|
-
const seq = '---'
|
|
105
|
-
expect(ungappedToGappedPosition(seq, 0)).toBeUndefined()
|
|
106
|
-
})
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
describe('gappedToUngappedPosition and ungappedToGappedPosition are inverses', () => {
|
|
110
|
-
test('round-trip conversion works', () => {
|
|
111
|
-
const seq = 'M-KA--YL-S'
|
|
112
|
-
// For each non-gap position, converting to ungapped and back should return original
|
|
113
|
-
for (let i = 0; i < seq.length; i++) {
|
|
114
|
-
if (seq[i] !== '-') {
|
|
115
|
-
const ungapped = gappedToUngappedPosition(seq, i)
|
|
116
|
-
expect(ungapped).toBeDefined()
|
|
117
|
-
const backToGapped = ungappedToGappedPosition(seq, ungapped!)
|
|
118
|
-
expect(backToGapped).toBe(i)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
})
|
|
122
|
-
})
|
|
@@ -40,8 +40,6 @@ export interface StructureConnection {
|
|
|
40
40
|
msaRowName: string
|
|
41
41
|
/** Map from MSA ungapped position to structure sequence position */
|
|
42
42
|
msaToStructure: Record<number, number>
|
|
43
|
-
/** Map from structure sequence position to MSA ungapped position */
|
|
44
|
-
structureToMsa: Record<number, number>
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
/**
|
|
@@ -69,23 +67,3 @@ export function gappedToUngappedPosition(
|
|
|
69
67
|
|
|
70
68
|
return ungapped
|
|
71
69
|
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Helper to convert ungapped position to gapped MSA column for a specific row
|
|
75
|
-
*/
|
|
76
|
-
export function ungappedToGappedPosition(
|
|
77
|
-
sequence: string,
|
|
78
|
-
ungappedPosition: number,
|
|
79
|
-
): number | undefined {
|
|
80
|
-
let ungapped = 0
|
|
81
|
-
for (let i = 0; i < sequence.length; i++) {
|
|
82
|
-
const element = sequence[i]
|
|
83
|
-
if (element !== '-') {
|
|
84
|
-
if (ungapped === ungappedPosition) {
|
|
85
|
-
return i
|
|
86
|
-
}
|
|
87
|
-
ungapped++
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return undefined
|
|
91
|
-
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { getSession } from '@jbrowse/core/util'
|
|
2
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { syncGenomeHoverToMsaColumn } from './afterCreateAutoruns'
|
|
5
|
+
|
|
6
|
+
import type { JBrowsePluginMsaViewModel } from './model'
|
|
7
|
+
|
|
8
|
+
// Mock only getSession; keep the rest of the util module real so the
|
|
9
|
+
// afterCreateAutoruns import graph still loads.
|
|
10
|
+
vi.mock('@jbrowse/core/util', async importOriginal => ({
|
|
11
|
+
...(await importOriginal<Record<string, unknown>>()),
|
|
12
|
+
getSession: vi.fn(),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const mockGetSession = vi.mocked(getSession)
|
|
16
|
+
|
|
17
|
+
const mafRegion = {
|
|
18
|
+
refName: 'chr1',
|
|
19
|
+
start: 1000,
|
|
20
|
+
end: 1010,
|
|
21
|
+
assemblyName: 'hg38',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A model wired through the real genomeToMSA path: a connected genome view
|
|
25
|
+
// over a maf region, with seqPosToVisibleCol as identity so the asserted
|
|
26
|
+
// column equals the ungapped offset into the region.
|
|
27
|
+
function makeModel() {
|
|
28
|
+
const calls: (number | undefined)[] = []
|
|
29
|
+
const model = {
|
|
30
|
+
querySeqName: 'hg38.chr1',
|
|
31
|
+
transcriptToMsaMap: undefined,
|
|
32
|
+
mafRegion,
|
|
33
|
+
connectedView: { initialized: true, assemblyNames: ['hg38'] },
|
|
34
|
+
seqPosToVisibleCol: (_name: string, pos: number) => pos,
|
|
35
|
+
setMousePos: (col?: number) => {
|
|
36
|
+
calls.push(col)
|
|
37
|
+
},
|
|
38
|
+
} as unknown as JBrowsePluginMsaViewModel
|
|
39
|
+
return { model, calls }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hoverGenome(coord: number) {
|
|
43
|
+
mockGetSession.mockReturnValue({
|
|
44
|
+
hovered: { hoverFeature: {}, hoverPosition: { coord, refName: 'chr1' } },
|
|
45
|
+
} as unknown as ReturnType<typeof getSession>)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clearGenomeHover() {
|
|
49
|
+
mockGetSession.mockReturnValue({
|
|
50
|
+
hovered: null,
|
|
51
|
+
} as unknown as ReturnType<typeof getSession>)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('syncGenomeHoverToMsaColumn (real genomeToMSA mapping)', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('genome hover at coord 1005 highlights MSA column 5', () => {
|
|
60
|
+
const { model, calls } = makeModel()
|
|
61
|
+
const run = syncGenomeHoverToMsaColumn(model)
|
|
62
|
+
|
|
63
|
+
hoverGenome(1005) // 1005 - mafRegion.start(1000) = ungapped 5
|
|
64
|
+
run()
|
|
65
|
+
expect(calls).toEqual([5])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('moving the genome hover moves the highlighted column', () => {
|
|
69
|
+
const { model, calls } = makeModel()
|
|
70
|
+
const run = syncGenomeHoverToMsaColumn(model)
|
|
71
|
+
|
|
72
|
+
hoverGenome(1002)
|
|
73
|
+
run()
|
|
74
|
+
hoverGenome(1007)
|
|
75
|
+
run()
|
|
76
|
+
expect(calls).toEqual([2, 7])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('leaving the genome clears the column it set', () => {
|
|
80
|
+
const { model, calls } = makeModel()
|
|
81
|
+
const run = syncGenomeHoverToMsaColumn(model)
|
|
82
|
+
|
|
83
|
+
hoverGenome(1004)
|
|
84
|
+
run()
|
|
85
|
+
clearGenomeHover()
|
|
86
|
+
run()
|
|
87
|
+
expect(calls).toEqual([4, undefined])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('a hover outside the maf region clears a previously-set column once', () => {
|
|
91
|
+
const { model, calls } = makeModel()
|
|
92
|
+
const run = syncGenomeHoverToMsaColumn(model)
|
|
93
|
+
|
|
94
|
+
hoverGenome(1004)
|
|
95
|
+
run()
|
|
96
|
+
hoverGenome(5000) // outside [1000,1010) -> genomeToMSA returns undefined
|
|
97
|
+
run()
|
|
98
|
+
run()
|
|
99
|
+
expect(calls).toEqual([4, undefined])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('never touches mouseCol when the genome never provides a column, so a direct MSA hover survives unrelated session hovers', () => {
|
|
103
|
+
const { model, calls } = makeModel()
|
|
104
|
+
const run = syncGenomeHoverToMsaColumn(model)
|
|
105
|
+
|
|
106
|
+
clearGenomeHover()
|
|
107
|
+
run()
|
|
108
|
+
hoverGenome(9999) // unrelated/out-of-range hover elsewhere
|
|
109
|
+
run()
|
|
110
|
+
expect(calls).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { openDB } from 'idb'
|
|
2
|
+
|
|
3
|
+
import type { DomainMatch } from './ncbiDomains'
|
|
4
|
+
|
|
5
|
+
const DB_NAME = 'jbrowse-msaview-domain-cache'
|
|
6
|
+
const STORE_NAME = 'domains'
|
|
7
|
+
const DB_VERSION = 1
|
|
8
|
+
|
|
9
|
+
interface CachedDomain {
|
|
10
|
+
accession: string
|
|
11
|
+
matches: DomainMatch[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function getDB() {
|
|
15
|
+
return openDB(DB_NAME, DB_VERSION, {
|
|
16
|
+
upgrade(db) {
|
|
17
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
18
|
+
db.createObjectStore(STORE_NAME, { keyPath: 'accession' })
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getCachedDomains(accessions: string[]) {
|
|
25
|
+
const db = await getDB()
|
|
26
|
+
const tx = db.transaction(STORE_NAME, 'readonly')
|
|
27
|
+
const results = await Promise.all(
|
|
28
|
+
accessions.map(
|
|
29
|
+
accession =>
|
|
30
|
+
tx.store.get(accession) as Promise<CachedDomain | undefined>,
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
await tx.done
|
|
34
|
+
return results
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function saveDomains(entries: CachedDomain[]) {
|
|
38
|
+
const db = await getDB()
|
|
39
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
await tx.store.put(entry)
|
|
42
|
+
}
|
|
43
|
+
await tx.done
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// NCBI asks that programmatic E-utilities requests identify themselves with a
|
|
2
|
+
// tool name and contact email so they can reach out before throttling, rather
|
|
3
|
+
// than silently rate-limiting. https://www.ncbi.nlm.nih.gov/books/NBK25497/
|
|
4
|
+
export const NCBI_TOOL = 'jbrowse-plugin-msaview'
|
|
5
|
+
export const NCBI_EMAIL = 'colin.diesh@gmail.com'
|
|
6
|
+
|
|
7
|
+
const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
|
|
8
|
+
|
|
9
|
+
export function efetchUrl(params: Record<string, string>) {
|
|
10
|
+
const search = new URLSearchParams({
|
|
11
|
+
...params,
|
|
12
|
+
tool: NCBI_TOOL,
|
|
13
|
+
email: NCBI_EMAIL,
|
|
14
|
+
})
|
|
15
|
+
return `${EUTILS}/efetch.fcgi?${search.toString()}`
|
|
16
|
+
}
|
package/src/utils/msa.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { textfetch
|
|
1
|
+
import { textfetch } from './fetch'
|
|
2
|
+
import { pollLoop } from './poll'
|
|
2
3
|
|
|
3
4
|
import type { MsaAlgorithm } from '../LaunchMsaView/components/NCBIBlastQuery/consts'
|
|
4
5
|
|
|
5
6
|
const base = `https://www.ebi.ac.uk/Tools/services/rest`
|
|
7
|
+
const email = 'colin.diesh@gmail.com'
|
|
6
8
|
|
|
7
9
|
const algorithms: Record<
|
|
8
10
|
MsaAlgorithm,
|
|
@@ -13,22 +15,22 @@ const algorithms: Record<
|
|
|
13
15
|
}
|
|
14
16
|
> = {
|
|
15
17
|
clustalo: {
|
|
16
|
-
params: { email
|
|
18
|
+
params: { email },
|
|
17
19
|
msaResult: 'aln-clustal_num',
|
|
18
20
|
treeResult: 'phylotree',
|
|
19
21
|
},
|
|
20
22
|
muscle: {
|
|
21
|
-
params: { email
|
|
23
|
+
params: { email, format: 'clw', tree: 'tree1' },
|
|
22
24
|
msaResult: 'fa',
|
|
23
25
|
treeResult: 'phylotree',
|
|
24
26
|
},
|
|
25
27
|
kalign: {
|
|
26
|
-
params: { email
|
|
28
|
+
params: { email, stype: 'protein' },
|
|
27
29
|
msaResult: 'fa',
|
|
28
30
|
treeResult: 'phylotree',
|
|
29
31
|
},
|
|
30
32
|
mafft: {
|
|
31
|
-
params: { email
|
|
33
|
+
params: { email, stype: 'protein' },
|
|
32
34
|
msaResult: 'fa',
|
|
33
35
|
treeResult: 'phylotree',
|
|
34
36
|
},
|
|
@@ -43,20 +45,22 @@ async function wait({
|
|
|
43
45
|
algorithm: MsaAlgorithm
|
|
44
46
|
onProgress: (arg: string) => void
|
|
45
47
|
}) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
await pollLoop({
|
|
49
|
+
intervalSeconds: 10,
|
|
50
|
+
onCountdown: s => {
|
|
51
|
+
onProgress(`Re-checking MSA status in... ${s}`)
|
|
52
|
+
},
|
|
53
|
+
check: async () => {
|
|
54
|
+
const result = await textfetch(`${base}/${algorithm}/status/${jobId}`)
|
|
55
|
+
if (result.includes('FINISHED')) {
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
if (result.includes('FAILURE')) {
|
|
59
|
+
throw new Error(`Failed to run: jobId ${jobId}`)
|
|
60
|
+
}
|
|
61
|
+
return false
|
|
62
|
+
},
|
|
63
|
+
})
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export async function launchMSA({
|
package/src/utils/ncbiBlast.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { jsonfetch, textfetch
|
|
1
|
+
import { jsonfetch, textfetch } from './fetch'
|
|
2
|
+
import { pollLoop } from './poll'
|
|
2
3
|
|
|
3
4
|
import type { BlastResults } from './types'
|
|
4
5
|
import type {
|
|
@@ -110,39 +111,33 @@ async function waitForRid({
|
|
|
110
111
|
onProgress: (arg: string) => void
|
|
111
112
|
baseUrl: string
|
|
112
113
|
}) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
114
|
+
await pollLoop({
|
|
115
|
+
intervalSeconds: 20,
|
|
116
|
+
onCountdown: s => {
|
|
117
|
+
onProgress(`Re-checking BLAST status in... ${s}`)
|
|
118
|
+
},
|
|
119
|
+
check: async () => {
|
|
120
|
+
const res = await textfetch(
|
|
121
|
+
`${baseUrl}?CMD=Get&FORMAT_OBJECT=SearchInfo&RID=${rid}`,
|
|
122
|
+
)
|
|
123
|
+
const status = /\s+Status=(\S+)/m.exec(res)?.[1]
|
|
124
|
+
const hasHits = /\s+ThereAreHits=yes/m.test(res)
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (status === 'FAILED') {
|
|
133
|
-
throw new Error(`BLAST ${rid} failed`)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (status === 'READY') {
|
|
137
|
-
if (hasHits) {
|
|
138
|
-
return true
|
|
139
|
-
} else {
|
|
126
|
+
if (status === 'WAITING') {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
if (status === 'FAILED') {
|
|
130
|
+
throw new Error(`BLAST ${rid} failed`)
|
|
131
|
+
}
|
|
132
|
+
if (status === 'READY') {
|
|
133
|
+
if (hasHits) {
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
140
136
|
throw new Error('No hits found')
|
|
141
137
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
138
|
+
throw new Error(
|
|
139
|
+
`BLAST ${rid} returned unexpected status: ${status ?? 'unknown'}`,
|
|
140
|
+
)
|
|
141
|
+
},
|
|
142
|
+
})
|
|
148
143
|
}
|