jbrowse-plugin-msaview 3.4.0 → 3.5.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/README.md +7 -0
- package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +23 -17
- package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +1 -0
- package/dist/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.js +5 -3
- package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +24 -4
- package/dist/LaunchMsaView/components/BlastQuery/consts.js +44 -2
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
- package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
- package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -0
- package/dist/LaunchMsaView/components/ManualMSALoader/launchView.d.ts +3 -1
- package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +2 -1
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +10 -4
- package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +4 -1
- package/dist/LaunchMsaView/components/QueryRowSelector.js +21 -5
- package/dist/LaunchMsaView/detectQueryRow.d.ts +11 -0
- package/dist/LaunchMsaView/detectQueryRow.js +11 -4
- package/dist/LaunchMsaView/detectQueryRow.test.js +30 -0
- package/dist/LaunchMsaView/useQueryRowName.d.ts +1 -0
- package/dist/LaunchMsaView/useQueryRowName.js +5 -1
- package/dist/LaunchMsaView/useQueryRowName.test.d.ts +1 -0
- package/dist/LaunchMsaView/useQueryRowName.test.js +23 -0
- package/dist/LaunchMsaViewExtensionPoint/index.js +19 -12
- package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
- package/dist/LaunchMsaViewExtensionPoint/index.test.js +64 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +16 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.js +138 -86
- package/dist/MsaViewPanel/components/LaunchProgress.js +23 -5
- package/dist/MsaViewPanel/components/MsaViewPanel.js +6 -2
- package/dist/MsaViewPanel/components/MsaViewPanel.test.js +40 -0
- package/dist/MsaViewPanel/doLaunchBlast.d.ts +11 -1
- package/dist/MsaViewPanel/doLaunchBlast.js +79 -96
- package/dist/MsaViewPanel/doLaunchBlast.test.d.ts +1 -0
- package/dist/MsaViewPanel/doLaunchBlast.test.js +158 -0
- package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
- package/dist/MsaViewPanel/doLaunchOrthologs.js +46 -10
- package/dist/MsaViewPanel/doLaunchOrthologs.test.js +48 -24
- package/dist/MsaViewPanel/genomeToMSA.js +4 -4
- package/dist/MsaViewPanel/genomeToMSA.test.js +67 -0
- package/dist/MsaViewPanel/loadProteinDomains.d.ts +8 -2
- package/dist/MsaViewPanel/loadProteinDomains.js +18 -9
- package/dist/MsaViewPanel/loadProteinDomains.test.d.ts +1 -0
- package/dist/MsaViewPanel/loadProteinDomains.test.js +41 -0
- package/dist/MsaViewPanel/model.d.ts +306 -85
- package/dist/MsaViewPanel/model.js +169 -42
- package/dist/MsaViewPanel/model.test.d.ts +1 -0
- package/dist/MsaViewPanel/model.test.js +100 -0
- package/dist/MsaViewPanel/msaCoordToGenomeCoord.d.ts +12 -1
- package/dist/MsaViewPanel/msaCoordToGenomeCoord.js +8 -8
- package/dist/MsaViewPanel/msaCoordToGenomeCoord.test.js +133 -32
- package/dist/MsaViewPanel/msaDataStore.d.ts +7 -3
- package/dist/MsaViewPanel/msaDataStore.js +26 -7
- package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
- package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
- package/dist/MsaViewPanel/observeProteinHighlights.test.js +20 -3
- package/dist/MsaViewPanel/processInit.test.d.ts +1 -0
- package/dist/MsaViewPanel/processInit.test.js +72 -0
- package/dist/MsaViewPanel/resolveConnectedTranscript.d.ts +9 -0
- package/dist/MsaViewPanel/resolveConnectedTranscript.js +122 -0
- package/dist/MsaViewPanel/runLaunch.d.ts +39 -0
- package/dist/MsaViewPanel/runLaunch.js +100 -0
- package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
- package/dist/MsaViewPanel/runLaunch.test.js +175 -0
- package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
- package/dist/MsaViewPanel/storedData.test.js +160 -0
- package/dist/MsaViewPanel/structureConnection.d.ts +0 -4
- package/dist/MsaViewPanel/structureConnection.js +0 -19
- package/dist/MsaViewPanel/util.d.ts +39 -0
- package/dist/MsaViewPanel/util.js +44 -0
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +46 -45
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/utils/blastCache.js +32 -0
- package/dist/utils/blastCache.test.d.ts +1 -0
- package/dist/utils/blastCache.test.js +72 -0
- package/dist/utils/browserAlign.d.ts +42 -0
- package/dist/utils/browserAlign.js +297 -0
- package/dist/utils/browserAlign.test.d.ts +1 -0
- package/dist/utils/browserAlign.test.js +85 -0
- package/dist/utils/ebiBlast.d.ts +13 -2
- package/dist/utils/ebiBlast.js +32 -4
- package/dist/utils/ebiJobDispatcher.d.ts +6 -3
- package/dist/utils/ebiJobDispatcher.js +13 -6
- package/dist/utils/fetch.d.ts +8 -1
- package/dist/utils/fetch.js +28 -2
- package/dist/utils/homologSearch.d.ts +31 -0
- package/dist/utils/homologSearch.js +6 -0
- package/dist/utils/msa.d.ts +5 -11
- package/dist/utils/msa.js +19 -30
- package/dist/utils/msaRows.d.ts +11 -8
- package/dist/utils/msaRows.js +14 -12
- package/dist/utils/ncbiDomains.d.ts +2 -2
- package/dist/utils/ncbiOrthologs.d.ts +11 -1
- package/dist/utils/ncbiOrthologs.js +26 -7
- package/dist/utils/ncbiOrthologs.test.js +23 -1
- package/dist/utils/pantherOrthologs.js +2 -10
- package/dist/utils/phmmer.d.ts +12 -1
- package/dist/utils/phmmer.js +52 -13
- package/dist/utils/poll.d.ts +6 -1
- package/dist/utils/poll.js +7 -2
- package/dist/utils/unirefHomologs.d.ts +94 -0
- package/dist/utils/unirefHomologs.js +193 -0
- package/dist/utils/unirefHomologs.test.d.ts +1 -0
- package/dist/utils/unirefHomologs.test.js +118 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +17 -16
- package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +40 -19
- package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -0
- package/src/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.tsx +7 -2
- package/src/LaunchMsaView/components/BlastQuery/consts.ts +50 -2
- package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
- package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
- package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -0
- package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +4 -0
- package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +12 -5
- package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +4 -1
- package/src/LaunchMsaView/components/QueryRowSelector.tsx +35 -7
- package/src/LaunchMsaView/detectQueryRow.test.ts +34 -0
- package/src/LaunchMsaView/detectQueryRow.ts +22 -4
- package/src/LaunchMsaView/useQueryRowName.test.ts +27 -0
- package/src/LaunchMsaView/useQueryRowName.ts +5 -1
- package/src/LaunchMsaViewExtensionPoint/index.test.ts +74 -0
- package/src/LaunchMsaViewExtensionPoint/index.ts +53 -12
- package/src/MsaViewPanel/afterCreateAutoruns.ts +150 -81
- package/src/MsaViewPanel/components/LaunchProgress.tsx +46 -4
- package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +43 -0
- package/src/MsaViewPanel/components/MsaViewPanel.tsx +8 -2
- package/src/MsaViewPanel/doLaunchBlast.test.ts +207 -0
- package/src/MsaViewPanel/doLaunchBlast.ts +94 -140
- package/src/MsaViewPanel/doLaunchOrthologs.test.ts +54 -26
- package/src/MsaViewPanel/doLaunchOrthologs.ts +63 -9
- package/src/MsaViewPanel/genomeToMSA.test.ts +76 -0
- package/src/MsaViewPanel/genomeToMSA.ts +8 -4
- package/src/MsaViewPanel/loadProteinDomains.test.ts +52 -0
- package/src/MsaViewPanel/loadProteinDomains.ts +24 -11
- package/src/MsaViewPanel/model.test.ts +121 -0
- package/src/MsaViewPanel/model.ts +200 -49
- package/src/MsaViewPanel/msaCoordToGenomeCoord.test.ts +152 -32
- package/src/MsaViewPanel/msaCoordToGenomeCoord.ts +21 -11
- package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
- package/src/MsaViewPanel/msaDataStore.ts +32 -13
- package/src/MsaViewPanel/observeProteinHighlights.test.ts +27 -3
- package/src/MsaViewPanel/processInit.test.ts +84 -0
- package/src/MsaViewPanel/resolveConnectedTranscript.ts +150 -0
- package/src/MsaViewPanel/runLaunch.test.ts +208 -0
- package/src/MsaViewPanel/runLaunch.ts +141 -0
- package/src/MsaViewPanel/storedData.test.ts +196 -0
- package/src/MsaViewPanel/structureConnection.ts +0 -26
- package/src/MsaViewPanel/util.ts +75 -0
- package/src/utils/blastCache.test.ts +86 -0
- package/src/utils/blastCache.ts +34 -1
- package/src/utils/browserAlign.test.ts +102 -0
- package/src/utils/browserAlign.ts +352 -0
- package/src/utils/ebiBlast.ts +42 -2
- package/src/utils/ebiJobDispatcher.ts +18 -3
- package/src/utils/fetch.ts +29 -2
- package/src/utils/homologSearch.ts +51 -0
- package/src/utils/msa.ts +25 -39
- package/src/utils/msaRows.ts +20 -16
- package/src/utils/ncbiDomains.ts +4 -2
- package/src/utils/ncbiOrthologs.test.ts +25 -0
- package/src/utils/ncbiOrthologs.ts +27 -7
- package/src/utils/pantherOrthologs.ts +6 -11
- package/src/utils/phmmer.ts +62 -12
- package/src/utils/poll.ts +8 -1
- package/src/utils/unirefHomologs.test.ts +149 -0
- package/src/utils/unirefHomologs.ts +323 -0
- package/src/version.ts +1 -1
- package/dist/MsaViewPanel/structureConnection.test.js +0 -53
- package/src/MsaViewPanel/structureConnection.test.ts +0 -62
- /package/dist/{MsaViewPanel/structureConnection.test.d.ts → LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts} +0 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { loadStoredData, storeDataToIndexedDB } from './afterCreateAutoruns'
|
|
4
|
+
import {
|
|
5
|
+
generateDataStoreId,
|
|
6
|
+
retrieveMsaData,
|
|
7
|
+
storeMsaData,
|
|
8
|
+
} from './msaDataStore'
|
|
9
|
+
|
|
10
|
+
import type { JBrowsePluginMsaViewModel } from './model'
|
|
11
|
+
import type { MsaDataPayload } from './msaDataStore'
|
|
12
|
+
|
|
13
|
+
// IndexedDB itself is msaDataStore.test.ts's subject; what is under test here is
|
|
14
|
+
// which writes these two autoruns decide to make.
|
|
15
|
+
vi.mock('./msaDataStore', () => ({
|
|
16
|
+
cleanupOldData: vi.fn(),
|
|
17
|
+
generateDataStoreId: vi.fn(),
|
|
18
|
+
retrieveMsaData: vi.fn(),
|
|
19
|
+
storeMsaData: vi.fn(),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
const mockRetrieve = vi.mocked(retrieveMsaData)
|
|
23
|
+
const mockStore = vi.mocked(storeMsaData)
|
|
24
|
+
const mockGenerateId = vi.mocked(generateDataStoreId)
|
|
25
|
+
|
|
26
|
+
const MSA = '>a\nMK'
|
|
27
|
+
const NEW_ID = 'msa-generated'
|
|
28
|
+
|
|
29
|
+
function makeModel(over: Record<string, unknown> = {}) {
|
|
30
|
+
const model = {
|
|
31
|
+
dataStoreId: undefined as string | undefined,
|
|
32
|
+
rows: [] as string[][],
|
|
33
|
+
data: {} as MsaDataPayload,
|
|
34
|
+
msaFilehandle: undefined,
|
|
35
|
+
treeFilehandle: undefined,
|
|
36
|
+
isStoringData: false,
|
|
37
|
+
lastStoredData: undefined as MsaDataPayload | undefined,
|
|
38
|
+
loadingStoredData: false,
|
|
39
|
+
error: undefined as unknown,
|
|
40
|
+
setDataStoreId(arg?: string) {
|
|
41
|
+
model.dataStoreId = arg
|
|
42
|
+
},
|
|
43
|
+
setIsStoringData(arg: boolean) {
|
|
44
|
+
model.isStoringData = arg
|
|
45
|
+
},
|
|
46
|
+
setLastStoredData(arg?: MsaDataPayload) {
|
|
47
|
+
model.lastStoredData = arg
|
|
48
|
+
},
|
|
49
|
+
setLoadingStoredData(arg: boolean) {
|
|
50
|
+
model.loadingStoredData = arg
|
|
51
|
+
},
|
|
52
|
+
setError(arg: unknown) {
|
|
53
|
+
model.error = arg
|
|
54
|
+
},
|
|
55
|
+
setMSA(arg: string) {
|
|
56
|
+
model.data.msa = arg
|
|
57
|
+
},
|
|
58
|
+
setTree(arg: string) {
|
|
59
|
+
model.data.tree = arg
|
|
60
|
+
},
|
|
61
|
+
setTreeMetadata(arg: string) {
|
|
62
|
+
model.data.treeMetadata = arg
|
|
63
|
+
},
|
|
64
|
+
...over,
|
|
65
|
+
}
|
|
66
|
+
return model as typeof model & JBrowsePluginMsaViewModel
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const settle = () => new Promise(res => setTimeout(res, 0))
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.clearAllMocks()
|
|
73
|
+
mockGenerateId.mockReturnValue(NEW_ID)
|
|
74
|
+
mockStore.mockResolvedValue(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('restoring a view from IndexedDB', () => {
|
|
78
|
+
test('a row that is still there is applied and recorded as stored', async () => {
|
|
79
|
+
mockRetrieve.mockResolvedValue({
|
|
80
|
+
msa: MSA,
|
|
81
|
+
tree: '(a);',
|
|
82
|
+
treeMetadata: '{}',
|
|
83
|
+
})
|
|
84
|
+
const model = makeModel({ dataStoreId: 'msa-1' })
|
|
85
|
+
|
|
86
|
+
loadStoredData(model)
|
|
87
|
+
await settle()
|
|
88
|
+
|
|
89
|
+
expect(model.data.msa).toBe(MSA)
|
|
90
|
+
expect(model.error).toBeUndefined()
|
|
91
|
+
// recorded, so the store autorun does not write the restored data straight
|
|
92
|
+
// back out again
|
|
93
|
+
expect(model.lastStoredData).toEqual({
|
|
94
|
+
msa: MSA,
|
|
95
|
+
tree: '(a);',
|
|
96
|
+
treeMetadata: '{}',
|
|
97
|
+
})
|
|
98
|
+
model.rows = [['a', 'MK']]
|
|
99
|
+
storeDataToIndexedDB(model)
|
|
100
|
+
expect(mockStore).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// the row expires or the user clears site data, and the view used to reopen as
|
|
104
|
+
// a blank import form with no hint that anything had been lost
|
|
105
|
+
test('a row that is gone says so, in words that name the policy', async () => {
|
|
106
|
+
mockRetrieve.mockResolvedValue(undefined)
|
|
107
|
+
const model = makeModel({ dataStoreId: 'msa-1' })
|
|
108
|
+
|
|
109
|
+
loadStoredData(model)
|
|
110
|
+
await settle()
|
|
111
|
+
|
|
112
|
+
expect(model.error).toBeInstanceOf(Error)
|
|
113
|
+
expect((model.error as Error).message).toMatch(/7 days/)
|
|
114
|
+
// the id names nothing now, so it goes -- otherwise react-msaview's "Return
|
|
115
|
+
// to import form" lands straight back on this error
|
|
116
|
+
expect(model.dataStoreId).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('keeping IndexedDB up to date', () => {
|
|
121
|
+
test('the first alignment is written under a fresh id', async () => {
|
|
122
|
+
const model = makeModel({ rows: [['a', 'MK']], data: { msa: MSA } })
|
|
123
|
+
|
|
124
|
+
storeDataToIndexedDB(model)
|
|
125
|
+
await settle()
|
|
126
|
+
|
|
127
|
+
expect(mockStore).toHaveBeenCalledWith(NEW_ID, { msa: MSA })
|
|
128
|
+
expect(model.dataStoreId).toBe(NEW_ID)
|
|
129
|
+
expect(model.isStoringData).toBe(false)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// react-msaview's "calculate neighbor-joining tree" replaces data.tree long
|
|
133
|
+
// after the first write; the row used to keep the alignment's original tree
|
|
134
|
+
// forever, so a reopened session restored a picture the user had replaced
|
|
135
|
+
test('an edit after the first write updates the existing row', async () => {
|
|
136
|
+
const model = makeModel({
|
|
137
|
+
dataStoreId: 'msa-1',
|
|
138
|
+
rows: [['a', 'MK']],
|
|
139
|
+
data: { msa: MSA, tree: '(a);' },
|
|
140
|
+
lastStoredData: { msa: MSA, tree: '(a);' },
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
storeDataToIndexedDB(model)
|
|
144
|
+
expect(mockStore).not.toHaveBeenCalled()
|
|
145
|
+
|
|
146
|
+
model.data.tree = '(a:0.1);'
|
|
147
|
+
storeDataToIndexedDB(model)
|
|
148
|
+
await settle()
|
|
149
|
+
|
|
150
|
+
expect(mockStore).toHaveBeenCalledWith('msa-1', {
|
|
151
|
+
msa: MSA,
|
|
152
|
+
tree: '(a:0.1);',
|
|
153
|
+
treeMetadata: undefined,
|
|
154
|
+
})
|
|
155
|
+
expect(model.dataStoreId).toBe('msa-1')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// a browser that refuses IndexedDB (private mode) answers every write the same
|
|
159
|
+
// way, and the autorun reruns on its own isStoringData flag -- so a write that
|
|
160
|
+
// is not recorded as attempted is a write that retries forever
|
|
161
|
+
test('a write that fails is not retried on the same data', async () => {
|
|
162
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
163
|
+
mockStore.mockResolvedValue(false)
|
|
164
|
+
const model = makeModel({ rows: [['a', 'MK']], data: { msa: MSA } })
|
|
165
|
+
|
|
166
|
+
storeDataToIndexedDB(model)
|
|
167
|
+
await settle()
|
|
168
|
+
expect(model.dataStoreId).toBeUndefined()
|
|
169
|
+
|
|
170
|
+
storeDataToIndexedDB(model)
|
|
171
|
+
await settle()
|
|
172
|
+
expect(mockStore).toHaveBeenCalledTimes(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('a view reading from a filehandle stores nothing, since the file is the source', () => {
|
|
176
|
+
storeDataToIndexedDB(
|
|
177
|
+
makeModel({
|
|
178
|
+
rows: [['a', 'MK']],
|
|
179
|
+
data: { msa: MSA },
|
|
180
|
+
msaFilehandle: { uri: 'msa.fa', locationType: 'UriLocation' },
|
|
181
|
+
}),
|
|
182
|
+
)
|
|
183
|
+
expect(mockStore).not.toHaveBeenCalled()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('a write already in flight is not duplicated', () => {
|
|
187
|
+
storeDataToIndexedDB(
|
|
188
|
+
makeModel({
|
|
189
|
+
rows: [['a', 'MK']],
|
|
190
|
+
data: { msa: MSA },
|
|
191
|
+
isStoringData: true,
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
expect(mockStore).not.toHaveBeenCalled()
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -31,29 +31,3 @@ export function isProteinView(view: unknown): view is ProteinView {
|
|
|
31
31
|
export function getProteinViews(views: { type: string }[]): ProteinView[] {
|
|
32
32
|
return (views as unknown[]).filter(isProteinView)
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Helper to convert gapped MSA column to ungapped position for a specific row
|
|
37
|
-
*/
|
|
38
|
-
export function gappedToUngappedPosition(
|
|
39
|
-
sequence: string,
|
|
40
|
-
gappedPosition: number,
|
|
41
|
-
): number | undefined {
|
|
42
|
-
if (gappedPosition < 0 || gappedPosition >= sequence.length) {
|
|
43
|
-
return undefined
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let ungapped = 0
|
|
47
|
-
for (let i = 0; i < gappedPosition; i++) {
|
|
48
|
-
if (sequence[i] !== '-') {
|
|
49
|
-
ungapped++
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// If the position itself is a gap, return undefined
|
|
54
|
-
if (sequence[gappedPosition] === '-') {
|
|
55
|
-
return undefined
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return ungapped
|
|
59
|
-
}
|
package/src/MsaViewPanel/util.ts
CHANGED
|
@@ -16,6 +16,81 @@ export function hasQueryRow(model: { rows: string[][]; querySeqName: string }) {
|
|
|
16
16
|
return model.rows.some(r => r[0] === model.querySeqName)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The transcript a launch was started from, as it is stored: plain JSON.
|
|
21
|
+
*
|
|
22
|
+
* blastParams and orthologParams are frozen snapshot properties, so a Feature
|
|
23
|
+
* instance put in one lives only as long as the tab. A reloaded session hands
|
|
24
|
+
* back the JSON the instance serialized to, and `.get()` on that threw --
|
|
25
|
+
* minutes after the EBI job the reload resubmitted had finally come back.
|
|
26
|
+
*/
|
|
27
|
+
export type TranscriptRef = Record<string, unknown> | LiveFeature
|
|
28
|
+
|
|
29
|
+
interface LiveFeature {
|
|
30
|
+
toJSON: () => Record<string, unknown>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read the transcript's fields whichever shape it arrived in: a caller inside
|
|
35
|
+
* the session may still hand over a live Feature.
|
|
36
|
+
*/
|
|
37
|
+
export function transcriptFields(transcript?: TranscriptRef) {
|
|
38
|
+
if (!transcript) {
|
|
39
|
+
return {}
|
|
40
|
+
}
|
|
41
|
+
return typeof (transcript as LiveFeature).toJSON === 'function'
|
|
42
|
+
? (transcript as LiveFeature).toJSON()
|
|
43
|
+
: (transcript as Record<string, unknown>)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function str(val: unknown) {
|
|
47
|
+
return typeof val === 'string' ? val : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** what a transcript is called, preferring its name over its id */
|
|
51
|
+
export function transcriptName(transcript?: TranscriptRef) {
|
|
52
|
+
const fields = transcriptFields(transcript)
|
|
53
|
+
return str(fields.name) ?? str(fields.id)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface QueryRowModel {
|
|
57
|
+
querySeqName: string
|
|
58
|
+
querySeqOffset: number
|
|
59
|
+
seqPosToVisibleCol: (rowName: string, seqPos: number) => number | undefined
|
|
60
|
+
visibleColToSeqPos: (
|
|
61
|
+
rowName: string,
|
|
62
|
+
visibleCol: number,
|
|
63
|
+
) => number | undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The visible column showing residue `proteinPos` (0-based) of the transcript,
|
|
68
|
+
* or undefined when the query row does not carry that residue or react-msaview
|
|
69
|
+
* is hiding its column.
|
|
70
|
+
*
|
|
71
|
+
* Two things separate the transcript from the row. `querySeqOffset` is the
|
|
72
|
+
* trimming — a pasted BLAST alignment carries the aligned region, not the whole
|
|
73
|
+
* protein. And the row can simply stop short: react-msaview answers one column
|
|
74
|
+
* past the end for a position it does not have, which would light the last
|
|
75
|
+
* column for every residue beyond the row, so the round trip back through
|
|
76
|
+
* visibleColToSeqPos is what rejects those.
|
|
77
|
+
*/
|
|
78
|
+
export function transcriptPosToVisibleCol(
|
|
79
|
+
model: QueryRowModel,
|
|
80
|
+
proteinPos: number,
|
|
81
|
+
) {
|
|
82
|
+
const { querySeqName, querySeqOffset } = model
|
|
83
|
+
const seqPos = proteinPos - querySeqOffset
|
|
84
|
+
if (seqPos < 0) {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
const col = model.seqPosToVisibleCol(querySeqName, seqPos)
|
|
88
|
+
return col !== undefined &&
|
|
89
|
+
model.visibleColToSeqPos(querySeqName, col) === seqPos
|
|
90
|
+
? col
|
|
91
|
+
: undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
19
94
|
export function hasHoverPosition(
|
|
20
95
|
hovered: unknown,
|
|
21
96
|
): hovered is { hoverPosition: { coord: number; refName: string } } {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { getAllCachedResults, saveBlastResult } from './blastCache'
|
|
4
|
+
|
|
5
|
+
import type { CachedBlastResult } from './blastCache'
|
|
6
|
+
|
|
7
|
+
// An in-memory stand-in for the one object store this module opens. Only the
|
|
8
|
+
// idb calls blastCache makes are implemented; anything else would be untested
|
|
9
|
+
// scaffolding.
|
|
10
|
+
const { rows } = vi.hoisted(() => ({
|
|
11
|
+
rows: new Map<string, { id: string; timestamp: number }>(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
vi.mock('./idb', () => ({
|
|
15
|
+
createDbOpener: () => () =>
|
|
16
|
+
Promise.resolve({
|
|
17
|
+
put: (_store: string, value: { id: string }) => {
|
|
18
|
+
rows.set(value.id, value as { id: string; timestamp: number })
|
|
19
|
+
return Promise.resolve(value.id)
|
|
20
|
+
},
|
|
21
|
+
count: () => Promise.resolve(rows.size),
|
|
22
|
+
getAll: () => Promise.resolve([...rows.values()]),
|
|
23
|
+
transaction: () => ({
|
|
24
|
+
store: {
|
|
25
|
+
delete: (id: string) => {
|
|
26
|
+
rows.delete(id)
|
|
27
|
+
return Promise.resolve()
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
done: Promise.resolve(),
|
|
31
|
+
}),
|
|
32
|
+
}),
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
function save(n: number) {
|
|
36
|
+
return saveBlastResult({
|
|
37
|
+
proteinSequence: `SEQ${n}`,
|
|
38
|
+
blastDatabase: 'uniprotkb_swissprot',
|
|
39
|
+
msaAlgorithm: 'clustalo',
|
|
40
|
+
msa: '>a\nMK',
|
|
41
|
+
tree: '(a);',
|
|
42
|
+
treeMetadata: '{}',
|
|
43
|
+
rid: `job-${n}`,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
rows.clear()
|
|
49
|
+
// strictly increasing, so "oldest" is unambiguous -- 55 saves in one
|
|
50
|
+
// millisecond would otherwise all carry the same timestamp
|
|
51
|
+
let clock = 1
|
|
52
|
+
vi.spyOn(Date, 'now').mockImplementation(() => clock++)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.restoreAllMocks()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('a store at the cap loses nothing', async () => {
|
|
60
|
+
for (let i = 0; i < 50; i++) {
|
|
61
|
+
await save(i)
|
|
62
|
+
}
|
|
63
|
+
expect(rows.size).toBe(50)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Every row holds a whole alignment and its tree, so an unbounded store grows
|
|
67
|
+
// until the browser refuses writes to it and the user never learns why.
|
|
68
|
+
test('going over the cap drops the oldest results and keeps the newest 50', async () => {
|
|
69
|
+
for (let i = 0; i < 55; i++) {
|
|
70
|
+
await save(i)
|
|
71
|
+
}
|
|
72
|
+
expect(rows.size).toBe(50)
|
|
73
|
+
const rids = (await getAllCachedResults()).map(
|
|
74
|
+
(r: CachedBlastResult) => r.rid,
|
|
75
|
+
)
|
|
76
|
+
expect(rids.at(0)).toBe('job-54')
|
|
77
|
+
expect(rids.at(-1)).toBe('job-5')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('re-saving the same query overwrites its row rather than growing the store', async () => {
|
|
81
|
+
for (let i = 0; i < 50; i++) {
|
|
82
|
+
await save(i)
|
|
83
|
+
}
|
|
84
|
+
await save(0)
|
|
85
|
+
expect(rows.size).toBe(50)
|
|
86
|
+
})
|
package/src/utils/blastCache.ts
CHANGED
|
@@ -6,12 +6,19 @@ import type {
|
|
|
6
6
|
PhmmerDatabase,
|
|
7
7
|
SearchProgram,
|
|
8
8
|
} from '../LaunchMsaView/components/BlastQuery/consts'
|
|
9
|
-
import type { DBSchema } from 'idb'
|
|
9
|
+
import type { DBSchema, IDBPDatabase } from 'idb'
|
|
10
10
|
|
|
11
11
|
const DB_NAME = 'jbrowse-msaview-blast-cache'
|
|
12
12
|
const STORE_NAME = 'blast-results'
|
|
13
13
|
const DB_VERSION = 2
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* How many results the history keeps. Every row holds a whole alignment and its
|
|
17
|
+
* tree — megabytes each — so an unbounded store grows until the browser starts
|
|
18
|
+
* refusing writes to it, and the user never sees why.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_CACHED_RESULTS = 50
|
|
21
|
+
|
|
15
22
|
export interface CachedBlastResult {
|
|
16
23
|
id: string
|
|
17
24
|
proteinSequence: string
|
|
@@ -134,9 +141,35 @@ export async function saveBlastResult({
|
|
|
134
141
|
geneName,
|
|
135
142
|
}
|
|
136
143
|
await db.put(STORE_NAME, entry)
|
|
144
|
+
await evictOldest(db)
|
|
137
145
|
return entry
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Drop the oldest rows until the store is back at MAX_CACHED_RESULTS.
|
|
150
|
+
*
|
|
151
|
+
* `count` first so the common save reads no values at all: without a timestamp
|
|
152
|
+
* index the oldest have to be found by loading every row, and each one is an
|
|
153
|
+
* entire alignment. Failing to evict must not fail the save — the result is
|
|
154
|
+
* already in hand and losing it to a housekeeping error would be the worse
|
|
155
|
+
* outcome.
|
|
156
|
+
*/
|
|
157
|
+
async function evictOldest(db: IDBPDatabase<BlastCacheDB>) {
|
|
158
|
+
try {
|
|
159
|
+
if ((await db.count(STORE_NAME)) <= MAX_CACHED_RESULTS) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const all = await db.getAll(STORE_NAME)
|
|
163
|
+
const doomed = all
|
|
164
|
+
.toSorted((a, b) => a.timestamp - b.timestamp)
|
|
165
|
+
.slice(0, all.length - MAX_CACHED_RESULTS)
|
|
166
|
+
const tx = db.transaction(STORE_NAME, 'readwrite')
|
|
167
|
+
await Promise.all([...doomed.map(e => tx.store.delete(e.id)), tx.done])
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.warn('Failed to evict old BLAST cache entries:', e)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
140
173
|
export async function getAllCachedResults() {
|
|
141
174
|
const db = await getDB()
|
|
142
175
|
const results = await db.getAll(STORE_NAME)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { expect, test } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
alignInBrowser,
|
|
5
|
+
alignToQuery,
|
|
6
|
+
mergeOnQuery,
|
|
7
|
+
parseFastaRecords,
|
|
8
|
+
} from './browserAlign'
|
|
9
|
+
|
|
10
|
+
test('an identical target sits residue for residue under the query', () => {
|
|
11
|
+
const { matched, inserts } = alignToQuery('MKWVTFISLL', 'MKWVTFISLL')
|
|
12
|
+
expect(matched.join('')).toBe('MKWVTFISLL')
|
|
13
|
+
expect(inserts.every(i => i === '')).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('a deletion in the target is a gap under the query', () => {
|
|
17
|
+
const { matched, inserts } = alignToQuery(
|
|
18
|
+
'MKWVTFISLLFLFSSAYS',
|
|
19
|
+
'MKWVTLLFLFSSAYS',
|
|
20
|
+
)
|
|
21
|
+
expect(matched.join('').replaceAll('-', '')).toBe('MKWVTLLFLFSSAYS')
|
|
22
|
+
expect(matched.filter(c => c === '-')).toHaveLength(3)
|
|
23
|
+
expect(inserts.every(i => i === '')).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('an insertion in the target lands between two query residues', () => {
|
|
27
|
+
const query = 'MKWVTFISLLFLFSSAYS'
|
|
28
|
+
const target = 'MKWVTFISLLGGGGFLFSSAYS'
|
|
29
|
+
const { matched, inserts } = alignToQuery(query, target)
|
|
30
|
+
expect(matched.join('')).toBe(query)
|
|
31
|
+
expect(inserts.join('|')).toBe('|'.repeat(10) + 'GGGG' + '|'.repeat(8))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('end gaps are free, so a domain-only target is not dragged across the flanks', () => {
|
|
35
|
+
const query = 'AAAAAAAAAAMKWVTFISLLFLFSSAYSAAAAAAAAAA'
|
|
36
|
+
const target = 'MKWVTFISLLFLFSSAYS'
|
|
37
|
+
const { matched, inserts } = alignToQuery(query, target)
|
|
38
|
+
expect(matched.join('')).toBe('----------MKWVTFISLLFLFSSAYS----------')
|
|
39
|
+
expect(inserts.every(i => i === '')).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('a target that overhangs the query puts the overhang in the end inserts', () => {
|
|
43
|
+
const query = 'MKWVTFISLLFLFSSAYS'
|
|
44
|
+
const target = 'PPPPMKWVTFISLLFLFSSAYSQQQQ'
|
|
45
|
+
const { matched, inserts } = alignToQuery(query, target)
|
|
46
|
+
expect(matched.join('')).toBe(query)
|
|
47
|
+
expect(inserts[0]).toBe('PPPP')
|
|
48
|
+
expect(inserts.at(-1)).toBe('QQQQ')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('merging pads every row to the longest insert at each query position', () => {
|
|
52
|
+
const query = { name: 'q', sequence: 'MKWVTF' }
|
|
53
|
+
const rows = mergeOnQuery(query, [
|
|
54
|
+
{
|
|
55
|
+
name: 'a',
|
|
56
|
+
alignment: alignToQuery(query.sequence, 'MKWGGVTF'),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'b',
|
|
60
|
+
alignment: alignToQuery(query.sequence, 'MKWGVTF'),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'c',
|
|
64
|
+
alignment: alignToQuery(query.sequence, 'MKWVTF'),
|
|
65
|
+
},
|
|
66
|
+
])
|
|
67
|
+
expect(rows.map(r => r.sequence)).toEqual([
|
|
68
|
+
'MKW--VTF',
|
|
69
|
+
'MKWGGVTF',
|
|
70
|
+
'MKWG-VTF',
|
|
71
|
+
'MKW--VTF',
|
|
72
|
+
])
|
|
73
|
+
expect(new Set(rows.map(r => r.sequence.length)).size).toBe(1)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('alignInBrowser returns FASTA with the query first and every row the same length', async () => {
|
|
77
|
+
const progress: string[] = []
|
|
78
|
+
const fasta = await alignInBrowser({
|
|
79
|
+
query: { name: 'QUERY', sequence: 'MEEPQSDPSVEPPLSQETFSDLWKLLPENNV' },
|
|
80
|
+
targets: [
|
|
81
|
+
{ name: 'a', sequence: 'MEEPQSDPSVEPPLSQETFSDLWKLLPENNV' },
|
|
82
|
+
{ name: 'b', sequence: 'MEPQSDPSVEPPLSQETFSDLW' },
|
|
83
|
+
{ name: 'c', sequence: 'SQETFSDLWKLLPENNVXXXXX' },
|
|
84
|
+
],
|
|
85
|
+
onProgress: s => progress.push(s),
|
|
86
|
+
})
|
|
87
|
+
const records = parseFastaRecords(fasta)
|
|
88
|
+
expect(records.map(r => r.name)).toEqual(['QUERY', 'a', 'b', 'c'])
|
|
89
|
+
const lengths = fasta
|
|
90
|
+
.split('\n')
|
|
91
|
+
.filter(l => !l.startsWith('>'))
|
|
92
|
+
.map(l => l.length)
|
|
93
|
+
expect(new Set(lengths).size).toBe(1)
|
|
94
|
+
expect(progress.length).toBeGreaterThan(0)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('parseFastaRecords strips gaps and stops, and keeps the first header token', () => {
|
|
98
|
+
expect(parseFastaRecords('>a desc\nMK-W*\nVT\n>b\nMM')).toEqual([
|
|
99
|
+
{ name: 'a', sequence: 'MKWVT' },
|
|
100
|
+
{ name: 'b', sequence: 'MM' },
|
|
101
|
+
])
|
|
102
|
+
})
|