jbrowse-plugin-msaview 2.7.2 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/AddHighlightModel/MsaToGenomeHighlight.js +6 -5
  2. package/dist/LaunchMsaView/components/LaunchMsaViewDialog.js +7 -1
  3. package/dist/LaunchMsaView/components/NCBIBlastQuery/CachedBlastResults.js +1 -1
  4. package/dist/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.d.ts +1 -1
  5. package/dist/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.js +5 -2
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.d.ts +8 -0
  7. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +86 -0
  8. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.d.ts +9 -0
  9. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +13 -0
  10. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +8 -0
  11. package/dist/MsaViewPanel/afterCreateAutoruns.js +28 -0
  12. package/dist/MsaViewPanel/doLaunchBlast.js +10 -22
  13. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +23 -0
  14. package/dist/MsaViewPanel/doLaunchOrthologs.js +97 -0
  15. package/dist/MsaViewPanel/genomeToMSA.js +13 -6
  16. package/dist/MsaViewPanel/genomeToMSA.test.js +35 -6
  17. package/dist/MsaViewPanel/model.d.ts +88 -70
  18. package/dist/MsaViewPanel/model.js +26 -13
  19. package/dist/MsaViewPanel/msaCoordToGenomeCoord.d.ts +32 -13
  20. package/dist/MsaViewPanel/msaCoordToGenomeCoord.js +43 -14
  21. package/dist/MsaViewPanel/msaCoordToGenomeCoord.test.js +108 -18
  22. package/dist/MsaViewPanel/msaDataStore.js +8 -17
  23. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +8 -6
  24. package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -31
  25. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  26. package/dist/utils/blastCache.d.ts +1 -2
  27. package/dist/utils/blastCache.js +9 -22
  28. package/dist/utils/domainCache.js +6 -15
  29. package/dist/utils/idb.d.ts +12 -0
  30. package/dist/utils/idb.js +21 -0
  31. package/dist/utils/ncbiOrthologs.d.ts +105 -0
  32. package/dist/utils/ncbiOrthologs.js +211 -0
  33. package/dist/utils/ncbiOrthologs.test.d.ts +1 -0
  34. package/dist/utils/ncbiOrthologs.test.js +41 -0
  35. package/dist/utils/taxonomyNames.js +13 -18
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +3 -3
  39. package/src/AddHighlightModel/MsaToGenomeHighlight.tsx +6 -8
  40. package/src/LaunchMsaView/components/LaunchMsaViewDialog.tsx +13 -2
  41. package/src/LaunchMsaView/components/NCBIBlastQuery/CachedBlastResults.tsx +1 -1
  42. package/src/LaunchMsaView/components/NCBIBlastQuery/useCachedBlastResults.ts +4 -2
  43. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +172 -0
  44. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +28 -0
  45. package/src/MsaViewPanel/afterCreateAutoruns.ts +27 -0
  46. package/src/MsaViewPanel/doLaunchBlast.ts +20 -29
  47. package/src/MsaViewPanel/doLaunchOrthologs.ts +123 -0
  48. package/src/MsaViewPanel/genomeToMSA.test.ts +38 -6
  49. package/src/MsaViewPanel/genomeToMSA.ts +14 -6
  50. package/src/MsaViewPanel/model.ts +41 -12
  51. package/src/MsaViewPanel/msaCoordToGenomeCoord.test.ts +117 -18
  52. package/src/MsaViewPanel/msaCoordToGenomeCoord.ts +70 -26
  53. package/src/MsaViewPanel/msaDataStore.ts +17 -17
  54. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +8 -6
  55. package/src/utils/blastCache.ts +20 -23
  56. package/src/utils/domainCache.ts +14 -18
  57. package/src/utils/idb.ts +28 -0
  58. package/src/utils/ncbiOrthologs.test.ts +56 -0
  59. package/src/utils/ncbiOrthologs.ts +320 -0
  60. package/src/utils/taxonomyNames.ts +22 -24
  61. package/src/version.ts +1 -1
  62. package/dist/MsaViewPanel/blosum62.d.ts +0 -2
  63. package/dist/MsaViewPanel/blosum62.js +0 -627
  64. package/src/MsaViewPanel/blosum62.ts +0 -628
