jbrowse-plugin-msaview 2.5.0 → 2.5.2

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 (35) hide show
  1. package/dist/LaunchMsaViewExtensionPoint/index.js +2 -1
  2. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +22 -1
  3. package/dist/MsaViewPanel/afterCreateAutoruns.js +83 -26
  4. package/dist/MsaViewPanel/loadProteinDomains.d.ts +16 -0
  5. package/dist/MsaViewPanel/loadProteinDomains.js +33 -0
  6. package/dist/MsaViewPanel/model.d.ts +189 -158
  7. package/dist/MsaViewPanel/model.js +17 -5
  8. package/dist/jbrowse-plugin-msaview.umd.production.min.js +28 -28
  9. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  10. package/dist/utils/domainCache.d.ts +8 -0
  11. package/dist/utils/domainCache.js +28 -0
  12. package/dist/utils/eutils.d.ts +3 -0
  13. package/dist/utils/eutils.js +14 -0
  14. package/dist/utils/msa.js +18 -15
  15. package/dist/utils/ncbiBlast.js +22 -24
  16. package/dist/utils/ncbiDomains.d.ts +39 -0
  17. package/dist/utils/ncbiDomains.js +156 -0
  18. package/dist/utils/poll.d.ts +11 -0
  19. package/dist/utils/poll.js +19 -0
  20. package/dist/utils/taxonomyNames.js +2 -1
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/package.json +22 -19
  24. package/src/LaunchMsaViewExtensionPoint/index.ts +3 -0
  25. package/src/MsaViewPanel/afterCreateAutoruns.ts +86 -29
  26. package/src/MsaViewPanel/loadProteinDomains.ts +57 -0
  27. package/src/MsaViewPanel/model.ts +22 -4
  28. package/src/utils/domainCache.ts +43 -0
  29. package/src/utils/eutils.ts +16 -0
  30. package/src/utils/msa.ts +18 -16
  31. package/src/utils/ncbiBlast.ts +27 -31
  32. package/src/utils/ncbiDomains.ts +173 -0
  33. package/src/utils/poll.ts +28 -0
  34. package/src/utils/taxonomyNames.ts +3 -1
  35. package/src/version.ts +1 -1
@@ -0,0 +1,57 @@
1
+ import { fetchProteinDomains } from '../utils/ncbiDomains'
2
+
3
+ import type { InterProScanResults } from 'react-msaview'
4
+
5
+ // structural subset of the MSA model: the full model type can't be used here
6
+ // because it references this very action, creating a self-referential cycle
7
+ interface DomainModel {
8
+ data: { treeMetadata?: string }
9
+ setProgress: (arg: string) => void
10
+ setDomains: (data: Record<string, InterProScanResults>) => void
11
+ }
12
+
13
+ /**
14
+ * Overlay protein domains on the alignment using NCBI's pre-computed CDD
15
+ * annotations. The BLAST workflow stores each hit's accession in treeMetadata,
16
+ * so we look those up via efetch and key the results by MSA row name (which is
17
+ * what react-msaview matches domains against).
18
+ */
19
+ export async function loadProteinDomains(self: DomainModel) {
20
+ const metadataJson = self.data.treeMetadata
21
+ if (!metadataJson) {
22
+ throw new Error('No sequence metadata available to look up domains')
23
+ }
24
+ const metadata = JSON.parse(metadataJson) as Record<
25
+ string,
26
+ Record<string, string>
27
+ >
28
+
29
+ const rowAccessions = Object.entries(metadata)
30
+ .map(([rowName, meta]) => ({ rowName, accession: meta.Accession }))
31
+ .filter((r): r is { rowName: string; accession: string } => !!r.accession)
32
+
33
+ if (rowAccessions.length === 0) {
34
+ throw new Error('No NCBI accessions found in alignment rows')
35
+ }
36
+
37
+ self.setProgress(
38
+ `Fetching protein domains from NCBI for ${rowAccessions.length} sequences...`,
39
+ )
40
+ const byAccession = await fetchProteinDomains(
41
+ rowAccessions.map(r => r.accession),
42
+ )
43
+
44
+ const annotations: Record<string, InterProScanResults> = {}
45
+ for (const { rowName, accession } of rowAccessions) {
46
+ const matches = byAccession.get(accession)
47
+ if (matches && matches.length > 0) {
48
+ annotations[rowName] = { matches, xref: [{ id: rowName }] }
49
+ }
50
+ }
51
+
52
+ if (Object.keys(annotations).length === 0) {
53
+ throw new Error('No CDD domain annotations found for these proteins')
54
+ }
55
+
56
+ self.setDomains(annotations)
57
+ }
@@ -7,8 +7,13 @@ import { genomeToTranscriptSeqMapping } from 'g2p_mapper'
7
7
  import { autorun } from 'mobx'
