jbrowse-plugin-msaview 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/AddHighlightModel/GenomeMouseoverHighlight.js +1 -1
  2. package/dist/AddHighlightModel/MsaToGenomeHighlight.js +1 -1
  3. package/dist/AddHighlightModel/index.js +1 -1
  4. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +61 -17
  5. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +1 -1
  6. package/dist/LaunchMsaView/components/BlastQuery/BlastPanel.js +2 -2
  7. package/dist/LaunchMsaView/components/BlastQuery/CachedBlastResults.d.ts +12 -0
  8. package/dist/LaunchMsaView/components/BlastQuery/CachedBlastResults.js +21 -2
  9. package/dist/LaunchMsaView/components/BlastQuery/CachedBlastResults.test.d.ts +1 -0
  10. package/dist/LaunchMsaView/components/BlastQuery/CachedBlastResults.test.js +29 -0
  11. package/dist/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.js +1 -1
  12. package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +28 -0
  13. package/dist/LaunchMsaView/components/BlastQuery/consts.js +21 -0
  14. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -1
  15. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +9 -4
  16. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.d.ts +9 -0
  17. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +20 -0
  18. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +1 -1
  19. package/dist/LaunchMsaView/components/useFeatureSequence.js +1 -1
  20. package/dist/LaunchMsaView/detectQueryRow.d.ts +15 -2
  21. package/dist/LaunchMsaView/detectQueryRow.js +20 -21
  22. package/dist/LaunchMsaView/detectQueryRow.test.js +15 -15
  23. package/dist/LaunchMsaView/useQueryRowName.js +5 -8
  24. package/dist/MsaViewPanel/afterCreateAutoruns.js +2 -2
  25. package/dist/MsaViewPanel/components/ErrorBoundary.d.ts +2 -2
  26. package/dist/MsaViewPanel/components/JobLink.js +7 -1
  27. package/dist/MsaViewPanel/components/LaunchProgress.d.ts +17 -0
  28. package/dist/MsaViewPanel/components/LaunchProgress.js +41 -0
  29. package/dist/MsaViewPanel/components/MsaViewPanel.js +6 -3
  30. package/dist/MsaViewPanel/components/MsaViewPanel.test.d.ts +1 -0
  31. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +68 -0
  32. package/dist/MsaViewPanel/doLaunchBlast.d.ts +1 -1
  33. package/dist/MsaViewPanel/doLaunchBlast.js +84 -52
  34. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +7 -5
  35. package/dist/MsaViewPanel/doLaunchOrthologs.js +64 -30
  36. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +106 -1
  37. package/dist/MsaViewPanel/genomeToMSA.js +4 -2
  38. package/dist/MsaViewPanel/genomeToMSA.test.js +34 -0
  39. package/dist/MsaViewPanel/model.d.ts +41 -11
  40. package/dist/MsaViewPanel/model.js +6 -0
  41. package/dist/MsaViewPanel/observeProteinHighlights.test.js +11 -0
  42. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +1 -0
  43. package/dist/MsaViewPanel/util.d.ts +18 -0
  44. package/dist/MsaViewPanel/util.js +17 -0
  45. package/dist/jbrowse-plugin-msaview.umd.production.min.js +47 -35
  46. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  47. package/dist/utils/blastCache.d.ts +10 -6
  48. package/dist/utils/blastCache.js +15 -3
  49. package/dist/utils/ebiBlast.d.ts +1 -1
  50. package/dist/utils/msa.d.ts +12 -0
  51. package/dist/utils/msa.js +35 -12
  52. package/dist/utils/msaRows.d.ts +31 -0
  53. package/dist/utils/msaRows.js +67 -0
  54. package/dist/utils/pantherOrthologs.d.ts +79 -0
  55. package/dist/utils/pantherOrthologs.js +262 -0
  56. package/dist/utils/phmmer.d.ts +53 -0
  57. package/dist/utils/phmmer.js +118 -0
  58. package/dist/utils/taxonomyNames.d.ts +1 -1
  59. package/dist/utils/taxonomyNames.js +6 -1
  60. package/dist/version.d.ts +1 -1
  61. package/dist/version.js +1 -1
  62. package/package.json +27 -21
  63. package/src/AddHighlightModel/GenomeMouseoverHighlight.tsx +1 -1
  64. package/src/AddHighlightModel/MsaToGenomeHighlight.tsx +1 -1
  65. package/src/AddHighlightModel/index.tsx +1 -1
  66. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +88 -30
  67. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -1
  68. package/src/LaunchMsaView/components/BlastQuery/BlastPanel.tsx +4 -4
  69. package/src/LaunchMsaView/components/BlastQuery/CachedBlastResults.test.ts +50 -0
  70. package/src/LaunchMsaView/components/BlastQuery/CachedBlastResults.tsx +23 -3
  71. package/src/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.tsx +1 -1
  72. package/src/LaunchMsaView/components/BlastQuery/consts.ts +40 -0
  73. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -1
  74. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +21 -5
  75. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +52 -0
  76. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +1 -1
  77. package/src/LaunchMsaView/components/useFeatureSequence.ts +1 -1
  78. package/src/LaunchMsaView/detectQueryRow.test.ts +17 -15
  79. package/src/LaunchMsaView/detectQueryRow.ts +34 -23
  80. package/src/LaunchMsaView/useQueryRowName.ts +6 -9
  81. package/src/MsaViewPanel/afterCreateAutoruns.ts +2 -2
  82. package/src/MsaViewPanel/components/ErrorBoundary.tsx +2 -1
  83. package/src/MsaViewPanel/components/JobLink.tsx +7 -2
  84. package/src/MsaViewPanel/components/LaunchProgress.tsx +62 -0
  85. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +83 -0
  86. package/src/MsaViewPanel/components/MsaViewPanel.tsx +7 -4
  87. package/src/MsaViewPanel/doLaunchBlast.ts +127 -69
  88. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +119 -2
  89. package/src/MsaViewPanel/doLaunchOrthologs.ts +100 -38
  90. package/src/MsaViewPanel/genomeToMSA.test.ts +37 -0
  91. package/src/MsaViewPanel/genomeToMSA.ts +6 -2
  92. package/src/MsaViewPanel/model.ts +38 -5
  93. package/src/MsaViewPanel/observeProteinHighlights.test.ts +13 -0
  94. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +1 -0
  95. package/src/MsaViewPanel/util.ts +18 -0
  96. package/src/utils/blastCache.ts +33 -12
  97. package/src/utils/ebiBlast.ts +1 -1
  98. package/src/utils/msa.ts +43 -12
  99. package/src/utils/msaRows.ts +95 -0
  100. package/src/utils/pantherOrthologs.ts +399 -0
  101. package/src/utils/phmmer.ts +174 -0
  102. package/src/utils/taxonomyNames.ts +6 -1
  103. package/src/version.ts +1 -1
  104. package/dist/MsaViewPanel/components/LoadingBLAST.d.ts +0 -6
  105. package/dist/MsaViewPanel/components/LoadingBLAST.js +0 -26
  106. package/src/MsaViewPanel/components/LoadingBLAST.tsx +0 -48
