jbrowse-plugin-msaview 2.8.2 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastRIDPanel.d.ts → BlastQuery/BlastAutomaticPanel.d.ts} +2 -3
  2. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastAutomaticPanel.js → BlastQuery/BlastAutomaticPanel.js} +6 -25
  3. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.d.ts → BlastQuery/BlastManualPanel.d.ts} +2 -3
  4. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.js → BlastQuery/BlastManualPanel.js} +7 -4
  5. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.d.ts → BlastQuery/BlastMethodSelector.d.ts} +2 -2
  6. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.js → BlastQuery/BlastMethodSelector.js} +1 -2
  7. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.d.ts → BlastQuery/BlastPanel.d.ts} +3 -12
  8. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.js → BlastQuery/BlastPanel.js} +14 -16
  9. package/dist/LaunchMsaView/components/BlastQuery/BlastSettingsDialog.d.ts +8 -0
  10. package/dist/LaunchMsaView/components/{NCBIBlastQuery/NCBISettingsDialog.js → BlastQuery/BlastSettingsDialog.js} +13 -9
  11. package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.js +1 -1
  12. package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +12 -0
  13. package/dist/LaunchMsaView/components/BlastQuery/consts.js +17 -0
  14. package/dist/LaunchMsaView/components/LaunchMsaViewDialog.js +3 -3
  15. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +15 -35
  16. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.d.ts +15 -0
  17. package/dist/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.js +67 -0
  18. package/dist/LaunchMsaView/index.js +19 -1
  19. package/dist/MsaViewPanel/components/JobLink.d.ts +5 -0
  20. package/dist/MsaViewPanel/components/JobLink.js +13 -0
  21. package/dist/MsaViewPanel/components/LoadingBLAST.d.ts +1 -2
  22. package/dist/MsaViewPanel/components/LoadingBLAST.js +8 -15
  23. package/dist/MsaViewPanel/components/MsaViewPanel.js +1 -1
  24. package/dist/MsaViewPanel/doLaunchBlast.js +11 -19
  25. package/dist/MsaViewPanel/doLaunchOrthologs.js +43 -12
  26. package/dist/MsaViewPanel/doLaunchOrthologs.test.d.ts +1 -0
  27. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +201 -0
  28. package/dist/MsaViewPanel/model.d.ts +20 -12
  29. package/dist/jbrowse-plugin-msaview.umd.production.min.js +31 -33
  30. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  31. package/dist/utils/blastCache.d.ts +8 -4
  32. package/dist/utils/blastCache.js +4 -5
  33. package/dist/utils/ebiBlast.d.ts +52 -0
  34. package/dist/utils/ebiBlast.js +63 -0
  35. package/dist/utils/ebiJobDispatcher.d.ts +33 -0
  36. package/dist/utils/ebiJobDispatcher.js +80 -0
  37. package/dist/utils/ebiJobDispatcher.test.d.ts +1 -0
  38. package/dist/utils/ebiJobDispatcher.test.js +46 -0
  39. package/dist/utils/eutils.d.ts +9 -0
  40. package/dist/utils/eutils.js +18 -0
  41. package/dist/utils/fetch.js +24 -1
  42. package/dist/utils/msa.d.ts +1 -1
  43. package/dist/utils/msa.js +25 -32
  44. package/dist/utils/ncbiOrthologs.d.ts +17 -75
  45. package/dist/utils/ncbiOrthologs.js +67 -63
  46. package/dist/utils/ncbiOrthologs.test.js +82 -2
  47. package/dist/utils/ncbiTaxonomy.d.ts +11 -0
  48. package/dist/utils/ncbiTaxonomy.js +33 -0
  49. package/dist/utils/types.d.ts +9 -14
  50. package/dist/utils/useLocalStorage.d.ts +1 -0
  51. package/dist/utils/useLocalStorage.js +1 -1
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +3 -3
  55. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastAutomaticPanel.tsx → BlastQuery/BlastAutomaticPanel.tsx} +13 -59
  56. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastManualPanel.tsx → BlastQuery/BlastManualPanel.tsx} +7 -6
  57. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastMethodSelector.tsx → BlastQuery/BlastMethodSelector.tsx} +2 -7
  58. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBIBlastPanel.tsx → BlastQuery/BlastPanel.tsx} +21 -25
  59. package/src/LaunchMsaView/components/{NCBIBlastQuery/NCBISettingsDialog.tsx → BlastQuery/BlastSettingsDialog.tsx} +26 -13
  60. package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.tsx +1 -1
  61. package/src/LaunchMsaView/components/BlastQuery/consts.ts +22 -0
  62. package/src/LaunchMsaView/components/LaunchMsaViewDialog.tsx +5 -7
  63. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +35 -73
  64. package/src/LaunchMsaView/components/OrthologQuery/QuerySpeciesSelect.tsx +92 -0
  65. package/src/LaunchMsaView/index.ts +20 -2
  66. package/src/MsaViewPanel/components/JobLink.tsx +17 -0
  67. package/src/MsaViewPanel/components/LoadingBLAST.tsx +10 -41
  68. package/src/MsaViewPanel/components/MsaViewPanel.tsx +1 -1
  69. package/src/MsaViewPanel/doLaunchBlast.ts +12 -28
  70. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +247 -0
  71. package/src/MsaViewPanel/doLaunchOrthologs.ts +56 -13
  72. package/src/MsaViewPanel/model.ts +10 -7
  73. package/src/utils/blastCache.ts +8 -9
  74. package/src/utils/ebiBlast.ts +114 -0
  75. package/src/utils/ebiJobDispatcher.test.ts +54 -0
  76. package/src/utils/ebiJobDispatcher.ts +120 -0
  77. package/src/utils/eutils.ts +19 -0
  78. package/src/utils/fetch.ts +26 -1
  79. package/src/utils/msa.ts +26 -47
  80. package/src/utils/ncbiOrthologs.test.ts +96 -2
  81. package/src/utils/ncbiOrthologs.ts +83 -71
  82. package/src/utils/ncbiTaxonomy.ts +37 -0
  83. package/src/utils/types.ts +8 -13
  84. package/src/utils/useLocalStorage.ts +1 -1
  85. package/src/version.ts +1 -1
  86. package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastAutomaticPanel.d.ts +0 -10
  87. package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastRIDPanel.js +0 -74
  88. package/dist/LaunchMsaView/components/NCBIBlastQuery/NCBISettingsDialog.d.ts +0 -5
  89. package/dist/LaunchMsaView/components/NCBIBlastQuery/consts.d.ts +0 -7
  90. package/dist/LaunchMsaView/components/NCBIBlastQuery/consts.js +0 -4
  91. package/dist/MsaViewPanel/components/RIDLink.d.ts +0 -6
  92. package/dist/MsaViewPanel/components/RIDLink.js +0 -12
  93. package/dist/utils/ncbiBlast.d.ts +0 -30
  94. package/dist/utils/ncbiBlast.js +0 -84
  95. package/src/LaunchMsaView/components/NCBIBlastQuery/NCBIBlastRIDPanel.tsx +0 -137
  96. package/src/LaunchMsaView/components/NCBIBlastQuery/consts.ts +0 -10
  97. package/src/MsaViewPanel/components/RIDLink.tsx +0 -19
  98. package/src/utils/ncbiBlast.ts +0 -143
  99. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/CachedBlastResults.d.ts +0 -0
  100. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.d.ts +0 -0
  101. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.js +0 -0
  102. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.d.ts +0 -0
  103. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.js +0 -0
  104. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/useCachedBlastResults.d.ts +0 -0
  105. /package/dist/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/useCachedBlastResults.js +0 -0
  106. /package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/MsaAlgorithmSelect.tsx +0 -0
  107. /package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/blastLaunchView.ts +0 -0
  108. /package/src/LaunchMsaView/components/{NCBIBlastQuery → BlastQuery}/useCachedBlastResults.ts +0 -0