8
8
  import { MSAModelF } from 'react-msaview'
9
9
 
10
+ // re-exported so the inferred (composed) state-model type can name MSAFormat
11
+ // from msa-parsers when emitting declarations (avoids TS2883 portability error)
12
+ export type { MSAFormat } from 'msa-parsers'
13
+
10
14
  import {
11
15
  autoConnectStructures,
16
+ autoLoadProteinDomains,
12
17
  highlightConnectedStructures,
13
18
  launchBlastIfNeeded,
14
19
  loadStoredData,
@@ -77,9 +82,6 @@ export default function stateModelFactory() {
77
82
  * #property
78
83
  */
79
84
  connectedFeature: types.frozen(),
80
- /**
81
- * #property
82
- */
83
85
  /**
84
86
  * #property
85
87
  */
@@ -128,6 +130,7 @@ export default function stateModelFactory() {
128
130
  error: unknown
129
131
  loadingStoredData: boolean
130
132
  isStoringData: boolean
133
+ domainsRequested: boolean
131
134
  } => ({
132
135
  /**
133
136
  * #volatile
@@ -149,6 +152,12 @@ export default function stateModelFactory() {
149
152
  * #volatile
150
153
  */
151
154
  isStoringData: false,
155
+ /**
156
+ * #volatile
157
+ * guards the one-shot auto-fetch of protein domains so it doesn't refire
158
+ * when NCBI returns no domains (leaving interProAnnotations undefined)
159
+ */
160
+ domainsRequested: false,
152
161
  }),
153
162
  )
154
163
 
@@ -295,6 +304,12 @@ export default function stateModelFactory() {
295
304
  setIsStoringData(arg: boolean) {
296
305
  self.isStoringData = arg
297
306
  },
307
+ /**
308
+ * #action
309
+ */
310
+ setDomainsRequested(arg: boolean) {
311
+ self.domainsRequested = arg
312
+ },
298
313
  /**
299
314
  * #action
300
315
  */
@@ -455,7 +470,7 @@ export default function stateModelFactory() {
455
470
  processInit,
456
471
  highlightConnectedStructures,
457
472
  autoConnectStructures,
458
- observeProteinHighlights,
473
+ autoLoadProteinDomains,
459
474
  ]) {
460
475
  addDisposer(
461
476
  self,
@@ -464,7 +479,10 @@ export default function stateModelFactory() {
464
479
  }),
465
480
  )
466
481
  }
482
+ // these two keep per-reaction state across runs (a "did I set it?" flag),
483
+ // so they're factories returning the autorun body rather than plain fns
467
484
  addDisposer(self, autorun(syncGenomeHoverToMsaColumn(self)))
485
+ addDisposer(self, autorun(observeProteinHighlights(self)))
468
486
  },
469
487
  }))
470
488
  }