@@ -0,0 +1,399 @@
1
+ // The second ortholog source, for the genes NCBI's ortholog sets leave out.
2
+ //
3
+ // NCBI Datasets computes orthologs for vertebrates and insects, so a yeast,
4
+ // worm or plant gene comes back with orthologs only in its own clade, and a fly
5
+ // gene gets insects and nothing else. PANTHER's ortholog sets span its 144
6
+ // reference proteomes, human to yeast to Arabidopsis, and one `matchortho` call
7
+ // answers "this gene's ortholog in every genome" with a UniProt accession per
8
+ // target. Sequences come from one UniProt batch call. Both hosts send
9
+ // `Access-Control-Allow-Origin: *`. The measurements that picked PANTHER over
10
+ // OMA, OrthoDB and Ensembl are in react-msaview's
11
+ // agent-docs/ideas/ortholog-sources-beyond-ncbi.md.
12
+ //
13
+ // Rows come out in the same shape as ncbiOrthologs.ts's, so the launch, the
14
+ // labels, the aligner and the CDD overlay do not know which source ran.
15
+ // `protein` is the UniProt accession; efetch serves UniProt accessions as
16
+ // GenPept records with CDD Region features, so the overlay attaches as it does
17
+ // to a RefSeq accession.
18
+
19
+ import { jsonfetch } from './fetch'
20
+ import { dedupeLabels, defaultMaxSpecies } from './ncbiOrthologs'
21
+ import { fetchTaxonomyInfo } from './taxonomyNames'
22
+
23
+ import type { OrthologRow } from './ncbiOrthologs'
24
+
25
+ const PANTHER = 'https://pantherdb.org/services/oai/pantherdb'
26
+ const UNIPROT = 'https://rest.uniprot.org/uniprotkb'
27
+
28
+ export interface PantherGenome {
29
+ /** PANTHER's organism code, e.g. HUMAN, DROME */
30
+ code: string
31
+ taxId: number
32
+ /** short common name, e.g. fruit_fly */
33
+ name: string
34
+ /** scientific name */
35
+ longName: string
36
+ }
37
+
38
+ interface GenomesResponse {
39
+ search?: {
40
+ output?: {
41
+ genomes?: {
42
+ genome?: {
43
+ short_name?: string
44
+ taxon_id?: number
45
+ name?: string
46
+ long_name?: string
47
+ }[]
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * `supportedgenomes` -> the code<->taxon map every other parse needs. PANTHER
55
+ * names organisms by code only in ortholog results.
56
+ */
57
+ export function parseGenomes(json: unknown): PantherGenome[] {
58
+ const list = (json as GenomesResponse).search?.output?.genomes?.genome ?? []
59
+ return list.flatMap(g =>
60
+ g.short_name && g.taxon_id
61
+ ? [
62
+ {
63
+ code: g.short_name,
64
+ taxId: g.taxon_id,
65
+ name: g.name ?? g.short_name,
66
+ longName: g.long_name ?? g.short_name,
67
+ },
68
+ ]
69
+ : [],
70
+ )
71
+ }
72
+
73
+ export interface PantherGene {
74
+ code: string
75
+ /** UniProt accession */
76
+ accession: string
77
+ /** the source database's own id, e.g. HGNC=1773, FlyBase=FBgn0016131 */
78
+ geneRef: string
79
+ }
80
+
81
+ export interface PantherHit extends PantherGene {
82
+ symbol?: string
83
+ /**
84
+ * LDO = least diverged ortholog, PANTHER's pick of the one-to-one; O = any
85
+ * other ortholog in a one-to-many or many-to-many family
86
+ */
87
+ type: 'LDO' | 'O'
88
+ }
89
+
90
+ interface Mapping {
91
+ id?: string
92
+ gene?: string
93
+ target_gene?: string
94
+ target_gene_symbol?: string | number
95
+ ortholog?: string
96
+ }
97
+
98
+ interface MatchResponse {
99
+ search?: {
100
+ mapping?: {
101
+ /** one object for a single (or empty) match, an array otherwise */
102
+ mapped?: Mapping | Mapping[]
103
+ unmapped_ids?: unknown
104
+ }
105
+ }
106
+ }
107
+
108
+ // "HUMAN|HGNC=1773|UniProtKB=P11802" -> { code, geneRef, accession }
109
+ function parseGeneRef(ref: string | undefined): PantherGene | undefined {
110
+ const [code, ...xrefs] = (ref ?? '').split('|')
111
+ const accession = xrefs
112
+ .find(x => x.startsWith('UniProtKB='))
113
+ ?.slice('UniProtKB='.length)
114
+ const geneRef = xrefs.find(x => !x.startsWith('UniProtKB=')) ?? accession
115
+ return code && accession && geneRef ? { code, accession, geneRef } : undefined
116
+ }
117
+
118
+ /**
119
+ * `matchortho` -> the query gene (PANTHER names it in every row) and one hit
120
+ * per target gene. An unknown gene comes back under `unmapped_ids`; a gene with
121
+ * no ortholog in the target set comes back as a bare `{ id }`.
122
+ */
123
+ export function parseMatches(json: unknown): {
124
+ unmapped: boolean
125
+ query?: PantherGene
126
+ hits: PantherHit[]
127
+ } {
128
+ const mapping = (json as MatchResponse).search?.mapping
129
+ const mapped = mapping?.mapped
130
+ const rows = Array.isArray(mapped) ? mapped : mapped ? [mapped] : []
131
+ const hits: PantherHit[] = []
132
+ let query: PantherGene | undefined
133
+ for (const row of rows) {
134
+ query ??= parseGeneRef(row.gene)
135
+ const target = parseGeneRef(row.target_gene)
136
+ if (target && (row.ortholog === 'LDO' || row.ortholog === 'O')) {
137
+ hits.push({
138
+ ...target,
139
+ symbol:
140
+ row.target_gene_symbol === undefined
141
+ ? undefined
142
+ : String(row.target_gene_symbol),
143
+ type: row.ortholog,
144
+ })
145
+ }
146
+ }
147
+ return { unmapped: !!mapping?.unmapped_ids, query, hits }
148
+ }
149
+
150
+ /**
151
+ * One hit per organism, in first-seen order: the LDO where PANTHER named one,
152
+ * else the first other ortholog it listed. A many-to-many family (the Hox
153
+ * genes) has no LDO at all, so dropping to "first O" is what keeps those
154
+ * species in the alignment.
155
+ */
156
+ export function pickOnePerGenome(hits: PantherHit[]): PantherHit[] {
157
+ const byCode = new Map<string, PantherHit>()
158
+ for (const hit of hits) {
159
+ const current = byCode.get(hit.code)
160
+ if (!current || (current.type === 'O' && hit.type === 'LDO')) {
161
+ byCode.set(hit.code, hit)
162
+ }
163
+ }
164
+ return [...byCode.values()]
165
+ }
166
+
167
+ interface UniProtResponse {
168
+ results?: {
169
+ primaryAccession?: string
170
+ sequence?: { value?: string }
171
+ }[]
172
+ }
173
+
174
+ /** `uniprotkb/accessions` -> accession -> sequence */
175
+ export function parseSequences(json: unknown): Map<string, string> {
176
+ const map = new Map<string, string>()
177
+ for (const r of (json as UniProtResponse).results ?? []) {
178
+ if (r.primaryAccession && r.sequence?.value) {
179
+ map.set(r.primaryAccession, r.sequence.value)
180
+ }
181
+ }
182
+ return map
183
+ }
184
+
185
+ let genomes: Promise<PantherGenome[]> | undefined
186
+
187
+ /** The proteome list, fetched once per page and forgotten on failure. */
188
+ export function fetchGenomes() {
189
+ genomes ??= jsonfetch(`${PANTHER}/supportedgenomes`)
190
+ .then(parseGenomes)
191
+ .catch((e: unknown) => {
192
+ genomes = undefined
193
+ throw e
194
+ })
195
+ return genomes
196
+ }
197
+
198
+ // UniProt caps one `accessions` call at 100 ids
199
+ const UNIPROT_CHUNK = 100
200
+
201
+ async function fetchSequences(accessions: string[]) {
202
+ const map = new Map<string, string>()
203
+ for (let i = 0; i < accessions.length; i += UNIPROT_CHUNK) {
204
+ const chunk = accessions.slice(i, i + UNIPROT_CHUNK)
205
+ const json = await jsonfetch(
206
+ `${UNIPROT}/accessions?accessions=${chunk.join(',')}&fields=accession,sequence&format=json`,
207
+ )
208
+ for (const [acc, seq] of parseSequences(json)) {
209
+ map.set(acc, seq)
210
+ }
211
+ }
212
+ return map
213
+ }
214
+
215
+ // strip a version suffix (NM_000546.6) and any GFF ID prefix (gene:TP53), as
216
+ // resolveGeneId does for NCBI
217
+ function cleanCandidate(raw: string) {
218
+ return raw
219
+ .trim()
220
+ .replace(/^\w+:/, '')
221
+ .replace(/\.\d+$/, '')
222
+ }
223
+
224
+ /**
225
+ * One `matchortho` per candidate until PANTHER maps one. A JBrowse feature
226
+ * carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
227
+ * some of those are names PANTHER knows. `targets` omitted asks for every
228
+ * genome PANTHER has, which is one call rather than one per genome.
229
+ */
230
+ async function matchOrthologs(
231
+ candidates: string[],
232
+ taxId: number,
233
+ targets: PantherGenome[] | undefined,
234
+ ) {
235
+ let matched: string | undefined
236
+ for (const raw of candidates) {
237
+ const query = cleanCandidate(raw)
238
+ if (!query) {
239
+ continue
240
+ }
241
+ const params = new URLSearchParams({
242
+ geneInputList: query,
243
+ organism: String(taxId),
244
+ orthologType: 'all',
245
+ })
246
+ if (targets) {
247
+ params.set('targetOrganism', targets.map(t => t.taxId).join(','))
248
+ }
249
+ const parsed = parseMatches(
250
+ await jsonfetch(`${PANTHER}/ortholog/matchortho?${params.toString()}`),
251
+ )
252
+ if (!parsed.unmapped) {
253
+ matched ??= query
254
+ if (parsed.query) {
255
+ return { ...parsed, matched: query }
256
+ }
257
+ }
258
+ }
259
+ return matched === undefined
260
+ ? undefined
261
+ : { matched, hits: [], query: undefined }
262
+ }
263
+
264
+ export interface PantherOrthologs {
265
+ /** the candidate PANTHER recognised */
266
+ matched: string
267
+ /** the query gene as PANTHER knows it, with its UniProt sequence */
268
+ query?: PantherGene & { sequence: string }
269
+ rows: OrthologRow[]
270
+ }
271
+
272
+ /**
273
+ * The whole PANTHER half of the pipeline: gene -> ortholog rows carrying
274
+ * labels, accessions and sequences, plus the query gene's own protein for the
275
+ * query row. Two lookups (genomes, orthologs), one taxonomy batch for the
276
+ * labels, one UniProt batch for the sequences.
277
+ *
278
+ * `taxa`, `exclude` and `limit` mean what they mean for fetchOrthologRows:
279
+ * `taxa` narrows the targets (omitted, every genome PANTHER has, in its order),
280
+ * `exclude` drops the query taxon, `limit` caps the rows before their
281
+ * sequences are fetched.
282
+ */
283
+ export async function fetchPantherOrthologs({
284
+ candidates,
285
+ taxId,
286
+ taxa,
287
+ exclude,
288
+ limit = defaultMaxSpecies,
289
+ onProgress,
290
+ }: {
291
+ candidates: string[]
292
+ taxId: number
293
+ taxa?: Set<number>
294
+ exclude?: number
295
+ limit?: number
296
+ onProgress: (arg: string) => void
297
+ }): Promise<PantherOrthologs> {
298
+ const all = await fetchGenomes()
299
+ const byTaxId = new Map(all.map(g => [g.taxId, g]))
300
+ const byCode = new Map(all.map(g => [g.code, g]))
301
+ const queryGenome = byTaxId.get(taxId)
302
+ if (!queryGenome) {
303
+ throw new Error(
304
+ `PANTHER has no reference proteome for taxon ${taxId}. NCBI orthologs cover vertebrates and insects; try that source.`,
305
+ )
306
+ }
307
+ const targets = taxa
308
+ ? [...taxa].flatMap(t => {
309
+ const g = byTaxId.get(t)
310
+ return g && t !== exclude ? [g] : []
311
+ })
312
+ : undefined
313
+
314
+ onProgress('Matching orthologs at PANTHER...')
315
+ const match = await matchOrthologs(candidates, taxId, targets)
316
+ if (!match) {
317
+ throw new Error(
318
+ `PANTHER has no entry for ${candidates.join(', ')} in ${queryGenome.longName}. Try the NCBI BLAST tab, which needs no gene identifier.`,
319
+ )
320
+ }
321
+
322
+ const rank = new Map(targets?.map((t, i) => [t.taxId, i]))
323
+ const picks = pickOnePerGenome(match.hits)
324
+ .map(hit => ({ hit, genome: byCode.get(hit.code) }))
325
+ .filter(
326
+ (p): p is { hit: PantherHit; genome: PantherGenome } =>
327
+ !!p.genome &&
328
+ p.genome.taxId !== exclude &&
329
+ (targets ? rank.has(p.genome.taxId) : true),
330
+ )
331
+ .sort((a, b) =>
332
+ targets ? rank.get(a.genome.taxId)! - rank.get(b.genome.taxId)! : 0,
333
+ )
334
+ .slice(0, limit)
335
+ if (picks.length < 2) {
336
+ throw new Error(
337
+ `Only ${picks.length} PANTHER ortholog(s) found for ${match.matched} — not enough to align`,
338
+ )
339
+ }
340
+
341
+ onProgress(`Fetching ${picks.length} protein sequences from UniProt...`)
342
+ const [names, sequences] = await Promise.all([
343
+ taxonomyNames(picks.map(p => p.genome.taxId)),
344
+ fetchSequences([
345
+ ...(match.query ? [match.query.accession] : []),
346
+ ...picks.map(p => p.hit.accession),
347
+ ]),
348
+ ])
349
+
350
+ const described = picks.map(({ hit, genome }) => {
351
+ const info = names.get(genome.taxId)
352
+ return {
353
+ hit,
354
+ genome,
355
+ name: info ? (info.commonName ?? info.sciname) : genome.name,
356
+ scientificName: info?.sciname || genome.longName,
357
+ commonName: info?.commonName,
358
+ }
359
+ })
360
+ const labels = dedupeLabels(described.map(d => d.name))
361
+ const rows = described
362
+ .map(({ hit, genome, scientificName, commonName }, i) => ({
363
+ taxId: genome.taxId,
364
+ label: labels[i]!,
365
+ scientificName,
366
+ commonName,
367
+ geneId: hit.geneRef,
368
+ protein: hit.accession,
369
+ sequence: sequences.get(hit.accession) ?? '',
370
+ }))
371
+ .filter(r => r.sequence)
372
+ if (rows.length < 2) {
373
+ throw new Error('Could not fetch protein sequences for the orthologs')
374
+ }
375
+
376
+ const querySequence = match.query && sequences.get(match.query.accession)
377
+ return {
378
+ matched: match.matched,
379
+ query:
380
+ match.query && querySequence
381
+ ? { ...match.query, sequence: querySequence }
382
+ : undefined,
383
+ rows,
384
+ }
385
+ }
386
+
387
+ /**
388
+ * NCBI's names for the taxa, so PANTHER rows are labelled exactly as NCBI rows
389
+ * are. A failed lookup only costs the labels, which fall back to PANTHER's own
390
+ * short names, so it is logged rather than thrown.
391
+ */
392
+ async function taxonomyNames(taxIds: number[]) {
393
+ try {
394
+ return await fetchTaxonomyInfo(taxIds)
395
+ } catch (e) {
396
+ console.warn('[msaview-orthologs] taxonomy name lookup failed:', e)
397
+ return new Map<number, { sciname: string; commonName?: string }>()
398
+ }
399
+ }
@@ -0,0 +1,174 @@
1
+ import { StockholmMSA } from 'msa-parsers'
2
+
3
+ import { fetchEbiResult, submitEbiJob, waitForEbiJob } from './ebiJobDispatcher'
4
+
5
+ import type { PhmmerDatabase } from '../LaunchMsaView/components/BlastQuery/consts'
6
+
7
+ const TOOL = 'hmmer3_phmmer'
8
+
9
+ export interface PhmmerRow {
10
+ accession: string
11
+ id: string
12
+ sciname: string
13
+ taxid?: number
14
+ title?: string
15
+ /** the matched envelope on the target, e.g. '503-912', absent if unparseable */
16
+ range?: string
17
+ /** the row as phmmer aligned it, uppercased with '.' inserts turned into '-' */
18
+ aligned: string
19
+ }
20
+
21
+ export interface PhmmerAlignment {
22
+ rows: PhmmerRow[]
23
+ /**
24
+ * the query, placed into the same columns. phmmer does not put the query in
25
+ * its own output, so this is derived — see buildQueryRow.
26
+ */
27
+ queryRow: string
28
+ }
29
+
30
+ /**
31
+ * Human-facing link to a job, shown while it runs and on error.
32
+ *
33
+ * The category has to be sss: jdispatcher serves its shell with a 200 for any
34
+ * category, so /pfa/ and /psa/ look fine to a fetch and render "Page Not Found"
35
+ * in a browser.
36
+ */
37
+ export function phmmerResultUrl(jobId: string) {
38
+ return `https://www.ebi.ac.uk/jdispatcher/sss/${TOOL}/summary?jobId=${jobId}`
39
+ }
40
+
41
+ /** EBI job ids are prefixed with the tool that made them */
42
+ export function isPhmmerJobId(jobId: string) {
43
+ return jobId.startsWith(`${TOOL}-`)
44
+ }
45
+
46
+ /**
47
+ * phmmer aligns every hit to a profile built from the query, one match state
48
+ * per query residue, and marks those columns 'x' in #=GC RF. So the query's own
49
+ * row is exactly recoverable: walk RF, consume a query residue at each match
50
+ * column, gap everywhere else.
51
+ *
52
+ * This is the one piece of real logic here rather than a library call, and it
53
+ * is checked hard: if the match columns do not account for the query exactly,
54
+ * the columns and the query have drifted apart, and a query row that is off by
55
+ * even one residue would silently mis-map every column to the genome. Throwing
56
+ * is much better than drawing that.
57
+ */
58
+ function buildQueryRow({ rf, query }: { rf: string; query: string }) {
59
+ let consumed = 0
60
+ const row = Array.from(rf, c =>
61
+ c === 'x' ? (query[consumed++] ?? '-') : '-',
62
+ ).join('')
63
+ if (consumed !== query.length) {
64
+ throw new Error(
65
+ `phmmer alignment has ${consumed} match columns for a query of ${query.length} residues, so the query row cannot be placed`,
66
+ )
67
+ }
68
+ return row
69
+ }
70
+
71
+ /**
72
+ * '[subseq from] Albumin OS=Homo sapiens OX=9606 GN=ALB PE=1 SV=2' is what a
73
+ * UniProt target's #=GS DE looks like. Hits from the non-UniProt databases
74
+ * phmmer also offers (PDB, AlphaFold, MEROPS...) carry no OS=/OX= at all, so
75
+ * every field here is optional.
76
+ */
77
+ function parseDescription(de: string | undefined) {
78
+ const text = (de ?? '').replace('[subseq from] ', '')
79
+ const sciname = /OS=(.*?)\s+(?:OX|GN|PE|SV)=/.exec(text)?.[1]
80
+ const ox = /OX=(\d+)/.exec(text)?.[1]
81
+ return {
82
+ sciname: sciname ?? 'unknown',
83
+ taxid: ox ? Number.parseInt(ox, 10) : undefined,
84
+ title: text.split(' OS=')[0] || undefined,
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Target names look like 'sp|P02768|ALBU_HUMAN/1-609' for UniProt databases and
90
+ * like anything at all for the others, so an unrecognized name becomes its own
91
+ * accession rather than being dropped.
92
+ */
93
+ function parseName(name: string) {
94
+ const slash = name.lastIndexOf('/')
95
+ const range =
96
+ slash === -1 ? undefined : /^\d+-\d+$/.exec(name.slice(slash + 1))?.[0]
97
+ const bare = range === undefined ? name : name.slice(0, slash)
98
+ const parts = bare.split('|')
99
+ return parts.length === 3
100
+ ? { accession: parts[1]!, id: parts[2]!, range }
101
+ : { accession: bare, id: bare, range }
102
+ }
103
+
104
+ /**
105
+ * Exported for testing against a captured .sto — the annotation names (RF, the
106
+ * DE line's OS=/OX=) are the whole risk in this mapping, and nothing else in CI
107
+ * would notice if HMMER or EBI changed one.
108
+ */
109
+ export function parsePhmmerAlignment({
110
+ stockholm,
111
+ query,
112
+ }: {
113
+ stockholm: string
114
+ query: string
115
+ }): PhmmerAlignment {
116
+ const { gc, gs, seqdata, seqname } = new StockholmMSA(stockholm, 0).getMSA()
117
+ const rf = gc.RF
118
+ if (!rf) {
119
+ throw new Error('phmmer alignment has no #=GC RF line')
120
+ }
121
+
122
+ return {
123
+ queryRow: buildQueryRow({ rf, query }),
124
+ rows: seqname.map(name => ({
125
+ ...parseName(name),
126
+ ...parseDescription(gs.DE?.[name]?.[0]),
127
+ // insert columns come back lowercase with '.' for gaps; the MSA renderer
128
+ // looks colors up by the literal letter, so lowercase would draw
129
+ // uncolored. The insert columns stay visible as gaps in the query row.
130
+ aligned: (seqdata[name] ?? '').replaceAll('.', '-').toUpperCase(),
131
+ })),
132
+ }
133
+ }
134
+
135
+ export async function queryPhmmer({
136
+ query,
137
+ database,
138
+ onProgress,
139
+ onRid,
140
+ }: {
141
+ query: string
142
+ database: PhmmerDatabase
143
+ onProgress: (arg: string) => void
144
+ onRid: (arg: string) => void
145
+ }) {
146
+ onProgress('Submitting to EBI phmmer...')
147
+ const jobId = await submitEbiJob({
148
+ tool: TOOL,
149
+ params: {
150
+ database,
151
+ sequence: query,
152
+ // the alignment is the whole point of using phmmer here
153
+ alignView: 'true',
154
+ },
155
+ })
156
+ onRid(jobId)
157
+
158
+ await waitForEbiJob({
159
+ tool: TOOL,
160
+ jobId,
161
+ onCountdown: s => {
162
+ onProgress(`Re-checking phmmer status in... ${s}`)
163
+ },
164
+ })
165
+
166
+ const alignment = parsePhmmerAlignment({
167
+ stockholm: await fetchEbiResult({ tool: TOOL, jobId, type: 'sto' }),
168
+ query,
169
+ })
170
+ if (alignment.rows.length === 0) {
171
+ throw new Error('No hits found')
172
+ }
173
+ return { rid: jobId, ...alignment }
174
+ }
@@ -51,8 +51,13 @@ export interface TaxonomyInfo {
51
51
  }
52
52
 
53
53
  export async function fetchTaxonomyInfo(
54
- taxids: number[],
54
+ taxidsWithRepeats: number[],
55
55
  ): Promise<Map<number, TaxonomyInfo>> {
56
+ // callers pass one taxid per alignment row, and a BLAST hit list is several
57
+ // rows per species: 100 albumin hits are maybe 50 taxa, and asking as they
58
+ // came did 100 IndexedDB reads and sent eutils 100 ids for 50 answers. The
59
+ // result is keyed by taxid, so no caller can tell the difference
60
+ const taxids = [...new Set(taxidsWithRepeats)]
56
61
  const result = new Map<number, TaxonomyInfo>()
57
62
  const uncachedTaxids: number[] = []
58
63
  const cachedResults = await getCachedTaxonomies(taxids)
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '3.2.0'
1
+ export const version = '3.4.0'
@@ -1,6 +0,0 @@
1
- import React from 'react';
2
- import type { JBrowsePluginMsaViewModel } from '../model';
3
- declare const LoadingBLAST: ({ model, }: {
4
- model: JBrowsePluginMsaViewModel;
5
- }) => React.JSX.Element;
6
- export default LoadingBLAST;
@@ -1,26 +0,0 @@
1
- import React from 'react';
2
- import { ErrorMessage, LoadingEllipses } from '@jbrowse/core/ui';
3
- import { Typography } from '@mui/material';
4
- import { observer } from 'mobx-react';
5
- import { makeStyles } from 'tss-react/mui';
6
- import JobLink from './JobLink';
7
- const useStyles = makeStyles()(theme => ({
8
- margin: {
9
- padding: 20,
10
- },
11
- loading: {
12
- background: theme.palette.background.paper,
13
- },
14
- }));
15
- const LoadingBLAST = observer(function LoadingBLAST2({ model, }) {
16
- const { progress, rid, error } = model;
17
- const { classes } = useStyles();
18
- return (React.createElement("div", { className: classes.margin },
19
- React.createElement(LoadingEllipses, { message: "Running EBI BLAST", variant: "h5" }),
20
- error ? (React.createElement("div", null,
21
- rid ? React.createElement(JobLink, { jobId: rid }) : null,
22
- React.createElement(ErrorMessage, { error: error }))) : rid ? (React.createElement("div", { className: classes.loading },
23
- React.createElement(JobLink, { jobId: rid }),
24
- React.createElement(Typography, null, progress))) : (React.createElement(Typography, null, progress || 'Initializing BLAST query'))));
25
- });
26
- export default LoadingBLAST;
@@ -1,48 +0,0 @@
1
- import React from 'react'
2
-
3
- import { ErrorMessage, LoadingEllipses } from '@jbrowse/core/ui'
4
- import { Typography } from '@mui/material'
5
- import { observer } from 'mobx-react'
6
- import { makeStyles } from 'tss-react/mui'
7
-
8
- import JobLink from './JobLink'
9
-
10
- import type { JBrowsePluginMsaViewModel } from '../model'
11
-
12
- const useStyles = makeStyles()(theme => ({
13
- margin: {
14
- padding: 20,
15
- },
16
- loading: {
17
- background: theme.palette.background.paper,
18
- },
19
- }))
20
-
21
- const LoadingBLAST = observer(function LoadingBLAST2({
22
- model,
23
- }: {
24
- model: JBrowsePluginMsaViewModel
25
- }) {
26
- const { progress, rid, error } = model
27
- const { classes } = useStyles()
28
- return (
29
- <div className={classes.margin}>
30
- <LoadingEllipses message="Running EBI BLAST" variant="h5" />
31
- {error ? (
32
- <div>
33
- {rid ? <JobLink jobId={rid} /> : null}
34
- <ErrorMessage error={error} />
35
- </div>
36
- ) : rid ? (
37
- <div className={classes.loading}>
38
- <JobLink jobId={rid} />
39
- <Typography>{progress}</Typography>
40
- </div>
41
- ) : (
42
- <Typography>{progress || 'Initializing BLAST query'}</Typography>
43
- )}
44
- </div>
45
- )
46
- })
47
-
48
- export default LoadingBLAST