@@ -0,0 +1,320 @@
1
+ // Homolog discovery WITHOUT a search job.
2
+ //
3
+ // The BLAST path answers "what looks like this sequence", which is not the
4
+ // question an MSA row set wants — it wants "what is homologous to this gene,
5
+ // one per species, labelled by species". BLAST then costs 10+ minutes to
6
+ // return a redundant, accession-labelled hit list that has to be deduplicated
7
+ // before it reads. NCBI has already computed the answer: the Datasets
8
+ // orthologs endpoint returns one ortholog gene per species, instantly.
9
+ //
10
+ // gene symbol -> gene id -> orthologs -> a representative protein each ->
11
+ // sequences, all from NCBI, in a handful of requests. The caller aligns them
12
+ // (EBI Clustal Omega, ~10s) and overlays CDD domains, which are already baked
13
+ // into the GenPept records (see ncbiDomains.ts).
14
+ //
15
+ // Mirrors jb2hubs' website/src/components/proteinMsa.ts assembler, trimmed to
16
+ // what the launch dialog needs and using this plugin's fetch/eutils helpers.
17
+
18
+ import { NCBI_EMAIL, NCBI_TOOL } from './eutils'
19
+ import { jsonfetch, textfetch } from './fetch'
20
+
21
+ // v2, not v2alpha: the alpha path still answers /orthologs but 404s
22
+ // /product_report, so an assembler pointed at it silently resolves zero
23
+ // representative proteins and reports "no orthologs" for every gene.
24
+ const DATASETS = 'https://api.ncbi.nlm.nih.gov/datasets/v2'
25
+ const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
26
+
27
+ // The species panel offered in the launch dialog, ordered from the reference
28
+ // outward so a run that finds only close relatives still reads as a ladder.
29
+ // Orthologs absent for a given gene are skipped rather than erroring.
30
+ export const COMMON_SPECIES = [
31
+ { label: 'Human', taxId: 9606 },
32
+ { label: 'Mouse', taxId: 10090 },
33
+ { label: 'Rat', taxId: 10116 },
34
+ { label: 'Cow', taxId: 9913 },
35
+ { label: 'Pig', taxId: 9823 },
36
+ { label: 'Dog', taxId: 9615 },
37
+ { label: 'Chicken', taxId: 9031 },
38
+ { label: 'Frog', taxId: 8364 },
39
+ { label: 'Zebrafish', taxId: 7955 },
40
+ { label: 'Fruitfly', taxId: 7227 },
41
+ { label: 'C. elegans', taxId: 6239 },
42
+ { label: 'Yeast', taxId: 4932 },
43
+ { label: 'Arabidopsis', taxId: 3702 },
44
+ ] as const
45
+
46
+ export const COMMON_TAX_RANK = new Map(
47
+ COMMON_SPECIES.map((s, i) => [s.taxId as number, i]),
48
+ )
49
+
50
+ export interface OrthologRow {
51
+ taxId: number
52
+ /** single-token id used identically in the FASTA, the tree and the domain GFF */
53
+ label: string
54
+ scientificName: string
55
+ commonName?: string
56
+ geneId: string
57
+ /** accession.version */
58
+ protein: string
59
+ sequence: string
60
+ }
61
+
62
+ function ncbiUrl(url: string) {
63
+ const sep = url.includes('?') ? '&' : '?'
64
+ return `${url}${sep}tool=${NCBI_TOOL}&email=${encodeURIComponent(NCBI_EMAIL)}`
65
+ }
66
+
67
+ /**
68
+ * A free-text gene reference -> NCBI gene id. A bare number is taken as the id
69
+ * itself; anything else is searched as a gene name within the query taxon.
70
+ * Several candidate identifiers are tried in order, because a JBrowse feature
71
+ * carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
72
+ * some of those are real symbols.
73
+ */
74
+ export async function resolveGeneId(
75
+ candidates: string[],
76
+ taxId: number,
77
+ ): Promise<{ geneId: string; matched: string } | undefined> {
78
+ for (const raw of candidates) {
79
+ const query = raw.trim()
80
+ if (!query) {
81
+ continue
82
+ }
83
+ if (/^\d+$/.test(query)) {
84
+ return { geneId: query, matched: query }
85
+ }
86
+ // strip a version suffix (NM_000546.6) and any GFF ID prefix (gene:TP53)
87
+ const cleaned = query.replace(/^\w+:/, '').replace(/\.\d+$/, '')
88
+ const term = `${cleaned}[Gene Name] AND ${taxId}[taxid]`
89
+ const json = await jsonfetch<{
90
+ esearchresult?: { idlist?: string[] }
91
+ }>(
92
+ ncbiUrl(
93
+ `${EUTILS}/esearch.fcgi?db=gene&term=${encodeURIComponent(term)}&retmode=json&retmax=1`,
94
+ ),
95
+ )
96
+ const geneId = json.esearchresult?.idlist?.[0]
97
+ if (geneId) {
98
+ return { geneId, matched: cleaned }
99
+ }
100
+ }
101
+ return undefined
102
+ }
103
+
104
+ interface OrthologReport {
105
+ reports?: {
106
+ gene?: {
107
+ gene_id?: string
108
+ tax_id?: string | number
109
+ taxname?: string
110
+ common_name?: string
111
+ }
112
+ }[]
113
+ }
114
+
115
+ /** One ortholog gene per species, restricted to the requested taxa. */
116
+ export async function fetchOrthologGenes(geneId: string, taxa: Set<number>) {
117
+ const json = await jsonfetch<OrthologReport>(
118
+ ncbiUrl(
119
+ `${DATASETS}/gene/id/${geneId}/orthologs?returned_content=COMPLETE`,
120
+ ),
121
+ )
122
+ const byTaxon = new Map<
123
+ number,
124
+ {
125
+ taxId: number
126
+ geneId: string
127
+ scientificName: string
128
+ commonName?: string
129
+ }
130
+ >()
131
+ for (const { gene } of json.reports ?? []) {
132
+ const taxId = Number(gene?.tax_id)
133
+ if (gene?.gene_id && taxa.has(taxId) && !byTaxon.has(taxId)) {
134
+ byTaxon.set(taxId, {
135
+ taxId,
136
+ geneId: gene.gene_id,
137
+ scientificName: gene.taxname ?? String(taxId),
138
+ commonName: gene.common_name,
139
+ })
140
+ }
141
+ }
142
+ return [...byTaxon.values()].sort(
143
+ (a, b) =>
144
+ (COMMON_TAX_RANK.get(a.taxId) ?? Infinity) -
145
+ (COMMON_TAX_RANK.get(b.taxId) ?? Infinity),
146
+ )
147
+ }
148
+
149
+ interface ProductReport {
150
+ reports?: {
151
+ product?: {
152
+ gene_id?: string
153
+ transcripts?: {
154
+ select_category?: string
155
+ protein?: { accession_version?: string; length?: number }
156
+ }[]
157
+ }
158
+ }[]
159
+ }
160
+
161
+ /**
162
+ * geneId -> representative protein accession: MANE Select where flagged, else
163
+ * the longest isoform. A stable, comparable choice across species — picking
164
+ * "the first" would silently vary with NCBI's ordering.
165
+ */
166
+ export async function fetchRepresentativeProteins(geneIds: string[]) {
167
+ const byGene = new Map<string, string>()
168
+ if (geneIds.length > 0) {
169
+ const json = await jsonfetch<ProductReport>(
170
+ ncbiUrl(`${DATASETS}/gene/id/${geneIds.join(',')}/product_report`),
171
+ )
172
+ for (const { product } of json.reports ?? []) {
173
+ const candidates = (product?.transcripts ?? [])
174
+ .map(t => ({
175
+ acc: t.protein?.accession_version,
176
+ len: t.protein?.length ?? 0,
177
+ mane: /select/i.test(t.select_category ?? ''),
178
+ }))
179
+ .filter(
180
+ (c): c is { acc: string; len: number; mane: boolean } => !!c.acc,
181
+ )
182
+ const best =
183
+ candidates.find(c => c.mane) ??
184
+ [...candidates].sort((a, b) => b.len - a.len).at(0)
185
+ if (product?.gene_id && best) {
186
+ byGene.set(product.gene_id, best.acc)
187
+ }
188
+ }
189
+ }
190
+ return byGene
191
+ }
192
+
193
+ /** accession (first header token) -> ungapped sequence, from a multi-FASTA. */
194
+ export function parseFasta(text: string) {
195
+ const map = new Map<string, string>()
196
+ let acc: string | undefined
197
+ let buf: string[] = []
198
+ for (const line of text.split('\n')) {
199
+ if (line.startsWith('>')) {
200
+ if (acc) {
201
+ map.set(acc, buf.join(''))
202
+ }
203
+ acc = line.slice(1).split(/\s+/)[0]
204
+ buf = []
205
+ } else {
206
+ buf.push(line.trim())
207
+ }
208
+ }
209
+ if (acc) {
210
+ map.set(acc, buf.join(''))
211
+ }
212
+ return map
213
+ }
214
+
215
+ function sanitize(name: string) {
216
+ return name.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
217
+ }
218
+
219
+ /**
220
+ * Sanitized, unique single-token labels used identically in the FASTA headers,
221
+ * the tree leaf names and the domain GFF seq_ids — that identity is how the
222
+ * viewer pairs a tree leaf to its alignment row to its domain track. Collisions
223
+ * get a numeric suffix rather than silently overwriting a row.
224
+ */
225
+ export function dedupeLabels(names: string[]) {
226
+ const seen = new Map<string, number>()
227
+ return names.map(name => {
228
+ const base = sanitize(name) || 'row'
229
+ const n = seen.get(base) ?? 0
230
+ seen.set(base, n + 1)
231
+ return n === 0 ? base : `${base}_${n + 1}`
232
+ })
233
+ }
234
+
235
+ /**
236
+ * The representative protein for a single gene, with its sequence. Used to
237
+ * decide whether the user's own translated transcript is byte-identical to the
238
+ * RefSeq protein — if it is, that accession's precomputed CDD domains apply to
239
+ * the query row exactly, and if it isn't, they would land at an offset.
240
+ */
241
+ export async function fetchProteinForGene(geneId: string) {
242
+ const acc = (await fetchRepresentativeProteins([geneId])).get(geneId)
243
+ if (!acc) {
244
+ return undefined
245
+ }
246
+ const seq = parseFasta(
247
+ await textfetch(
248
+ ncbiUrl(
249
+ `${EUTILS}/efetch.fcgi?db=protein&id=${acc}&rettype=fasta&retmode=text`,
250
+ ),
251
+ ),
252
+ ).get(acc)
253
+ return seq ? { accession: acc, sequence: seq } : undefined
254
+ }
255
+
256
+ /**
257
+ * The whole NCBI half of the pipeline: gene -> ortholog rows carrying labels,
258
+ * accessions and sequences. Everything here is a precomputed lookup, so this
259
+ * returns in seconds rather than the 10+ minutes a BLAST submission costs.
260
+ */
261
+ export async function fetchOrthologRows({
262
+ geneId,
263
+ taxa,
264
+ onProgress,
265
+ }: {
266
+ geneId: string
267
+ taxa: Set<number>
268
+ onProgress: (arg: string) => void
269
+ }): Promise<OrthologRow[]> {
270
+ onProgress('Finding orthologs across species...')
271
+ const genes = await fetchOrthologGenes(geneId, taxa)
272
+ if (genes.length < 2) {
273
+ throw new Error(
274
+ `Only ${genes.length} ortholog(s) found among the selected species — not enough to align`,
275
+ )
276
+ }
277
+
278
+ onProgress('Selecting a representative protein per species...')
279
+ const proteinByGene = await fetchRepresentativeProteins(
280
+ genes.map(g => g.geneId),
281
+ )
282
+ const withProtein = genes.filter(g => proteinByGene.has(g.geneId))
283
+ if (withProtein.length < 2) {
284
+ throw new Error(
285
+ 'Could not resolve representative proteins for the orthologs',
286
+ )
287
+ }
288
+
289
+ onProgress(`Fetching ${withProtein.length} protein sequences...`)
290
+ const accessions = withProtein.map(g => proteinByGene.get(g.geneId)!)
291
+ const seqByAcc = parseFasta(
292
+ await textfetch(
293
+ ncbiUrl(
294
+ `${EUTILS}/efetch.fcgi?db=protein&id=${accessions.join(',')}&rettype=fasta&retmode=text`,
295
+ ),
296
+ ),
297
+ )
298
+
299
+ const labels = dedupeLabels(
300
+ withProtein.map(g => g.commonName ?? g.scientificName),
301
+ )
302
+ const rows = withProtein
303
+ .map((g, i) => {
304
+ const protein = proteinByGene.get(g.geneId)!
305
+ return {
306
+ taxId: g.taxId,
307
+ label: labels[i]!,
308
+ scientificName: g.scientificName,
309
+ commonName: g.commonName,
310
+ geneId: g.geneId,
311
+ protein,
312
+ sequence: seqByAcc.get(protein) ?? '',
313
+ }
314
+ })
315
+ .filter(r => r.sequence)
316
+ if (rows.length < 2) {
317
+ throw new Error('Could not fetch protein sequences for the orthologs')
318
+ }
319
+ return rows
320
+ }
@@ -1,6 +1,8 @@
1
- import { openDB } from 'idb'
2
-
3
1
  import { efetchUrl } from './eutils'