@@ -0,0 +1,43 @@
1
+ import { openDB } from 'idb'
2
+
3
+ import type { DomainMatch } from './ncbiDomains'
4
+
5
+ const DB_NAME = 'jbrowse-msaview-domain-cache'
6
+ const STORE_NAME = 'domains'
7
+ const DB_VERSION = 1
8
+
9
+ interface CachedDomain {
10
+ accession: string
11
+ matches: DomainMatch[]
12
+ }
13
+
14
+ async function getDB() {
15
+ return openDB(DB_NAME, DB_VERSION, {
16
+ upgrade(db) {
17
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
18
+ db.createObjectStore(STORE_NAME, { keyPath: 'accession' })
19
+ }
20
+ },
21
+ })
22
+ }
23
+
24
+ export async function getCachedDomains(accessions: string[]) {
25
+ const db = await getDB()
26
+ const tx = db.transaction(STORE_NAME, 'readonly')
27
+ const results = await Promise.all(
28
+ accessions.map(
29
+ accession => tx.store.get(accession) as Promise<CachedDomain | undefined>,
30
+ ),
31
+ )
32
+ await tx.done
33
+ return results
34
+ }
35
+
36
+ export async function saveDomains(entries: CachedDomain[]) {
37
+ const db = await getDB()
38
+ const tx = db.transaction(STORE_NAME, 'readwrite')
39
+ for (const entry of entries) {
40
+ await tx.store.put(entry)
41
+ }
42
+ await tx.done
43
+ }
@@ -0,0 +1,16 @@
1
+ // NCBI asks that programmatic E-utilities requests identify themselves with a
2
+ // tool name and contact email so they can reach out before throttling, rather
3
+ // than silently rate-limiting. https://www.ncbi.nlm.nih.gov/books/NBK25497/
4
+ export const NCBI_TOOL = 'jbrowse-plugin-msaview'
5
+ export const NCBI_EMAIL = 'colin.diesh@gmail.com'
6
+
7
+ const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
8
+
9
+ export function efetchUrl(params: Record<string, string>) {
10
+ const search = new URLSearchParams({
11
+ ...params,
12
+ tool: NCBI_TOOL,
13
+ email: NCBI_EMAIL,
14
+ })
15
+ return `${EUTILS}/efetch.fcgi?${search.toString()}`
16
+ }
package/src/utils/msa.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { textfetch, timeout } from './fetch'
1
+ import { textfetch } from './fetch'
2
+ import { pollLoop } from './poll'
2
3
 
3
4
  import type { MsaAlgorithm } from '../LaunchMsaView/components/NCBIBlastQuery/consts'
4
5
 
@@ -44,21 +45,22 @@ async function wait({
44
45
  algorithm: MsaAlgorithm
45
46
  onProgress: (arg: string) => void
46
47
  }) {
47
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
48
- while (true) {
49
- const result = await textfetch(`${base}/${algorithm}/status/${jobId}`)
50
-
51
- if (result === 'FINISHED') {
52
- break
53
- } else if (result.includes('FAILURE')) {
54
- throw new Error(`Failed to run: jobId ${jobId}`)
55
- }
56
-
57
- for (let i = 0; i < 10; i++) {
58
- onProgress(`Re-checking MSA status in... ${10 - i}`)
59
- await timeout(1000)
60
- }
61
- }
48
+ await pollLoop({
49
+ intervalSeconds: 10,
50
+ onCountdown: s => {
51
+ onProgress(`Re-checking MSA status in... ${s}`)
52
+ },
53
+ check: async () => {
54
+ const result = await textfetch(`${base}/${algorithm}/status/${jobId}`)
55
+ if (result.includes('FINISHED')) {
56
+ return true
57
+ }
58
+ if (result.includes('FAILURE')) {
59
+ throw new Error(`Failed to run: jobId ${jobId}`)
60
+ }
61
+ return false
62
+ },
63
+ })
62
64
  }
63
65
 
