jbrowse-plugin-msaview 2.7.2 → 2.7.4
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/AddHighlightModel/MsaToGenomeHighlight.js +6 -5
- package/dist/LaunchMsaView/components/LaunchMsaViewDialog.js +7 -1
- package/dist/LaunchMsaView/components/NCBIBlastQuery/CachedBlastResults.js +1 -1
- package/dist/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.d.ts +1 -1
- package/dist/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.js +5 -2
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.d.ts +8 -0
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +86 -0
- package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.d.ts +9 -0
- package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +13 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +8 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.js +28 -0
- package/dist/MsaViewPanel/doLaunchBlast.js +10 -22
- package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +23 -0
- package/dist/MsaViewPanel/doLaunchOrthologs.js +97 -0
- package/dist/MsaViewPanel/genomeToMSA.js +13 -6
- package/dist/MsaViewPanel/genomeToMSA.test.js +35 -6
- package/dist/MsaViewPanel/model.d.ts +88 -70
- package/dist/MsaViewPanel/model.js +26 -13
- package/dist/MsaViewPanel/msaCoordToGenomeCoord.d.ts +32 -13
- package/dist/MsaViewPanel/msaCoordToGenomeCoord.js +43 -14
- package/dist/MsaViewPanel/msaCoordToGenomeCoord.test.js +108 -18
- package/dist/MsaViewPanel/msaDataStore.js +8 -17
- package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +8 -6
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -31
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/blastCache.d.ts +1 -2
- package/dist/utils/blastCache.js +9 -22
- package/dist/utils/domainCache.js +6 -15
- package/dist/utils/idb.d.ts +12 -0
- package/dist/utils/idb.js +21 -0
- package/dist/utils/ncbiOrthologs.d.ts +105 -0
- package/dist/utils/ncbiOrthologs.js +211 -0
- package/dist/utils/ncbiOrthologs.test.d.ts +1 -0
- package/dist/utils/ncbiOrthologs.test.js +41 -0
- package/dist/utils/taxonomyNames.js +13 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/AddHighlightModel/MsaToGenomeHighlight.tsx +6 -8
- package/src/LaunchMsaView/components/LaunchMsaViewDialog.tsx +13 -2
- package/src/LaunchMsaView/components/NCBIBlastQuery/CachedBlastResults.tsx +1 -1
- package/src/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.ts +4 -2
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +172 -0
- package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +28 -0
- package/src/MsaViewPanel/afterCreateAutoruns.ts +27 -0
- package/src/MsaViewPanel/doLaunchBlast.ts +20 -29
- package/src/MsaViewPanel/doLaunchOrthologs.ts +123 -0
- package/src/MsaViewPanel/genomeToMSA.test.ts +38 -6
- package/src/MsaViewPanel/genomeToMSA.ts +14 -6
- package/src/MsaViewPanel/model.ts +41 -12
- package/src/MsaViewPanel/msaCoordToGenomeCoord.test.ts +117 -18
- package/src/MsaViewPanel/msaCoordToGenomeCoord.ts +70 -26
- package/src/MsaViewPanel/msaDataStore.ts +17 -17
- package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +8 -6
- package/src/utils/blastCache.ts +20 -23
- package/src/utils/domainCache.ts +14 -18
- package/src/utils/idb.ts +28 -0
- package/src/utils/ncbiOrthologs.test.ts +56 -0
- package/src/utils/ncbiOrthologs.ts +320 -0
- package/src/utils/taxonomyNames.ts +22 -24
- package/src/version.ts +1 -1
- package/dist/MsaViewPanel/blosum62.d.ts +0 -2
- package/dist/MsaViewPanel/blosum62.js +0 -627
- package/src/MsaViewPanel/blosum62.ts +0 -628
|
@@ -1,6 +1,21 @@
|
|
|
1
|
+
import { genomeToTranscriptSeqMapping } from 'g2p_mapper'
|
|
1
2
|
import { describe, expect, test } from 'vitest'
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
msaCoordToGenomeCoord,
|
|
6
|
+
msaCoordToGenomeRegions,
|
|
7
|
+
} from './msaCoordToGenomeCoord'
|
|
8
|
+
|
|
9
|
+
// codon at protein position i covers three consecutive genome bases starting at
|
|
10
|
+
// 100 + i * 3, i.e. a single-exon forward-strand transcript
|
|
11
|
+
function forwardCodons(n: number) {
|
|
12
|
+
return Object.fromEntries(
|
|
13
|
+
Array.from({ length: n }, (_, i) => [
|
|
14
|
+
i,
|
|
15
|
+
[100 + i * 3, 101 + i * 3, 102 + i * 3],
|
|
16
|
+
]),
|
|
17
|
+
)
|
|
18
|
+
}
|
|
4
19
|
|
|
5
20
|
describe('msaCoordToGenomeCoord', () => {
|
|
6
21
|
test('returns undefined when neither transcriptToMsaMap nor mafRegion is defined', () => {
|
|
@@ -19,7 +34,7 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
19
34
|
querySeqName: 'QUERY',
|
|
20
35
|
transcriptToMsaMap: {
|
|
21
36
|
refName: 'chr1',
|
|
22
|
-
|
|
37
|
+
p2gCodon: forwardCodons(2),
|
|
23
38
|
},
|
|
24
39
|
rows: [['OTHER', 'MKAA']],
|
|
25
40
|
}
|
|
@@ -32,7 +47,7 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
32
47
|
querySeqName: 'QUERY',
|
|
33
48
|
transcriptToMsaMap: {
|
|
34
49
|
refName: 'chr1',
|
|
35
|
-
|
|
50
|
+
p2gCodon: forwardCodons(2),
|
|
36
51
|
},
|
|
37
52
|
rows: [['QUERY', 'M-KA']],
|
|
38
53
|
}
|
|
@@ -46,7 +61,7 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
46
61
|
querySeqName: 'QUERY',
|
|
47
62
|
transcriptToMsaMap: {
|
|
48
63
|
refName: 'chr1',
|
|
49
|
-
|
|
64
|
+
p2gCodon: forwardCodons(4),
|
|
50
65
|
},
|
|
51
66
|
rows: [['QUERY', 'MKAA']],
|
|
52
67
|
}
|
|
@@ -64,7 +79,7 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
64
79
|
querySeqName: 'QUERY',
|
|
65
80
|
transcriptToMsaMap: {
|
|
66
81
|
refName: 'chr1',
|
|
67
|
-
|
|
82
|
+
p2gCodon: forwardCodons(4),
|
|
68
83
|
},
|
|
69
84
|
rows: [['QUERY', 'M-K-AA']],
|
|
70
85
|
// 012345 gapped positions
|
|
@@ -87,35 +102,34 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
87
102
|
})
|
|
88
103
|
})
|
|
89
104
|
|
|
90
|
-
test('returns undefined when
|
|
105
|
+
test('returns undefined when the position has no codon mapping', () => {
|
|
91
106
|
const model = {
|
|
92
107
|
querySeqName: 'QUERY',
|
|
93
108
|
transcriptToMsaMap: {
|
|
94
109
|
refName: 'chr1',
|
|
95
|
-
|
|
110
|
+
p2gCodon: forwardCodons(1),
|
|
96
111
|
},
|
|
97
112
|
rows: [['QUERY', 'MKAA']],
|
|
98
113
|
}
|
|
99
|
-
//
|
|
100
|
-
const result = msaCoordToGenomeCoord({ model, coord:
|
|
114
|
+
// ungapped position 1 has no entry in p2gCodon
|
|
115
|
+
const result = msaCoordToGenomeCoord({ model, coord: 1 })
|
|
101
116
|
expect(result).toBeUndefined()
|
|
102
117
|
})
|
|
103
118
|
|
|
104
|
-
test('
|
|
119
|
+
test('maps the final residue, whose codon has no successor', () => {
|
|
105
120
|
const model = {
|
|
106
121
|
querySeqName: 'QUERY',
|
|
107
122
|
transcriptToMsaMap: {
|
|
108
123
|
refName: 'chr1',
|
|
109
|
-
|
|
124
|
+
p2gCodon: forwardCodons(4),
|
|
110
125
|
},
|
|
111
126
|
rows: [['QUERY', 'MKAA']],
|
|
112
127
|
}
|
|
113
|
-
|
|
114
|
-
const result = msaCoordToGenomeCoord({ model, coord: 0 })
|
|
128
|
+
const result = msaCoordToGenomeCoord({ model, coord: 3 })
|
|
115
129
|
expect(result).toEqual({
|
|
116
130
|
refName: 'chr1',
|
|
117
|
-
start:
|
|
118
|
-
end:
|
|
131
|
+
start: 109,
|
|
132
|
+
end: 112,
|
|
119
133
|
})
|
|
120
134
|
})
|
|
121
135
|
|
|
@@ -124,7 +138,7 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
124
138
|
querySeqName: 'QUERY',
|
|
125
139
|
transcriptToMsaMap: {
|
|
126
140
|
refName: 'chr1',
|
|
127
|
-
|
|
141
|
+
p2gCodon: forwardCodons(2),
|
|
128
142
|
},
|
|
129
143
|
rows: [['QUERY', 'MK']],
|
|
130
144
|
}
|
|
@@ -138,7 +152,7 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
138
152
|
querySeqName: 'SEQ2',
|
|
139
153
|
transcriptToMsaMap: {
|
|
140
154
|
refName: 'chr1',
|
|
141
|
-
|
|
155
|
+
p2gCodon: { 0: [200, 201, 202], 1: [203, 204, 205] },
|
|
142
156
|
},
|
|
143
157
|
rows: [
|
|
144
158
|
['SEQ1', 'AAAA'],
|
|
@@ -154,6 +168,91 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
154
168
|
})
|
|
155
169
|
})
|
|
156
170
|
|
|
171
|
+
// The mapping comes from the real g2p_mapper rather than hand-written
|
|
172
|
+
// fixtures: on the reverse strand p2g stores the codon's *highest*
|
|
173
|
+
// coordinate, which is what the old p2g[pos]..p2g[pos+1] arithmetic got wrong
|
|
174
|
+
describe('real g2p_mapper mappings', () => {
|
|
175
|
+
test('forward strand, single exon', () => {
|
|
176
|
+
const { p2gCodon, refName } = genomeToTranscriptSeqMapping({
|
|
177
|
+
refName: 'chr1',
|
|
178
|
+
start: 100,
|
|
179
|
+
end: 112,
|
|
180
|
+
strand: 1,
|
|
181
|
+
subfeatures: [{ refName: 'chr1', type: 'CDS', start: 100, end: 112 }],
|
|
182
|
+
})
|
|
183
|
+
const model = {
|
|
184
|
+
querySeqName: 'QUERY',
|
|
185
|
+
transcriptToMsaMap: { refName, p2gCodon },
|
|
186
|
+
rows: [['QUERY', 'MKAA']],
|
|
187
|
+
}
|
|
188
|
+
expect(msaCoordToGenomeCoord({ model, coord: 0 })).toEqual({
|
|
189
|
+
refName: 'chr1',
|
|
190
|
+
start: 100,
|
|
191
|
+
end: 103,
|
|
192
|
+
})
|
|
193
|
+
expect(msaCoordToGenomeCoord({ model, coord: 3 })).toEqual({
|
|
194
|
+
refName: 'chr1',
|
|
195
|
+
start: 109,
|
|
196
|
+
end: 112,
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('reverse strand codon covers the last three bases of the CDS', () => {
|
|
201
|
+
const { p2gCodon, refName } = genomeToTranscriptSeqMapping({
|
|
202
|
+
refName: 'chr1',
|
|
203
|
+
start: 100,
|
|
204
|
+
end: 112,
|
|
205
|
+
strand: -1,
|
|
206
|
+
subfeatures: [{ refName: 'chr1', type: 'CDS', start: 100, end: 112 }],
|
|
207
|
+
})
|
|
208
|
+
const model = {
|
|
209
|
+
querySeqName: 'QUERY',
|
|
210
|
+
transcriptToMsaMap: { refName, p2gCodon },
|
|
211
|
+
rows: [['QUERY', 'MKAA']],
|
|
212
|
+
}
|
|
213
|
+
// the first residue is translated from the 3' end of the genome region
|
|
214
|
+
expect(msaCoordToGenomeCoord({ model, coord: 0 })).toEqual({
|
|
215
|
+
refName: 'chr1',
|
|
216
|
+
start: 109,
|
|
217
|
+
end: 112,
|
|
218
|
+
})
|
|
219
|
+
expect(msaCoordToGenomeCoord({ model, coord: 3 })).toEqual({
|
|
220
|
+
refName: 'chr1',
|
|
221
|
+
start: 100,
|
|
222
|
+
end: 103,
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('codon split across an exon boundary yields one region per piece', () => {
|
|
227
|
+
// exon 1 contributes 4 bases, so residue 1 straddles the intron
|
|
228
|
+
const { p2gCodon, refName } = genomeToTranscriptSeqMapping({
|
|
229
|
+
refName: 'chr1',
|
|
230
|
+
start: 100,
|
|
231
|
+
end: 210,
|
|
232
|
+
strand: 1,
|
|
233
|
+
subfeatures: [
|
|
234
|
+
{ refName: 'chr1', type: 'CDS', start: 100, end: 104 },
|
|
235
|
+
{ refName: 'chr1', type: 'CDS', start: 200, end: 202 },
|
|
236
|
+
],
|
|
237
|
+
})
|
|
238
|
+
const model = {
|
|
239
|
+
querySeqName: 'QUERY',
|
|
240
|
+
transcriptToMsaMap: { refName, p2gCodon },
|
|
241
|
+
rows: [['QUERY', 'MK']],
|
|
242
|
+
}
|
|
243
|
+
expect(msaCoordToGenomeRegions({ model, coord: 1 })).toEqual([
|
|
244
|
+
{ refName: 'chr1', start: 103, end: 104 },
|
|
245
|
+
{ refName: 'chr1', start: 200, end: 202 },
|
|
246
|
+
])
|
|
247
|
+
// the single-region form bounds the pieces, for navigation
|
|
248
|
+
expect(msaCoordToGenomeCoord({ model, coord: 1 })).toEqual({
|
|
249
|
+
refName: 'chr1',
|
|
250
|
+
start: 103,
|
|
251
|
+
end: 202,
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
157
256
|
// MAF region tests
|
|
158
257
|
describe('mafRegion', () => {
|
|
159
258
|
test('returns genome position for mafRegion mapping', () => {
|
|
@@ -234,7 +333,7 @@ describe('msaCoordToGenomeCoord', () => {
|
|
|
234
333
|
querySeqName: 'hg38.chr1',
|
|
235
334
|
transcriptToMsaMap: {
|
|
236
335
|
refName: 'chr2',
|
|
237
|
-
|
|
336
|
+
p2gCodon: { 0: [5000, 5001, 5002], 1: [5003, 5004, 5005] },
|
|
238
337
|
},
|
|
239
338
|
mafRegion: {
|
|
240
339
|
refName: 'chr1',
|
|
@@ -1,51 +1,95 @@
|
|
|
1
|
+
import { getCodonRanges } from 'g2p_mapper'
|
|
2
|
+
|
|
1
3
|
import { gappedToUngappedPosition } from './structureConnection'
|
|
2
4
|
|
|
3
5
|
import type { MafRegion } from './types'
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
interface GenomeRegion {
|
|
8
|
+
refName: string
|
|
9
|
+
start: number
|
|
10
|
+
end: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CoordModel {
|
|
14
|
+
querySeqName: string
|
|
15
|
+
transcriptToMsaMap:
|
|
16
|
+
| {
|
|
17
|
+
refName: string
|
|
18
|
+
p2gCodon: Record<number, number[]>
|
|
19
|
+
}
|
|
20
|
+
| undefined
|
|
21
|
+
mafRegion?: MafRegion
|
|
22
|
+
rows: string[][]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The genome regions covered by MSA column `coord` of the query row, in 0-based
|
|
27
|
+
* half-open coordinates (what bpToPx and navTo take).
|
|
28
|
+
*
|
|
29
|
+
* Usually one region -- one codon, or one base in a MAF alignment -- but a
|
|
30
|
+
* codon split across an exon boundary yields one region per contiguous piece,
|
|
31
|
+
* which is why this returns a list.
|
|
32
|
+
*/
|
|
33
|
+
export function msaCoordToGenomeRegions({
|
|
6
34
|
model,
|
|
7
35
|
coord: mouseCol,
|
|
8
36
|
}: {
|
|
9
|
-
model:
|
|
10
|
-
querySeqName: string
|
|
11
|
-
transcriptToMsaMap:
|
|
12
|
-
| {
|
|
13
|
-
refName: string
|
|
14
|
-
p2g: Record<number, number>
|
|
15
|
-
}
|
|
16
|
-
| undefined
|
|
17
|
-
mafRegion?: MafRegion
|
|
18
|
-
rows: string[][]
|
|
19
|
-
}
|
|
37
|
+
model: CoordModel
|
|
20
38
|
coord: number
|
|
21
|
-
}) {
|
|
22
|
-
const { querySeqName, transcriptToMsaMap, mafRegion } = model
|
|
39
|
+
}): GenomeRegion[] {
|
|
40
|
+
const { querySeqName, transcriptToMsaMap, mafRegion, rows } = model
|
|
23
41
|
|
|
24
|
-
const querySeq =
|
|
42
|
+
const querySeq = rows.find(f => f[0] === querySeqName)?.[1]
|
|
25
43
|
if (!querySeq) {
|
|
26
|
-
return
|
|
44
|
+
return []
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
const ungappedPos = gappedToUngappedPosition(querySeq, mouseCol)
|
|
30
48
|
if (ungappedPos === undefined) {
|
|
31
|
-
return
|
|
49
|
+
return []
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
if (mafRegion) {
|
|
35
53
|
const genomePos = mafRegion.start + ungappedPos
|
|
36
54
|
return genomePos < mafRegion.end
|
|
37
|
-
? { refName: mafRegion.refName, start: genomePos, end: genomePos + 1 }
|
|
38
|
-
:
|
|
55
|
+
? [{ refName: mafRegion.refName, start: genomePos, end: genomePos + 1 }]
|
|
56
|
+
: []
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
if (transcriptToMsaMap) {
|
|
42
|
-
const { refName,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
const { refName, p2gCodon } = transcriptToMsaMap
|
|
61
|
+
// p2gCodon holds every genomic base of the codon, so the range is exact on
|
|
62
|
+
// either strand. Deriving it from consecutive p2g entries instead
|
|
63
|
+
// (p2g[pos]..p2g[pos+1]) was off by one base on the reverse strand -- where
|
|
64
|
+
// p2g stores the codon's *highest* coordinate -- dropped the final residue,
|
|
65
|
+
// whose successor has no p2g entry, and spanned the whole intron for a
|
|
66
|
+
// codon split across an exon boundary.
|
|
67
|
+
return (
|
|
68
|
+
getCodonRanges(p2gCodon, ungappedPos)?.map(([start, end]) => ({
|
|
69
|
+
refName,
|
|
70
|
+
start,
|
|
71
|
+
end,
|
|
72
|
+
})) ?? []
|
|
73
|
+
)
|
|
48
74
|
}
|
|
49
75
|
|
|
50
|
-
return
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A single region spanning the codon at MSA column `coord`, for navigation. For
|
|
81
|
+
* a codon split across an exon boundary this spans the intervening intron.
|
|
82
|
+
*/
|
|
83
|
+
export function msaCoordToGenomeCoord(args: {
|
|
84
|
+
model: CoordModel
|
|
85
|
+
coord: number
|
|
86
|
+
}): GenomeRegion | undefined {
|
|
87
|
+
const regions = msaCoordToGenomeRegions(args)
|
|
88
|
+
const first = regions[0]
|
|
89
|
+
const last = regions.at(-1)
|
|
90
|
+
// getCodonRanges returns ranges sorted ascending, so first.start..last.end
|
|
91
|
+
// bounds the codon
|
|
92
|
+
return first && last
|
|
93
|
+
? { refName: first.refName, start: first.start, end: last.end }
|
|
94
|
+
: undefined
|
|
51
95
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createDbOpener } from '../utils/idb'
|
|
2
|
+
|
|
3
|
+
import type { DBSchema } from 'idb'
|
|
2
4
|
|
|
3
5
|
const DB_NAME = 'jbrowse-msaview-data'
|
|
4
6
|
const DB_VERSION = 1
|
|
@@ -12,23 +14,21 @@ interface StoredMsaData {
|
|
|
12
14
|
timestamp: number
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
|
22
|
-
store.createIndex('timestamp', 'timestamp', { unique: false })
|
|
23
|
-
}
|
|
24
|
-
},
|
|
25
|
-
}).catch((e: unknown) => {
|
|
26
|
-
dbPromise = undefined
|
|
27
|
-
throw e
|
|
28
|
-
})
|
|
29
|
-
return dbPromise
|
|
17
|
+
interface MsaDataDB extends DBSchema {
|
|
18
|
+
[STORE_NAME]: {
|
|
19
|
+
key: string
|
|
20
|
+
value: StoredMsaData
|
|
21
|
+
indexes: { timestamp: number }
|
|
22
|
+
}
|
|
30
23
|
}
|
|
31
24
|
|
|
25
|
+
const getDB = createDbOpener<MsaDataDB>(DB_NAME, DB_VERSION, db => {
|
|
26
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
27
|
+
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
|
28
|
+
store.createIndex('timestamp', 'timestamp', { unique: false })
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
32
|
export function generateDataStoreId() {
|
|
33
33
|
return `msa-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
|
|
34
34
|
}
|
|
@@ -57,7 +57,7 @@ export async function storeMsaData(
|
|
|
57
57
|
export async function retrieveMsaData(id: string) {
|
|
58
58
|
try {
|
|
59
59
|
const db = await getDB()
|
|
60
|
-
const result =
|
|
60
|
+
const result = await db.get(STORE_NAME, id)
|
|
61
61
|
if (result) {
|
|
62
62
|
return {
|
|
63
63
|
msa: result.msa,
|
|
@@ -56,13 +56,15 @@ describe('syncGenomeHoverToMsaColumn (real genomeToMSA mapping)', () => {
|
|
|
56
56
|
vi.clearAllMocks()
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
test('genome hover at coord 1005 highlights MSA column
|
|
59
|
+
test('genome hover at coord 1005 highlights MSA column 4', () => {
|
|
60
60
|
const { model, calls } = makeModel()
|
|
61
61
|
const run = syncGenomeHoverToMsaColumn(model)
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
// the hover coord is 1-based, so 1005 is the 0-based base 1004, i.e.
|
|
64
|
+
// ungapped offset 4 into a region starting at 1000
|
|
65
|
+
hoverGenome(1005)
|
|
64
66
|
run()
|
|
65
|
-
expect(calls).toEqual([
|
|
67
|
+
expect(calls).toEqual([4])
|
|
66
68
|
})
|
|
67
69
|
|
|
68
70
|
test('moving the genome hover moves the highlighted column', () => {
|
|
@@ -73,7 +75,7 @@ describe('syncGenomeHoverToMsaColumn (real genomeToMSA mapping)', () => {
|
|
|
73
75
|
run()
|
|
74
76
|
hoverGenome(1007)
|
|
75
77
|
run()
|
|
76
|
-
expect(calls).toEqual([
|
|
78
|
+
expect(calls).toEqual([1, 6])
|
|
77
79
|
})
|
|
78
80
|
|
|
79
81
|
test('leaving the genome clears the column it set', () => {
|
|
@@ -84,7 +86,7 @@ describe('syncGenomeHoverToMsaColumn (real genomeToMSA mapping)', () => {
|
|
|
84
86
|
run()
|
|
85
87
|
clearGenomeHover()
|
|
86
88
|
run()
|
|
87
|
-
expect(calls).toEqual([
|
|
89
|
+
expect(calls).toEqual([3, undefined])
|
|
88
90
|
})
|
|
89
91
|
|
|
90
92
|
test('a hover outside the maf region clears a previously-set column once', () => {
|
|
@@ -96,7 +98,7 @@ describe('syncGenomeHoverToMsaColumn (real genomeToMSA mapping)', () => {
|
|
|
96
98
|
hoverGenome(5000) // outside [1000,1010) -> genomeToMSA returns undefined
|
|
97
99
|
run()
|
|
98
100
|
run()
|
|
99
|
-
expect(calls).toEqual([
|
|
101
|
+
expect(calls).toEqual([3, undefined])
|
|
100
102
|
})
|
|
101
103
|
|
|
102
104
|
test('never touches mouseCol when the genome never provides a column, so a direct MSA hover survives unrelated session hovers', () => {
|
package/src/utils/blastCache.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createDbOpener } from './idb'
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
BlastDatabase,
|
|
5
5
|
BlastProgram,
|
|
6
6
|
MsaAlgorithm,
|
|
7
7
|
} from '../LaunchMsaView/components/NCBIBlastQuery/consts'
|
|
8
|
+
import type { DBSchema } from 'idb'
|
|
8
9
|
|
|
9
10
|
const DB_NAME = 'jbrowse-msaview-blast-cache'
|
|
10
11
|
const STORE_NAME = 'blast-results'
|
|
@@ -27,25 +28,26 @@ export interface CachedBlastResult {
|
|
|
27
28
|
geneName?: string
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (oldVersion < 2 && db.objectStoreNames.contains(STORE_NAME)) {
|
|
36
|
-
db.deleteObjectStore(STORE_NAME)
|
|
37
|
-
}
|
|
38
|
-
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
39
|
-
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
}).catch((e: unknown) => {
|
|
43
|
-
dbPromise = undefined
|
|
44
|
-
throw e
|
|
45
|
-
})
|
|
46
|
-
return dbPromise
|
|
31
|
+
interface BlastCacheDB extends DBSchema {
|
|
32
|
+
[STORE_NAME]: {
|
|
33
|
+
key: string
|
|
34
|
+
value: CachedBlastResult
|
|
35
|
+
}
|
|
47
36
|
}
|
|
48
37
|
|
|
38
|
+
const getDB = createDbOpener<BlastCacheDB>(
|
|
39
|
+
DB_NAME,
|
|
40
|
+
DB_VERSION,
|
|
41
|
+
(db, oldVersion) => {
|
|
42
|
+
if (oldVersion < 2 && db.objectStoreNames.contains(STORE_NAME)) {
|
|
43
|
+
db.deleteObjectStore(STORE_NAME)
|
|
44
|
+
}
|
|
45
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
46
|
+
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
49
51
|
function createCacheKey(
|
|
50
52
|
proteinSequence: string,
|
|
51
53
|
blastDatabase: BlastDatabase,
|
|
@@ -125,8 +127,3 @@ export async function deleteCachedResult(id: string) {
|
|
|
125
127
|
const db = await getDB()
|
|
126
128
|
await db.delete(STORE_NAME, id)
|
|
127
129
|
}
|
|
128
|
-
|
|
129
|
-
export async function clearAllCachedResults() {
|
|
130
|
-
const db = await getDB()
|
|
131
|
-
await db.clear(STORE_NAME)
|
|
132
|
-
}
|
package/src/utils/domainCache.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createDbOpener } from './idb'
|
|
2
2
|
|
|
3
3
|
import type { DomainMatch } from './ncbiDomains'
|
|
4
|
+
import type { DBSchema } from 'idb'
|
|
4
5
|
|
|
5
6
|
const DB_NAME = 'jbrowse-msaview-domain-cache'
|
|
6
7
|
const STORE_NAME = 'domains'
|
|
@@ -11,29 +12,24 @@ interface CachedDomain {
|
|
|
11
12
|
matches: DomainMatch[]
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
20
|
-
db.createObjectStore(STORE_NAME, { keyPath: 'accession' })
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
}).catch((e: unknown) => {
|
|
24
|
-
dbPromise = undefined
|
|
25
|
-
throw e
|
|
26
|
-
})
|
|
27
|
-
return dbPromise
|
|
15
|
+
interface DomainCacheDB extends DBSchema {
|
|
16
|
+
[STORE_NAME]: {
|
|
17
|
+
key: string
|
|
18
|
+
value: CachedDomain
|
|
19
|
+
}
|
|
28
20
|
}
|
|
29
21
|
|
|
22
|
+
const getDB = createDbOpener<DomainCacheDB>(DB_NAME, DB_VERSION, db => {
|
|
23
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
24
|
+
db.createObjectStore(STORE_NAME, { keyPath: 'accession' })
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
30
28
|
export async function getCachedDomains(accessions: string[]) {
|
|
31
29
|
const db = await getDB()
|
|
32
30
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
|
33
31
|
const results = await Promise.all(
|
|
34
|
-
accessions.map(
|
|
35
|
-
accession => tx.store.get(accession) as Promise<CachedDomain | undefined>,
|
|
36
|
-
),
|
|
32
|
+
accessions.map(accession => tx.store.get(accession)),
|
|
37
33
|
)
|
|
38
34
|
await tx.done
|
|
39
35
|
return results
|
package/src/utils/idb.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { openDB } from 'idb'
|
|
2
|
+
|
|
3
|
+
import type { DBSchema, IDBPDatabase, OpenDBCallbacks } from 'idb'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Memoized `openDB` for a typed schema, shared by this plugin's caches.
|
|
7
|
+
*
|
|
8
|
+
* The connection promise is cached so callers share one connection, and dropped
|
|
9
|
+
* again if the open fails, so a later call retries instead of replaying the same
|
|
10
|
+
* rejection forever (IndexedDB is unavailable in some private-browsing modes).
|
|
11
|
+
*
|
|
12
|
+
* Passing a DBSchema is what keeps `get`/`getAll` from returning `any`: with an
|
|
13
|
+
* untyped database every cached record reaches the UI unchecked.
|
|
14
|
+
*/
|
|
15
|
+
export function createDbOpener<T extends DBSchema>(
|
|
16
|
+
name: string,
|
|
17
|
+
version: number,
|
|
18
|
+
upgrade: OpenDBCallbacks<T>['upgrade'],
|
|
19
|
+
) {
|
|
20
|
+
let dbPromise: Promise<IDBPDatabase<T>> | undefined
|
|
21
|
+
return () => {
|
|
22
|
+
dbPromise ??= openDB<T>(name, version, { upgrade }).catch((e: unknown) => {
|
|
23
|
+
dbPromise = undefined
|
|
24
|
+
throw e
|
|
25
|
+
})
|
|
26
|
+
return dbPromise
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { dedupeLabels, parseFasta } from './ncbiOrthologs'
|
|
4
|
+
|
|
5
|
+
describe('dedupeLabels', () => {
|
|
6
|
+
test('sanitizes to single tokens', () => {
|
|
7
|
+
// labels are used identically as FASTA headers, Newick leaf names and GFF
|
|
8
|
+
// seq_ids, so anything that would need quoting in one of those is stripped
|
|
9
|
+
expect(dedupeLabels(['house mouse', 'Norway rat'])).toEqual([
|
|
10
|
+
'house_mouse',
|
|
11
|
+
'Norway_rat',
|
|
12
|
+
])
|
|
13
|
+
expect(dedupeLabels(['Frog (X. tropicalis)'])).toEqual([
|
|
14
|
+
'Frog_X_tropicalis',
|
|
15
|
+
])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('suffixes collisions rather than overwriting a row', () => {
|
|
19
|
+
expect(dedupeLabels(['a b', 'a-b', 'a_b'])).toEqual([
|
|
20
|
+
'a_b',
|
|
21
|
+
'a_b_2',
|
|
22
|
+
'a_b_3',
|
|
23
|
+
])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('falls back for a name with no usable characters', () => {
|
|
27
|
+
expect(dedupeLabels(['...', '...'])).toEqual(['row', 'row_2'])
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('parseFasta', () => {
|
|
32
|
+
test('keys by the first header token and joins wrapped lines', () => {
|
|
33
|
+
const map = parseFasta(
|
|
34
|
+
['>NP_000537.3 cellular tumor antigen p53', 'MEEP', 'QSDP', ''].join(
|
|
35
|
+
'\n',
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
expect(map.get('NP_000537.3')).toBe('MEEPQSDP')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('reads every record of a multi-FASTA', () => {
|
|
42
|
+
const map = parseFasta(
|
|
43
|
+
['>A one', 'MMM', '>B two', 'KKK', '>C three', 'LLL'].join('\n'),
|
|
44
|
+
)
|
|
45
|
+
expect([...map.keys()]).toEqual(['A', 'B', 'C'])
|
|
46
|
+
expect(map.get('C')).toBe('LLL')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('returns nothing for a response that carried no records', () => {
|
|
50
|
+
// efetch answers an unknown accession with an error body, not a 4xx, so a
|
|
51
|
+
// caller that assumed "text back = sequences" would build empty rows
|
|
52
|
+
expect(parseFasta('Error: CEFetchPApplication::proxy_stream()').size).toBe(
|
|
53
|
+
0,
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
})
|