2
+ import { textfetch } from './fetch'
3
+ import { createDbOpener } from './idb'
4
+
5
+ import type { DBSchema } from 'idb'
4
6
 
5
7
  const DB_NAME = 'jbrowse-msaview-taxonomy-cache'
6
8
  const STORE_NAME = 'common-names'
@@ -12,31 +14,24 @@ interface CachedTaxonomy {
12
14
  commonName?: string
13
15
  }
14
16
 
15
- let dbPromise: ReturnType<typeof openDB> | undefined
16
-
17
- function getDB() {
18
- dbPromise ??= openDB(DB_NAME, DB_VERSION, {
19
- upgrade(db) {
20
- if (db.objectStoreNames.contains(STORE_NAME)) {
21
- db.deleteObjectStore(STORE_NAME)
22
- }
23
- db.createObjectStore(STORE_NAME, { keyPath: 'taxid' })
24
- },
25
- }).catch((e: unknown) => {
26
- dbPromise = undefined
27
- throw e
28
- })
29
- return dbPromise
17
+ interface TaxonomyCacheDB extends DBSchema {
18
+ [STORE_NAME]: {
19
+ key: number
20
+ value: CachedTaxonomy
21
+ }
30
22
  }
31
23
 
24
+ const getDB = createDbOpener<TaxonomyCacheDB>(DB_NAME, DB_VERSION, db => {
25
+ if (db.objectStoreNames.contains(STORE_NAME)) {
26
+ db.deleteObjectStore(STORE_NAME)
27
+ }
28
+ db.createObjectStore(STORE_NAME, { keyPath: 'taxid' })
29
+ })
30
+
32
31
  async function getCachedTaxonomies(taxids: number[]) {
33
32
  const db = await getDB()
34
33
  const tx = db.transaction(STORE_NAME, 'readonly')
35
- const results = await Promise.all(
36
- taxids.map(
37
- taxid => tx.store.get(taxid) as Promise<CachedTaxonomy | undefined>,
38
- ),
39
- )
34
+ const results = await Promise.all(taxids.map(taxid => tx.store.get(taxid)))
40
35
  await tx.done
41
36
  return results
42
37
  }
@@ -87,10 +82,13 @@ export async function fetchTaxonomyInfo(
87
82
  const idsParam = batch.join(',')
88
83
 
89
84
  try {
90
- const response = await fetch(
85
+ // textfetch rather than a bare fetch: an NCBI 429/5xx returns an HTML
86
+ // error body that the regexes below silently find nothing in, so without
87
+ // the status check a throttled batch looks like "these taxa have no
88
+ // names" instead of reporting why
89
+ const text = await textfetch(
91
90
  efetchUrl({ db: 'taxonomy', id: idsParam, retmode: 'xml' }),
92
91
  )
93
- const text = await response.text()
94
92
 
95
93
  // Build a map of taxid -> taxon block by finding Taxon elements.
96
94
  // Prefer entries with <LineageEx> (full top-level entries) over nested
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '2.7.2'
1
+ export const version = '2.7.4'
@@ -1,2 +0,0 @@
1
- declare const BLOSUM62: Record<string, Record<string, number>>;
2
- export default BLOSUM62;