64
66
  export async function launchMSA({
@@ -1,4 +1,5 @@
1
- import { jsonfetch, textfetch, timeout } from './fetch'
1
+ import { jsonfetch, textfetch } from './fetch'
2
+ import { pollLoop } from './poll'
2
3
 
3
4
  import type { BlastResults } from './types'
4
5
  import type {
@@ -110,38 +111,33 @@ async function waitForRid({
110
111
  onProgress: (arg: string) => void
111
112
  baseUrl: string
112
113
  }) {
113
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
114
- while (true) {
115
- const res = await textfetch(
116
- `${baseUrl}?CMD=Get&FORMAT_OBJECT=SearchInfo&RID=${rid}`,
117
- )
118
- const statusMatch = /\s+Status=(\S+)/m.exec(res)
119
- const status = statusMatch?.[1]
120
- const hasHits = /\s+ThereAreHits=yes/m.test(res)
114
+ await pollLoop({
115
+ intervalSeconds: 20,
116
+ onCountdown: s => {
117
+ onProgress(`Re-checking BLAST status in... ${s}`)
118
+ },
119
+ check: async () => {
120
+ const res = await textfetch(
121
+ `${baseUrl}?CMD=Get&FORMAT_OBJECT=SearchInfo&RID=${rid}`,
122
+ )
123
+ const status = /\s+Status=(\S+)/m.exec(res)?.[1]
124
+ const hasHits = /\s+ThereAreHits=yes/m.test(res)
121
125
 
122
- if (status === 'WAITING') {
123
- const iter = 20
124
- for (let i = 0; i < iter; i++) {
125
- onProgress(`Re-checking BLAST status in... ${iter - i}`)
126
- await timeout(1000)
126
+ if (status === 'WAITING') {
127
+ return false
127
128
  }
128
- continue
129
- }
130
-
131
- if (status === 'FAILED') {
132
- throw new Error(`BLAST ${rid} failed`)
133
- }
134
-
135
- if (status === 'READY') {
136
- if (hasHits) {
137
- return true
138
- } else {
129
+ if (status === 'FAILED') {
130
+ throw new Error(`BLAST ${rid} failed`)
131
+ }
132
+ if (status === 'READY') {
133
+ if (hasHits) {
134
+ return true
135
+ }
139
136
  throw new Error('No hits found')
140
137
  }
141
- }
142
-
143
- throw new Error(
144
- `BLAST ${rid} returned unexpected status: ${status ?? 'unknown'}`,
145
- )
146
- }
138
+ throw new Error(
139
+ `BLAST ${rid} returned unexpected status: ${status ?? 'unknown'}`,
140
+ )
141
+ },
142
+ })
147
143
  }
@@ -0,0 +1,173 @@
1
+ import { getCachedDomains, saveDomains } from './domainCache'
2
+ import { efetchUrl } from './eutils'
3
+ import { textfetch } from './fetch'
4
+
5
+ import type { InterProScanResults } from 'react-msaview'
6
+
7
+ export type DomainMatch = InterProScanResults['matches'][number]
8
+
9
+ function field(xml: string, tag: string) {
10
+ return new RegExp(`<${tag}>(.*?)</${tag}>`, 's').exec(xml)?.[1]
11
+ }
12
+
13
+ function parseQualifiers(featureXml: string) {
14
+ const quals: Record<string, string> = {}
15
+ const re = /<GBQualifier>([\s\S]*?)<\/GBQualifier>/g
16
+ let m
17
+ while ((m = re.exec(featureXml)) !== null) {
18
+ const name = field(m[1]!, 'GBQualifier_name')
19
+ const value = field(m[1]!, 'GBQualifier_value')
20
+ // keep the first occurrence: NCBI lists the canonical value first
21
+ if (name && value !== undefined && quals[name] === undefined) {
22
+ quals[name] = value
23
+ }
24
+ }
25
+ return quals
26
+ }
27
+
28
+ // A feature can span several intervals: domains are usually one contiguous
29
+ // range, but CDD Sites (e.g. an active site) are a set of scattered residues
30
+ // expressed as multiple GBInterval ranges and single GBInterval_point residues.
31
+ // We collapse those to a single bounding span so a site renders as one box
32
+ // rather than a spray of 1px specks.
33
+ function parseBoundingSpan(featureXml: string) {
34
+ const starts: number[] = []
35
+ const ends: number[] = []
36
+ const re = /<GBInterval>([\s\S]*?)<\/GBInterval>/g
37
+ let m
38
+ while ((m = re.exec(featureXml)) !== null) {
39
+ const block = m[1]!
40
+ const from = field(block, 'GBInterval_from')
41
+ const to = field(block, 'GBInterval_to')
42
+ const point = field(block, 'GBInterval_point')
43
+ if (from && to) {
44
+ starts.push(Number(from))
45
+ ends.push(Number(to))
46
+ } else if (point) {
47
+ starts.push(Number(point))
48
+ ends.push(Number(point))
49
+ }
50
+ }
51
+ return starts.length > 0
52
+ ? { start: Math.min(...starts), end: Math.max(...ends) }
53
+ : undefined
54
+ }
55
+
56
+ // Drop single-residue specks (acetylation/phospho points) but keep every
57
+ // genuine domain and functional site; react-msaview draws longest-first, so
58
+ // smaller features (binding sites, loops) layer on top of the domain they sit
59
+ // inside.
60
+ const MIN_FEATURE_LENGTH = 2
61
+
62
+ function parseFeature(featureXml: string): DomainMatch | undefined {
63
+ const key = field(featureXml, 'GBFeature_key')
64
+ const quals = parseQualifiers(featureXml)
65
+ const xref = quals.db_xref
66
+ const span = parseBoundingSpan(featureXml)
67
+ // only CDD-backed Regions/Sites are conserved-domain annotations; Regions and
68
+ // Sites without a CDD xref are UniProt-propagated point motifs we don't want
69
+ if (
70
+ (key === 'Region' || key === 'Site') &&
71
+ xref?.startsWith('CDD:') &&
72
+ span &&
73
+ span.end - span.start + 1 >= MIN_FEATURE_LENGTH
74
+ ) {
75
+ const cddId = xref.replace('CDD:', '')
76
+ const isDomain = key === 'Region'
77
+ // a site's note (e.g. "ATP binding site [chemical binding]") is more
78
+ // specific than its generic site_type ("other"), so prefer it for the name
79
+ // — that gives each functional site its own color/legend/filter entry
80
+ const noteName = quals.note?.split(/[[(]/)[0]?.trim()
81
+ const name = isDomain
82
+ ? (quals.region_name ?? cddId)
83
+ : noteName || quals.site_type || 'site'
84
+ const accession = isDomain ? cddId : `${cddId}:${name}`
85
+ return {
86
+ signature: {
87
+ entry: { name, description: quals.note ?? name, accession },
88
+ },
89
+ locations: [span],
90
+ }
91
+ }
92
+ return undefined
93
+ }
94
+
95
+ /**
96
+ * Parse a GenPept (efetch db=protein&rettype=gp&retmode=xml) document into CDD
97
+ * domain and site annotations, keyed by both the versioned and primary
98
+ * accession so callers can look up by whichever NCBI returned.
99
+ */
100
+ export function parseCddDomains(xml: string) {
101
+ const byAccession = new Map<string, DomainMatch[]>()
102
+ const seqRe = /<GBSeq>([\s\S]*?)<\/GBSeq>/g
103
+ let seqMatch
104
+ while ((seqMatch = seqRe.exec(xml)) !== null) {
105
+ const seqXml = seqMatch[1]!
106
+ const matches: DomainMatch[] = []
107
+
108
+ const featRe = /<GBFeature>([\s\S]*?)<\/GBFeature>/g
109
+ let featMatch
110
+ while ((featMatch = featRe.exec(seqXml)) !== null) {
111
+ const match = parseFeature(featMatch[1]!)
112
+ if (match) {
113
+ matches.push(match)
114
+ }
115
+ }
116
+
117
+ for (const acc of [
118
+ field(seqXml, 'GBSeq_accession-version'),
119
+ field(seqXml, 'GBSeq_primary-accession'),
120
+ ]) {
121
+ if (acc) {
122
+ byAccession.set(acc, matches)
123
+ }
124
+ }
125
+ }
126
+ return byAccession
127
+ }
128
+
129
+ /**
130
+ * Fetch pre-computed CDD domain and site annotations for NCBI protein
131
+ * accessions. These come baked into the GenPept records, so a single batched
132
+ * efetch returns them with no job submission or polling. Results are cached in
133
+ * IndexedDB so reopening a view doesn't refetch.
134
+ */
135
+ export async function fetchProteinDomains(accessions: string[]) {
136
+ const unique = [...new Set(accessions)].filter(Boolean)
137
+ const byAccession = new Map<string, DomainMatch[]>()
138
+
139
+ const cached = await getCachedDomains(unique)
140
+ const uncached: string[] = []
141
+ unique.forEach((acc, i) => {
142
+ const hit = cached[i]
143
+ if (hit) {
144
+ byAccession.set(acc, hit.matches)
145
+ } else {
146
+ uncached.push(acc)
147
+ }
148
+ })
149
+
150
+ const toCache: { accession: string; matches: DomainMatch[] }[] = []
151
+ const batchSize = 100
152
+ for (let i = 0; i < uncached.length; i += batchSize) {
153
+ const batch = uncached.slice(i, i + batchSize)
154
+ const xml = await textfetch(
155
+ efetchUrl({
156
+ db: 'protein',
157
+ id: batch.join(','),
158
+ rettype: 'gp',
159
+ retmode: 'xml',
160
+ }),
161
+ )
162
+ const parsed = parseCddDomains(xml)
163
+ for (const acc of batch) {
164
+ const matches = parsed.get(acc) ?? []
165
+ byAccession.set(acc, matches)
166
+ toCache.push({ accession: acc, matches })
167
+ }
168
+ }
169
+ if (toCache.length > 0) {
170
+ await saveDomains(toCache)
171
+ }
172
+ return byAccession
173
+ }
@@ -0,0 +1,28 @@
1
+ import { timeout } from './fetch'
2
+
3
+ /**
4
+ * Poll a remote job until it reports done. `check` returns true when finished,
5
+ * false when still pending, and throws on failure. Between checks it counts down
6
+ * `intervalSeconds`, calling `onCountdown` each second so the UI can show
7
+ * progress.
8
+ */
9
+ export async function pollLoop({
10
+ check,
11
+ intervalSeconds,
12
+ onCountdown,
13
+ }: {
14
+ check: () => Promise<boolean>
15
+ intervalSeconds: number
16
+ onCountdown: (secondsRemaining: number) => void
17
+ }) {
18
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
19
+ while (true) {
20
+ if (await check()) {
21
+ return
22
+ }
23
+ for (let i = intervalSeconds; i > 0; i--) {
24
+ onCountdown(i)
25
+ await timeout(1000)
26
+ }
27
+ }
28
+ }
@@ -1,5 +1,7 @@
1
1
  import { openDB } from 'idb'
2
2
 
3
+ import { efetchUrl } from './eutils'
4
+
3
5
  const DB_NAME = 'jbrowse-msaview-taxonomy-cache'
4
6
  const STORE_NAME = 'common-names'
5
7
  const DB_VERSION = 2
@@ -80,7 +82,7 @@ export async function fetchTaxonomyInfo(
80
82
 
81
83
  try {
82
84
  const response = await fetch(
83
- `https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=taxonomy&id=${idsParam}&retmode=xml`,
85
+ efetchUrl({ db: 'taxonomy', id: idsParam, retmode: 'xml' }),
84
86
  )
85
87
  const text = await response.text()
86
88
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '2.5.0'
1
+ export const version = '2.5.2'