@@ -1,8 +1,8 @@
1
1
  import { makeId, strip } from '../LaunchMsaView/components/util'
2
2
  import { cleanProteinSequence } from '../LaunchMsaView/util'
3
3
  import { saveBlastResult } from '../utils/blastCache'
4
+ import { queryEbiBlast } from '../utils/ebiBlast'
4
5
  import { launchMSA } from '../utils/msa'
5
- import { queryBlast, queryBlastFromRid } from '../utils/ncbiBlast'
6
6
  import { fetchTaxonomyInfo } from '../utils/taxonomyNames'
7
7
 
8
8
  import type { JBrowsePluginMsaViewModel } from './model'
@@ -14,39 +14,24 @@ export async function doLaunchBlast({
14
14
  }: {
15
15
  self: JBrowsePluginMsaViewModel
16
16
  }) {
17
- const {
18
- baseUrl,
19
- blastDatabase,
20
- blastProgram,
21
- msaAlgorithm,
22
- proteinSequence,
23
- selectedTranscript,
24
- rid: existingRid,
25
- } = self.blastParams!
17
+ const { blastDatabase, msaAlgorithm, proteinSequence, selectedTranscript } =
18
+ self.blastParams!
26
19
  const cleanedSeq = cleanProteinSequence(proteinSequence)
27
20
 
28
21
  const onProgress = (arg: string) => {
29
22
  self.setProgress(arg)
30
23
  }
31
24
 
32
- if (existingRid) {
33
- // publish it before the first poll so the view can link out to NCBI while
25
+ const { hits, rid } = await queryEbiBlast({
26
+ query: cleanedSeq,
27
+ blastDatabase,
28
+ onProgress,
29
+ // publish the job id before the first poll so the view can link out while
34
30
  // the job is still running
35
- self.setRid(existingRid)
36
- }
37
-
38
- const { hits, rid } = existingRid
39
- ? await queryBlastFromRid({ rid: existingRid, baseUrl, onProgress })
40
- : await queryBlast({
41
- query: cleanedSeq,
42
- blastDatabase,
43
- blastProgram,
44
- baseUrl,
45
- onProgress,
46
- onRid: r => {
47
- self.setRid(r)
48
- },
49
- })
31
+ onRid: r => {
32
+ self.setRid(r)
33
+ },
34
+ })
50
35
 
51
36
  self.setProgress('Fetching species taxonomy info...')
52
37
  const taxids = hits
@@ -81,7 +66,6 @@ export async function doLaunchBlast({
81
66
  await saveBlastResult({
82
67
  proteinSequence: cleanedSeq,
83
68
  blastDatabase,
84
- blastProgram,
85
69
  msaAlgorithm,
86
70
  msa: result.msa,
87
71
  tree: result.tree,
@@ -0,0 +1,247 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ import { doLaunchOrthologs } from './doLaunchOrthologs'
4
+ import { launchMSA } from '../utils/msa'
5
+ import {
6
+ defaultMaxSpecies,
7
+ fetchOrthologRows,
8
+ fetchProteinForGene,
9
+ resolveGeneId,
10
+ } from '../utils/ncbiOrthologs'
11
+ import { fetchTaxonomyInfo } from '../utils/taxonomyNames'
12
+
13
+ import type { JBrowsePluginMsaViewModel } from './model'
14
+ import type { OrthologRow } from '../utils/ncbiOrthologs'
15
+
16
+ // Every network call is mocked and nothing else is. What is under test is the
17
+ // argument shaping either side of those calls -- which species get asked for,
18
+ // what becomes the QUERY row, and whether the row earns the Accession that
19
+ // drives the CDD overlay -- so the real cleanProteinSequence stays in the
20
+ // picture.
21
+ vi.mock('../utils/ncbiOrthologs', async importOriginal => ({
22
+ ...(await importOriginal<Record<string, unknown>>()),
23
+ resolveGeneId: vi.fn(),
24
+ fetchProteinForGene: vi.fn(),
25
+ fetchOrthologRows: vi.fn(),
26
+ }))
27
+ vi.mock('../utils/msa', () => ({ launchMSA: vi.fn() }))
28
+ vi.mock('../utils/taxonomyNames', () => ({ fetchTaxonomyInfo: vi.fn() }))
29
+
30
+ const mockResolveGeneId = vi.mocked(resolveGeneId)
31
+ const mockFetchProtein = vi.mocked(fetchProteinForGene)
32
+ const mockFetchRows = vi.mocked(fetchOrthologRows)
33
+ const mockLaunchMSA = vi.mocked(launchMSA)
34
+ const mockFetchTaxonomy = vi.mocked(fetchTaxonomyInfo)
35
+
36
+ const HUMAN = 9606
37
+ const GENE_ID = '22861'
38
+ const REPRESENTATIVE = { accession: 'NP_127497.1', sequence: 'MAGGAWGRLACY' }
39
+
40
+ const setQuerySeqName = vi.fn()
41
+
42
+ function makeModel(orthologParams: Record<string, unknown>) {
43
+ return {
44
+ orthologParams,
45
+ setProgress: () => {},
46
+ setQuerySeqName,
47
+ } as unknown as JBrowsePluginMsaViewModel
48
+ }
49
+
50
+ function params(extra: Record<string, unknown> = {}) {
51
+ return {
52
+ taxId: HUMAN,
53
+ geneCandidates: ['NLRP1'],
54
+ msaAlgorithm: 'clustalo',
55
+ ...extra,
56
+ }
57
+ }
58
+
59
+ // What fetchOrthologRows was asked for, which is the only place the species
60
+ // defaults are observable.
61
+ function rowRequest() {
62
+ const { taxa, exclude, limit } = mockFetchRows.mock.calls[0]![0]
63
+ return {
64
+ taxa: taxa && [...taxa].sort((a, b) => a - b),
65
+ exclude,
66
+ limit,
67
+ }
68
+ }
69
+
70
+ // The QUERY row as it went to the aligner, read back out of the FASTA rather
71
+ // than out of an intermediate, since the FASTA is what the alignment is of.
72
+ function queryRowSent() {
73
+ return mockLaunchMSA.mock.calls[0]![0].sequence.split('\n')[1]
74
+ }
75
+
76
+ // Keyed by the row's own label, which has to be the name the FASTA header
77
+ // carries -- the tree comes back from the aligner naming its leaves that way,
78
+ // and the metadata is paired to a leaf by name.
79
+ function queryMetadata(result: { treeMetadata: string }) {
80
+ return JSON.parse(result.treeMetadata)[queryRowName()] as Record<
81
+ string,
82
+ string
83
+ >
84
+ }
85
+
86
+ function queryRowName() {
87
+ return mockLaunchMSA.mock.calls[0]![0].sequence.split('\n')[0]!.slice(1)
88
+ }
89
+
90
+ beforeEach(() => {
91
+ vi.clearAllMocks()
92
+ mockResolveGeneId.mockResolvedValue({ geneId: GENE_ID, matched: 'NLRP1' })
93
+ mockFetchProtein.mockResolvedValue(REPRESENTATIVE)
94
+ mockFetchRows.mockResolvedValue([] as OrthologRow[])
95
+ mockLaunchMSA.mockResolvedValue({ msa: '', tree: '' })
96
+ mockFetchTaxonomy.mockResolvedValue(
97
+ new Map([[HUMAN, { sciname: 'Homo sapiens', commonName: 'human' }]]),
98
+ )
99
+ })
100
+
101
+ describe('which species become rows', () => {
102
+ test('omitted taxa asks for no restriction at all, which is every ortholog NCBI has', async () => {
103
+ await doLaunchOrthologs({ self: makeModel(params()) })
104
+ expect(rowRequest().taxa).toBeUndefined()
105
+ })
106
+
107
+ test('given taxa is taken as written', async () => {
108
+ await doLaunchOrthologs({
109
+ self: makeModel(params({ taxa: [HUMAN, 10090, 9615] })),
110
+ })
111
+ expect(rowRequest().taxa).toEqual([9606, 9615, 10090])
112
+ })
113
+
114
+ // Not folded into the taxa list before the call, so that "restrict to these"
115
+ // and "the query row already covers this one" stay separable -- an unrestricted
116
+ // launch still has to drop the query species.
117
+ test('the query species is excluded whether or not taxa was given', async () => {
118
+ await doLaunchOrthologs({ self: makeModel(params()) })
119
+ expect(rowRequest().exclude).toBe(HUMAN)
120
+ vi.clearAllMocks()
121
+ mockResolveGeneId.mockResolvedValue({ geneId: GENE_ID, matched: 'NLRP1' })
122
+ mockFetchProtein.mockResolvedValue(REPRESENTATIVE)
123
+ mockFetchRows.mockResolvedValue([] as OrthologRow[])
124
+ mockLaunchMSA.mockResolvedValue({ msa: '', tree: '' })
125
+ await doLaunchOrthologs({
126
+ self: makeModel(params({ taxa: [HUMAN, 10090] })),
127
+ })
128
+ expect(rowRequest().exclude).toBe(HUMAN)
129
+ })
130
+
131
+ test('an empty list is a request for no rows, not a request for all of them', async () => {
132
+ await doLaunchOrthologs({ self: makeModel(params({ taxa: [] })) })
133
+ expect(rowRequest().taxa).toEqual([])
134
+ })
135
+ })
136
+
137
+ // The cap is the only thing standing between a launch and a 7 minute EBI job:
138
+ // NCBI publishes 865 orthologs for CFTR and the aligner runs at roughly half a
139
+ // second a row.
140
+ describe('the row cap', () => {
141
+ test('is passed through when given', async () => {
142
+ await doLaunchOrthologs({ self: makeModel(params({ maxSpecies: 12 })) })
143
+ expect(rowRequest().limit).toBe(12)
144
+ })
145
+
146
+ test('omitted leaves the default to fetchOrthologGenes rather than sending Infinity', async () => {
147
+ await doLaunchOrthologs({ self: makeModel(params()) })
148
+ expect(rowRequest().limit).toBeUndefined()
149
+ expect(defaultMaxSpecies).toBeGreaterThan(2)
150
+ })
151
+ })
152
+
153
+ // The row's name is load bearing three times over: it is the FASTA header, it
154
+ // is therefore the tree leaf the aligner returns, and it is what
155
+ // `seqPosToVisibleCol` looks up to turn a genome hover into a column. So the
156
+ // model's querySeqName and the header have to be the same string.
157
+ describe('the query row name', () => {
158
+ test('is the species, marked, rather than a bare QUERY among named rows', async () => {
159
+ await doLaunchOrthologs({ self: makeModel(params()) })
160
+ expect(queryRowName()).toBe('human_query')
161
+ expect(setQuerySeqName).toHaveBeenCalledWith('human_query')
162
+ })
163
+
164
+ test('cannot collide with an ortholog row that sanitizes to the same token', async () => {
165
+ mockFetchRows.mockResolvedValue([
166
+ { label: 'human_query', sequence: 'MM' },
167
+ ] as OrthologRow[])
168
+ await doLaunchOrthologs({ self: makeModel(params()) })
169
+ expect(queryRowName()).toBe('human_query_2')
170
+ expect(setQuerySeqName).toHaveBeenCalledWith('human_query_2')
171
+ })
172
+
173
+ test('falls back rather than throwing when NCBI cannot name the taxon', async () => {
174
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
175
+ mockFetchTaxonomy.mockRejectedValue(new Error('429'))
176
+ await doLaunchOrthologs({ self: makeModel(params()) })
177
+ expect(queryRowName()).toBe('query_query')
178
+ })
179
+
180
+ test('the metadata that drives the domain overlay is keyed to that same name', async () => {
181
+ const result = await doLaunchOrthologs({ self: makeModel(params()) })
182
+ expect(Object.keys(JSON.parse(result.treeMetadata))).toContain(
183
+ 'human_query',
184
+ )
185
+ })
186
+ })
187
+
188
+ describe('the query row sequence', () => {
189
+ test('omitted proteinSequence falls back to the representative protein', async () => {
190
+ await doLaunchOrthologs({ self: makeModel(params()) })
191
+ expect(queryRowSent()).toBe(REPRESENTATIVE.sequence)
192
+ })
193
+
194
+ test('a supplied sequence is used, and is cleaned first', async () => {
195
+ await doLaunchOrthologs({
196
+ self: makeModel(params({ proteinSequence: 'MAGG*AWGR&' })),
197
+ })
198
+ expect(queryRowSent()).toBe('MAGGAWGR')
199
+ })
200
+
201
+ test('throws when neither a sequence nor a representative is available', async () => {
202
+ mockFetchProtein.mockResolvedValue(undefined)
203
+ await expect(
204
+ doLaunchOrthologs({ self: makeModel(params()) }),
205
+ ).rejects.toThrow(/No query protein/)
206
+ expect(mockLaunchMSA).not.toHaveBeenCalled()
207
+ })
208
+
209
+ test('a failed representative lookup does not take down a launch that brought its own sequence', async () => {
210
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
211
+ mockFetchProtein.mockRejectedValue(new Error('429'))
212
+ await doLaunchOrthologs({
213
+ self: makeModel(params({ proteinSequence: REPRESENTATIVE.sequence })),
214
+ })
215
+ expect(queryRowSent()).toBe(REPRESENTATIVE.sequence)
216
+ })
217
+ })
218
+
219
+ // The Accession is what afterCreateAutoruns.autoLoadProteinDomains keys the CDD
220
+ // overlay off, and attaching it to a row that is NOT the protein it names draws
221
+ // every domain box at an offset. So the byte-identity guard is the assertion
222
+ // here, in both directions.
223
+ describe('the Accession that drives the domain overlay', () => {
224
+ test('is attached when the query row IS the representative protein', async () => {
225
+ const result = await doLaunchOrthologs({ self: makeModel(params()) })
226
+ expect(queryMetadata(result)).toMatchObject({
227
+ 'Gene ID': GENE_ID,
228
+ Accession: REPRESENTATIVE.accession,
229
+ })
230
+ })
231
+
232
+ test('is withheld from a non-representative isoform', async () => {
233
+ const result = await doLaunchOrthologs({
234
+ self: makeModel(params({ proteinSequence: 'MDIFFERENTISOFORM' })),
235
+ })
236
+ expect(queryMetadata(result).Accession).toBeUndefined()
237
+ })
238
+
239
+ test('is withheld when the representative lookup failed', async () => {
240
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
241
+ mockFetchProtein.mockRejectedValue(new Error('429'))
242
+ const result = await doLaunchOrthologs({
243
+ self: makeModel(params({ proteinSequence: REPRESENTATIVE.sequence })),
244
+ })
245
+ expect(queryMetadata(result).Accession).toBeUndefined()
246
+ })
247
+ })
@@ -1,11 +1,12 @@
1
1
  import { cleanProteinSequence } from '../LaunchMsaView/util'
2
2
  import { launchMSA } from '../utils/msa'
3
3
  import {
4
- COMMON_SPECIES,
4
+ dedupeLabels,
5
5
  fetchOrthologRows,
6
6
  fetchProteinForGene,
7
7
  resolveGeneId,
8
8
  } from '../utils/ncbiOrthologs'
9
+ import { fetchTaxonomyInfo } from '../utils/taxonomyNames'
9
10
 
10
11
  import type { JBrowsePluginMsaViewModel } from './model'
11
12
  import type { OrthologRow } from '../utils/ncbiOrthologs'
@@ -30,8 +31,14 @@ export async function doLaunchOrthologs({
30
31
  }: {
31
32
  self: JBrowsePluginMsaViewModel
32
33
  }) {
33
- const { taxId, taxa, geneCandidates, msaAlgorithm, proteinSequence } =
34
- self.orthologParams!
34
+ const {
35
+ taxId,
36
+ taxa,
37
+ maxSpecies,
38
+ geneCandidates,
39
+ msaAlgorithm,
40
+ proteinSequence,
41
+ } = self.orthologParams!
35
42
 
36
43
  const onProgress = (arg: string) => {
37
44
  self.setProgress(arg)
@@ -61,21 +68,40 @@ export async function doLaunchOrthologs({
61
68
  )
62
69
  }
63
70
 
64
- // Every species the panel offers, when a launch names none. A spec that wants
65
- // a narrower comparison says so; one that just wants "this gene across
66
- // species" should not have to enumerate the list the dialog would have
67
- // checked for it.
68
- const wantedTaxa = taxa ?? COMMON_SPECIES.map(s => s.taxId as number)
69
- // the query species is represented by the query row above
70
- const wanted = new Set(wantedTaxa.filter(t => t !== taxId))
71
+ // Every species NCBI has an ortholog for, when a launch names none, capped at
72
+ // maxSpecies. A launch that wants specific species lists them; one that just
73
+ // wants "this gene across species" gets NCBI's own order, which leads with the
74
+ // reference organisms.
71
75
  const rows = await fetchOrthologRows({
72
76
  geneId: resolved.geneId,
73
- taxa: wanted,
77
+ taxa: taxa ? new Set(taxa) : undefined,
78
+ // the query species is represented by the query row above
79
+ exclude: taxId,
80
+ limit: maxSpecies,
74
81
  onProgress,
75
82
  })
76
83
 
84
+ // The query row is named for its species like every other row, with a suffix
85
+ // marking it as the one the genome view is linked to. A bare `QUERY` among
86
+ // ninety-nine named species reads as a row whose species failed to resolve,
87
+ // and there is nothing else in the picture saying which row the hover
88
+ // highlight travels through -- react-msaview has no notion of a query row, it
89
+ // only looks one up by name.
90
+ //
91
+ // Deduped against the ortholog labels rather than assumed unique: the query
92
+ // taxon is excluded from that set, but a subspecies can sanitize to the same
93
+ // token, and a collision would silently point the coordinate mapping at
94
+ // another animal's row.
95
+ const queryLabel = await queryRowLabel(taxId, rows)
96
+ self.setQuerySeqName(queryLabel)
97
+
77
98
  const treeMetadata: Record<string, Record<string, string>> = {
78
- QUERY: buildQueryMetadata(self, resolved.geneId, cleanedSeq, representative),
99
+ [queryLabel]: buildQueryMetadata(
100
+ self,
101
+ resolved.geneId,
102
+ cleanedSeq,
103
+ representative,
104
+ ),
79
105
  }
80
106
  for (const row of rows) {
81
107
  treeMetadata[row.label] = buildRowMetadata(row)
@@ -84,7 +110,7 @@ export async function doLaunchOrthologs({
84
110
  const result = await launchMSA({
85
111
  algorithm: msaAlgorithm,
86
112
  sequence: [
87
- `>QUERY\n${cleanedSeq}`,
113
+ `>${queryLabel}\n${cleanedSeq}`,
88
114
  ...rows.map(r => `>${r.label}\n${r.sequence}`),
89
115
  ].join('\n'),
90
116
  onProgress,
@@ -96,6 +122,23 @@ export async function doLaunchOrthologs({
96
122
  }
97
123
  }
98
124
 
125
+ /**
126
+ * `<species>_query`, unique against the ortholog labels. Falls back to the bare
127
+ * marker when NCBI cannot name the taxon, which is a naming failure and must not
128
+ * take down the launch.
129
+ */
130
+ async function queryRowLabel(taxId: number, rows: OrthologRow[]) {
131
+ let name: string | undefined
132
+ try {
133
+ const info = (await fetchTaxonomyInfo([taxId])).get(taxId)
134
+ name = info?.commonName ?? info?.sciname
135
+ } catch (e) {
136
+ console.warn('[msaview-orthologs] taxonomy name lookup failed:', e)
137
+ }
138
+ return dedupeLabels([...rows.map(r => r.label), `${name ?? 'query'}_query`])
139
+ .at(-1)!
140
+ }
141
+
99
142
  /**
100
143
  * A failed lookup only costs the query row its domain overlay and, for a launch
101
144
  * that supplied no sequence of its own, the alignment — so it is reported by
@@ -28,9 +28,8 @@ import {
28
28
  import type { MafRegion, MsaViewInitState } from './types'
29
29
  import type {
30
30
  BlastDatabase,
31
- BlastProgram,
32
31
  MsaAlgorithm,
33
- } from '../LaunchMsaView/components/NCBIBlastQuery/consts'
32
+ } from '../LaunchMsaView/components/BlastQuery/consts'
34
33
  import type { Feature } from '@jbrowse/core/util'
35
34
  import type { Instance } from '@jbrowse/mobx-state-tree'
36
35
  import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'
@@ -46,13 +45,10 @@ export interface IRegion {
46
45
  }
47
46
 
48
47
  export interface BlastParams {
49
- baseUrl: string
50
48
  blastDatabase: BlastDatabase
51
49
  msaAlgorithm: MsaAlgorithm
52
- blastProgram: BlastProgram
53
50
  selectedTranscript?: Feature
54
51
  proteinSequence: string
55
- rid?: string
56
52
  }
57
53
 
58
54
  export interface OrthologParams {
@@ -60,10 +56,17 @@ export interface OrthologParams {
60
56
  taxId: number
61
57
  /**
62
58
  * taxon ids to include as rows (the query taxon is represented by QUERY).
63
- * Omitted means every species the launch dialog offers, which is what a
64
- * launch that just wants "this gene across species" wants.
59
+ * Omitted means every species NCBI has an ortholog for, in its report order,
60
+ * which is what a launch that just wants "this gene across species" wants.
65
61
  */
66
62
  taxa?: number[]
63
+ /**
64
+ * how many ortholog rows to align, `defaultMaxSpecies` when omitted. The
65
+ * aligner is what this bounds: EBI runs at roughly half a second per row for
66
+ * a ~1400aa protein, so a gene with 865 orthologs is a 7 minute job at no
67
+ * cap.
68
+ */
69
+ maxSpecies?: number
67
70
  /** candidate gene identifiers off the feature, tried in order */
68
71
  geneCandidates: string[]
69
72
  msaAlgorithm: MsaAlgorithm
@@ -2,9 +2,8 @@ import { createDbOpener } from './idb'
2
2
 
3
3
  import type {
4
4
  BlastDatabase,
5
- BlastProgram,
6
5
  MsaAlgorithm,
7
- } from '../LaunchMsaView/components/NCBIBlastQuery/consts'
6
+ } from '../LaunchMsaView/components/BlastQuery/consts'
8
7
  import type { DBSchema } from 'idb'
9
8
 
10
9
  const DB_NAME = 'jbrowse-msaview-blast-cache'
@@ -15,7 +14,12 @@ export interface CachedBlastResult {
15
14
  id: string
16
15
  proteinSequence: string
17
16
  blastDatabase: BlastDatabase
18
- blastProgram: BlastProgram
17
+ /**
18
+ * Only ever set on rows cached by a version that still queried NCBI, where
19
+ * the choice between blastp and quick-blastp was real. Kept so those rows
20
+ * still display; never written now.
21
+ */
22
+ blastProgram?: string
19
23
  msaAlgorithm: MsaAlgorithm
20
24
  msa: string
21
25
  tree: string
@@ -51,7 +55,6 @@ const getDB = createDbOpener<BlastCacheDB>(
51
55
  function createCacheKey(
52
56
  proteinSequence: string,
53
57
  blastDatabase: BlastDatabase,
54
- blastProgram: BlastProgram,
55
58
  msaAlgorithm: MsaAlgorithm,
56
59
  transcriptId?: string,
57
60
  ) {
@@ -59,13 +62,12 @@ function createCacheKey(
59
62
  // msaAlgorithm is part of the key because the stored msa/tree are produced by
60
63
  // it — without it, re-running the same query under a different algorithm
61
64
  // overwrites the earlier result and drops it from the history list
62
- return `${blastDatabase}:${blastProgram}:${msaAlgorithm}${idPart}:${proteinSequence}`
65
+ return `${blastDatabase}:${msaAlgorithm}${idPart}:${proteinSequence}`
63
66
  }
64
67
 
65
68
  export async function saveBlastResult({
66
69
  proteinSequence,
67
70
  blastDatabase,
68
- blastProgram,
69
71
  msaAlgorithm,
70
72
  msa,
71
73
  tree,
@@ -78,7 +80,6 @@ export async function saveBlastResult({
78
80
  }: {
79
81
  proteinSequence: string
80
82
  blastDatabase: BlastDatabase
81
- blastProgram: BlastProgram
82
83
  msaAlgorithm: MsaAlgorithm
83
84
  msa: string
84
85
  tree: string
@@ -93,7 +94,6 @@ export async function saveBlastResult({
93
94
  const id = createCacheKey(
94
95
  proteinSequence,
95
96
  blastDatabase,
96
- blastProgram,
97
97
  msaAlgorithm,
98
98
  transcriptId,
99
99
  )
@@ -101,7 +101,6 @@ export async function saveBlastResult({
101
101
  id,
102
102
  proteinSequence,
103
103
  blastDatabase,
104
- blastProgram,
105
104
  msaAlgorithm,
106
105
  msa,
107
106
  tree,
@@ -0,0 +1,114 @@
1
+ import { fetchEbiResult, submitEbiJob, waitForEbiJob } from './ebiJobDispatcher'
2
+
3
+ import type { BlastHit } from './types'
4
+ import type { BlastDatabase } from '../LaunchMsaView/components/BlastQuery/consts'
5
+
6
+ const TOOL = 'ncbiblast'
7
+
8
+ /**
9
+ * The subset of EBI's ncbiblast JSON result this plugin reads. The service
10
+ * returns a great deal more per hit (urls, bit scores, e-values, the match
11
+ * string); only what the MSA rows are built from is typed here.
12
+ */
13
+ interface EbiBlastJson {
14
+ hits?: {
15
+ hit_acc?: string
16
+ hit_id?: string
17
+ hit_desc?: string
18
+ /** the UniProt fields are absent on hits from non-UniProt databases */
19
+ hit_os?: string
20
+ hit_uni_de?: string
21
+ hit_uni_os?: string
22
+ /** NCBI taxon id, delivered as a string */
23
+ hit_uni_ox?: string
24
+ hit_hsps?: { hsp_hseq?: string }[]
25
+ }[]
26
+ }
27
+
28
+ /**
29
+ * Map EBI's hit shape onto the normalized one. Exported for testing against a
30
+ * captured response — the field names are the whole risk here, and nothing else
31
+ * in CI would notice if EBI renamed one.
32
+ */
33
+ export function normalizeEbiBlastHits(result: EbiBlastJson): BlastHit[] {
34
+ return (result.hits ?? []).map(hit => {
35
+ const taxid = Number.parseInt(hit.hit_uni_ox ?? '', 10)
36
+ return {
37
+ description: [
38
+ {
39
+ accession: hit.hit_acc ?? 'unknown',
40
+ id: hit.hit_id ?? hit.hit_acc ?? 'unknown',
41
+ sciname: hit.hit_uni_os ?? hit.hit_os ?? 'unknown',
42
+ taxid: Number.isNaN(taxid) ? undefined : taxid,
43
+ // hit_uni_de is the bare protein name; hit_desc repeats it with the
44
+ // OS=/OX=/GN= suffix that makeId already covers with real columns
45
+ title: hit.hit_uni_de ?? hit.hit_desc,
46
+ },
47
+ ],
48
+ hsps: (hit.hit_hsps ?? []).flatMap(hsp =>
49
+ hsp.hsp_hseq ? [{ hseq: hsp.hsp_hseq }] : [],
50
+ ),
51
+ }
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Human-facing link to a job, shown while it runs and on error — so it has to
57
+ * be EBI's own results UI, not the REST result endpoint, which does not exist
58
+ * yet at the moment the link is on screen.
59
+ */
60
+ export function ebiBlastResultUrl(jobId: string) {
61
+ return `https://www.ebi.ac.uk/jdispatcher/sss/${TOOL}/summary?jobId=${jobId}`
62
+ }
63
+
64
+ export async function queryEbiBlastFromJobId({
65
+ jobId,
66
+ onProgress,
67
+ }: {
68
+ jobId: string
69
+ onProgress: (arg: string) => void
70
+ }) {
71
+ onProgress(`Checking BLAST status for job: ${jobId}...`)
72
+ await waitForEbiJob({
73
+ tool: TOOL,
74
+ jobId,
75
+ onCountdown: s => {
76
+ onProgress(`Re-checking BLAST status in... ${s}`)
77
+ },
78
+ })
79
+
80
+ const hits = normalizeEbiBlastHits(
81
+ JSON.parse(
82
+ await fetchEbiResult({ tool: TOOL, jobId, type: 'json' }),
83
+ ) as EbiBlastJson,
84
+ )
85
+ if (hits.length === 0) {
86
+ throw new Error('No hits found')
87
+ }
88
+ return { rid: jobId, hits }
89
+ }
90
+
91
+ export async function queryEbiBlast({
92
+ query,
93
+ blastDatabase,
94
+ onProgress,
95
+ onRid,
96
+ }: {
97
+ query: string
98
+ blastDatabase: BlastDatabase
99
+ onProgress: (arg: string) => void
100
+ onRid: (arg: string) => void
101
+ }) {
102
+ onProgress('Submitting to EBI BLAST...')
103
+ const jobId = await submitEbiJob({
104
+ tool: TOOL,
105
+ params: {
106
+ program: 'blastp',
107
+ stype: 'protein',
108
+ database: blastDatabase,
109
+ sequence: query,
110
+ },
111
+ })
112
+ onRid(jobId)
113
+ return queryEbiBlastFromJobId({ jobId, onProgress })
114
+ }