jbrowse-plugin-msaview 3.4.0 → 3.4.1

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 (86) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +4 -6
  2. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
  3. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts +1 -0
  5. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +2 -1
  7. package/dist/LaunchMsaViewExtensionPoint/index.js +11 -6
  8. package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
  9. package/dist/LaunchMsaViewExtensionPoint/index.test.js +43 -0
  10. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
  11. package/dist/MsaViewPanel/afterCreateAutoruns.js +93 -70
  12. package/dist/MsaViewPanel/components/LaunchProgress.js +11 -2
  13. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +19 -0
  14. package/dist/MsaViewPanel/doLaunchBlast.d.ts +3 -1
  15. package/dist/MsaViewPanel/doLaunchBlast.js +10 -12
  16. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
  17. package/dist/MsaViewPanel/doLaunchOrthologs.js +6 -5
  18. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +38 -24
  19. package/dist/MsaViewPanel/model.d.ts +160 -10
  20. package/dist/MsaViewPanel/model.js +51 -1
  21. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  22. package/dist/MsaViewPanel/msaDataStore.js +16 -7
  23. package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
  24. package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
  25. package/dist/MsaViewPanel/runLaunch.d.ts +38 -0
  26. package/dist/MsaViewPanel/runLaunch.js +65 -0
  27. package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
  28. package/dist/MsaViewPanel/runLaunch.test.js +129 -0
  29. package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
  30. package/dist/MsaViewPanel/storedData.test.js +160 -0
  31. package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -35
  32. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  33. package/dist/utils/blastCache.js +32 -0
  34. package/dist/utils/blastCache.test.d.ts +1 -0
  35. package/dist/utils/blastCache.test.js +72 -0
  36. package/dist/utils/ebiBlast.d.ts +4 -2
  37. package/dist/utils/ebiBlast.js +6 -4
  38. package/dist/utils/ebiJobDispatcher.d.ts +6 -3
  39. package/dist/utils/ebiJobDispatcher.js +13 -6
  40. package/dist/utils/fetch.d.ts +8 -1
  41. package/dist/utils/fetch.js +28 -2
  42. package/dist/utils/msa.d.ts +4 -2
  43. package/dist/utils/msa.js +9 -5
  44. package/dist/utils/ncbiDomains.d.ts +2 -2
  45. package/dist/utils/ncbiOrthologs.d.ts +11 -1
  46. package/dist/utils/ncbiOrthologs.js +26 -7
  47. package/dist/utils/ncbiOrthologs.test.js +23 -1
  48. package/dist/utils/pantherOrthologs.js +2 -10
  49. package/dist/utils/phmmer.d.ts +2 -1
  50. package/dist/utils/phmmer.js +4 -2
  51. package/dist/utils/poll.d.ts +6 -1
  52. package/dist/utils/poll.js +7 -2
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +14 -14
  56. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +7 -11
  57. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
  58. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
  59. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +2 -2
  60. package/src/LaunchMsaViewExtensionPoint/index.test.ts +51 -0
  61. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -6
  62. package/src/MsaViewPanel/afterCreateAutoruns.ts +100 -66
  63. package/src/MsaViewPanel/components/LaunchProgress.tsx +20 -2
  64. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +21 -0
  65. package/src/MsaViewPanel/doLaunchBlast.ts +14 -10
  66. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +41 -26
  67. package/src/MsaViewPanel/doLaunchOrthologs.ts +8 -4
  68. package/src/MsaViewPanel/model.ts +54 -1
  69. package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
  70. package/src/MsaViewPanel/msaDataStore.ts +22 -13
  71. package/src/MsaViewPanel/runLaunch.test.ts +154 -0
  72. package/src/MsaViewPanel/runLaunch.ts +102 -0
  73. package/src/MsaViewPanel/storedData.test.ts +196 -0
  74. package/src/utils/blastCache.test.ts +86 -0
  75. package/src/utils/blastCache.ts +34 -1
  76. package/src/utils/ebiBlast.ts +8 -2
  77. package/src/utils/ebiJobDispatcher.ts +18 -3
  78. package/src/utils/fetch.ts +29 -2
  79. package/src/utils/msa.ts +11 -3
  80. package/src/utils/ncbiDomains.ts +4 -2
  81. package/src/utils/ncbiOrthologs.test.ts +25 -0
  82. package/src/utils/ncbiOrthologs.ts +27 -7
  83. package/src/utils/pantherOrthologs.ts +6 -11
  84. package/src/utils/phmmer.ts +5 -1
  85. package/src/utils/poll.ts +8 -1
  86. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { textfetch } from './fetch'
1
+ import { isAbortError, textfetch } from './fetch'
2
2
  import { pollLoop } from './poll'
3
3
  import { readLocalStorage } from './useLocalStorage'
4
4
 
@@ -35,13 +35,16 @@ const FAILED_STATUSES = new Set(['ERROR', 'FAILURE', 'NOT_FOUND'])
35
35
  export async function submitEbiJob({
36
36
  tool,
37
37
  params,
38
+ signal,
38
39
  }: {
39
40
  tool: string
40
41
  params: Record<string, string>
42
+ signal?: AbortSignal
41
43
  }) {
42
44
  const jobId = await textfetch(`${EBI_BASE}/${tool}/run`, {
43
45
  method: 'POST',
44
46
  body: new URLSearchParams({ email: getEbiEmail(), ...params }),
47
+ signal,
45
48
  })
46
49
  return jobId.trim()
47
50
  }
@@ -64,21 +67,31 @@ export async function waitForEbiJob({
64
67
  jobId,
65
68
  intervalSeconds = 10,
66
69
  onCountdown,
70
+ signal,
67
71
  }: {
68
72
  tool: string
69
73
  jobId: string
70
74
  intervalSeconds?: number
71
75
  onCountdown: (secondsRemaining: number) => void
76
+ signal?: AbortSignal
72
77
  }) {
73
78
  let consecutiveFailures = 0
74
79
  await pollLoop({
75
80
  intervalSeconds,
76
81
  onCountdown,
82
+ signal,
77
83
  check: async () => {
78
84
  let status: string
79
85
  try {
80
- status = (await textfetch(`${EBI_BASE}/${tool}/status/${jobId}`)).trim()
86
+ status = (
87
+ await textfetch(`${EBI_BASE}/${tool}/status/${jobId}`, { signal })
88
+ ).trim()
81
89
  } catch (e) {
90
+ // a cancelled request is the caller giving up, not EBI being
91
+ // unreachable, so it must end the poll rather than be retried
92
+ if (isAbortError(e)) {
93
+ throw e
94
+ }
82
95
  consecutiveFailures += 1
83
96
  if (consecutiveFailures >= MAX_CONSECUTIVE_STATUS_FAILURES) {
84
97
  throw new Error(
@@ -111,10 +124,12 @@ export async function fetchEbiResult({
111
124
  tool,
112
125
  jobId,
113
126
  type,
127
+ signal,
114
128
  }: {
115
129
  tool: string
116
130
  jobId: string
117
131
  type: string
132
+ signal?: AbortSignal
118
133
  }) {
119
- return textfetch(`${EBI_BASE}/${tool}/result/${jobId}/${type}`)
134
+ return textfetch(`${EBI_BASE}/${tool}/result/${jobId}/${type}`, { signal })
120
135
  }
@@ -45,6 +45,33 @@ export async function jsonfetch<T>(url: string, args?: RequestInit) {
45
45
  return response.json() as Promise<T>
46
46
  }
47
47
 
48
- export function timeout(time: number) {
49
- return new Promise(res => setTimeout(res, time))
48
+ export function timeout(time: number, signal?: AbortSignal) {
49
+ return new Promise<void>((resolve, reject) => {
50
+ if (signal?.aborted) {
51
+ reject(new DOMException('Aborted', 'AbortError'))
52
+ return
53
+ }
54
+ // the listener is removed on the natural path too: one signal serves every
55
+ // tick of a poll, and a 10-minute job is ~600 sleeps -- leaving each
56
+ // listener behind accumulates them all on that one signal
57
+ const onAbort = () => {
58
+ clearTimeout(id)
59
+ reject(new DOMException('Aborted', 'AbortError'))
60
+ }
61
+ const id = setTimeout(() => {
62
+ signal?.removeEventListener('abort', onAbort)
63
+ resolve()
64
+ }, time)
65
+ signal?.addEventListener('abort', onAbort, { once: true })
66
+ })
67
+ }
68
+
69
+ /**
70
+ * A cancelled launch has to be told apart from a failed one: it must not render
71
+ * the "failed" panel, and it must not touch a model that may already be gone.
72
+ * `fetch` and the sleep above both reject with a DOMException named AbortError,
73
+ * which subclasses Error.
74
+ */
75
+ export function isAbortError(e: unknown) {
76
+ return e instanceof Error && e.name === 'AbortError'
50
77
  }
package/src/utils/msa.ts CHANGED
@@ -43,9 +43,11 @@ const algorithms: Record<
43
43
  export async function launchTree({
44
44
  alignment,
45
45
  onProgress,
46
+ signal,
46
47
  }: {
47
48
  alignment: string
48
49
  onProgress: (arg: string) => void
50
+ signal?: AbortSignal
49
51
  }) {
50
52
  const tool = 'simple_phylogeny'
51
53
  onProgress('Building tree...')
@@ -58,25 +60,29 @@ export async function launchTree({
58
60
  clustering: 'Neighbour-joining',
59
61
  kimura: 'true',
60
62
  },
63
+ signal,
61
64
  })
62
65
  await waitForEbiJob({
63
66
  tool,
64
67
  jobId,
68
+ signal,
65
69
  onCountdown: s => {
66
70
  onProgress(`Re-checking tree status in... ${s}`)
67
71
  },
68
72
  })
69
- return fetchEbiResult({ tool, jobId, type: 'tree' })
73
+ return fetchEbiResult({ tool, jobId, type: 'tree', signal })
70
74
  }
71
75
 
72
76
  export async function launchMSA({
73
77
  algorithm,
74
78
  sequence,
75
79
  onProgress,
80
+ signal,
76
81
  }: {
77
82
  algorithm: MsaAlgorithm
78
83
  sequence: string
79
84
  onProgress: (arg: string) => void
85
+ signal?: AbortSignal
80
86
  }) {
81
87
  const config = algorithms[algorithm]
82
88
 
@@ -85,18 +91,20 @@ export async function launchMSA({
85
91
  const jobId = await submitEbiJob({
86
92
  tool: algorithm,
87
93
  params: { ...config.params, sequence },
94
+ signal,
88
95
  })
89
96
  await waitForEbiJob({
90
97
  tool: algorithm,
91
98
  jobId,
99
+ signal,
92
100
  onCountdown: s => {
93
101
  onProgress(`Re-checking MSA status in... ${s}`)
94
102
  },
95
103
  })
96
104
  // one finished job, two result files, neither derived from the other
97
105
  const [msa, tree] = await Promise.all([
98
- fetchEbiResult({ tool: algorithm, jobId, type: config.msaResult }),
99
- fetchEbiResult({ tool: algorithm, jobId, type: config.treeResult }),
106
+ fetchEbiResult({ tool: algorithm, jobId, type: config.msaResult, signal }),
107
+ fetchEbiResult({ tool: algorithm, jobId, type: config.treeResult, signal }),
100
108
  ])
101
109
  return { msa, tree }
102
110
  }
@@ -97,7 +97,7 @@ function parseFeature(featureXml: string): DomainMatch | undefined {
97
97
  * domain and site annotations, keyed by both the versioned and primary
98
98
  * accession so callers can look up by whichever NCBI returned.
99
99
  */
100
- export function parseCddDomains(xml: string) {
100
+ export function parseCddDomains(xml: string): Map<string, DomainMatch[]> {
101
101
  const byAccession = new Map<string, DomainMatch[]>()
102
102
  const seqRe = /<GBSeq>([\s\S]*?)<\/GBSeq>/g
103
103
  let seqMatch
@@ -132,7 +132,9 @@ export function parseCddDomains(xml: string) {
132
132
  * efetch returns them with no job submission or polling. Results are cached in
133
133
  * IndexedDB so reopening a view doesn't refetch.
134
134
  */
135
- export async function fetchProteinDomains(accessions: string[]) {
135
+ export async function fetchProteinDomains(
136
+ accessions: string[],
137
+ ): Promise<Map<string, DomainMatch[]>> {
136
138
  const unique = [...new Set(accessions)].filter(Boolean)
137
139
  const byAccession = new Map<string, DomainMatch[]>()
138
140
 
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test, vi } from 'vitest'
2
2
 
3
3
  import {
4
+ cleanGeneCandidate,
4
5
  dedupeLabels,
5
6
  fetchOrthologGenes,
6
7
  fetchRepresentativeProteins,
@@ -31,6 +32,30 @@ describe('dedupeLabels', () => {
31
32
  test('falls back for a name with no usable characters', () => {
32
33
  expect(dedupeLabels(['...', '...'])).toEqual(['row', 'row_2'])
33
34
  })
35
+
36
+ test('climbs past a suffix a literal name already took', () => {
37
+ // a label pairs a tree leaf to its alignment row, so handing the same one
38
+ // to two rows merges two species into one silently. The third label is ugly
39
+ // and unique, which is the trade
40
+ expect(dedupeLabels(['human', 'human', 'human_2'])).toEqual([
41
+ 'human',
42
+ 'human_2',
43
+ 'human_2_2',
44
+ ])
45
+ expect(dedupeLabels(['human_2', 'human', 'human'])).toEqual([
46
+ 'human_2',
47
+ 'human',
48
+ 'human_3',
49
+ ])
50
+ })
51
+ })
52
+
53
+ describe('cleanGeneCandidate', () => {
54
+ test('strips what a GFF feature carries that a gene lookup does not want', () => {
55
+ expect(cleanGeneCandidate(' gene:TP53 ')).toBe('TP53')
56
+ expect(cleanGeneCandidate('NM_000546.6')).toBe('NM_000546')
57
+ expect(cleanGeneCandidate('TP53')).toBe('TP53')
58
+ })
34
59
  })
35
60
 
36
61
  describe('parseFasta', () => {
@@ -70,6 +70,19 @@ function ncbiUrl(url: string) {
70
70
  return `${url}${sep}tool=${NCBI_TOOL}&email=${encodeURIComponent(NCBI_EMAIL)}`
71
71
  }
72
72
 
73
+ /**
74
+ * A candidate gene reference as the ortholog services know it: a GFF ID prefix
75
+ * (gene:TP53) and a version suffix (NM_000546.6) stripped off. Shared with
76
+ * pantherOrthologs, which asks PANTHER the same question with the same
77
+ * candidates.
78
+ */
79
+ export function cleanGeneCandidate(raw: string) {
80
+ return raw
81
+ .trim()
82
+ .replace(/^\w+:/, '')
83
+ .replace(/\.\d+$/, '')
84
+ }
85
+
73
86
  /**
74
87
  * A free-text gene reference -> NCBI gene id. A bare number is taken as the id
75
88
  * itself; anything else is searched as a gene name within the query taxon.
@@ -89,8 +102,7 @@ export async function resolveGeneId(
89
102
  if (/^\d+$/.test(query)) {
90
103
  return { geneId: query, matched: query }
91
104
  }
92
- // strip a version suffix (NM_000546.6) and any GFF ID prefix (gene:TP53)
93
- const cleaned = query.replace(/^\w+:/, '').replace(/\.\d+$/, '')
105
+ const cleaned = cleanGeneCandidate(query)
94
106
  const term = `${cleaned}[Gene Name] AND ${taxId}[taxid]`
95
107
  const json = await jsonfetch<{
96
108
  esearchresult?: { idlist?: string[] }
@@ -261,15 +273,23 @@ function sanitize(name: string) {
261
273
  * Sanitized, unique single-token labels used identically in the FASTA headers,
262
274
  * the tree leaf names and the domain GFF seq_ids — that identity is how the
263
275
  * viewer pairs a tree leaf to its alignment row to its domain track. Collisions
264
- * get a numeric suffix rather than silently overwriting a row.
276
+ * get a numeric suffix rather than silently overwriting a row, and the suffix
277
+ * climbs past labels already emitted: a species genuinely named `human_2` would
278
+ * otherwise take the label the second `human` is about to get, merging two rows
279
+ * into one.
265
280
  */
266
281
  export function dedupeLabels(names: string[]) {
267
- const seen = new Map<string, number>()
282
+ const used = new Set<string>()
268
283
  return names.map(name => {
269
284
  const base = sanitize(name) || 'row'
270
- const n = seen.get(base) ?? 0
271
- seen.set(base, n + 1)
272
- return n === 0 ? base : `${base}_${n + 1}`
285
+ let label = base
286
+ let n = 1
287
+ while (used.has(label)) {
288
+ n += 1
289
+ label = `${base}_${n}`
290
+ }
291
+ used.add(label)
292
+ return label
273
293
  })
274
294
  }
275
295
 
@@ -17,7 +17,11 @@
17
17
  // to a RefSeq accession.
18
18
 
19
19
  import { jsonfetch } from './fetch'
20
- import { dedupeLabels, defaultMaxSpecies } from './ncbiOrthologs'
20
+ import {
21
+ cleanGeneCandidate,
22
+ dedupeLabels,
23
+ defaultMaxSpecies,
24
+ } from './ncbiOrthologs'
21
25
  import { fetchTaxonomyInfo } from './taxonomyNames'
22
26
 
23
27
  import type { OrthologRow } from './ncbiOrthologs'
@@ -212,15 +216,6 @@ async function fetchSequences(accessions: string[]) {
212
216
  return map
213
217
  }
214
218
 
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
219
  /**
225
220
  * One `matchortho` per candidate until PANTHER maps one. A JBrowse feature
226
221
  * carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
@@ -234,7 +229,7 @@ async function matchOrthologs(
234
229
  ) {
235
230
  let matched: string | undefined
236
231
  for (const raw of candidates) {
237
- const query = cleanCandidate(raw)
232
+ const query = cleanGeneCandidate(raw)
238
233
  if (!query) {
239
234
  continue
240
235
  }
@@ -137,11 +137,13 @@ export async function queryPhmmer({
137
137
  database,
138
138
  onProgress,
139
139
  onRid,
140
+ signal,
140
141
  }: {
141
142
  query: string
142
143
  database: PhmmerDatabase
143
144
  onProgress: (arg: string) => void
144
145
  onRid: (arg: string) => void
146
+ signal?: AbortSignal
145
147
  }) {
146
148
  onProgress('Submitting to EBI phmmer...')
147
149
  const jobId = await submitEbiJob({
@@ -152,19 +154,21 @@ export async function queryPhmmer({
152
154
  // the alignment is the whole point of using phmmer here
153
155
  alignView: 'true',
154
156
  },
157
+ signal,
155
158
  })
156
159
  onRid(jobId)
157
160
 
158
161
  await waitForEbiJob({
159
162
  tool: TOOL,
160
163
  jobId,
164
+ signal,
161
165
  onCountdown: s => {
162
166
  onProgress(`Re-checking phmmer status in... ${s}`)
163
167
  },
164
168
  })
165
169
 
166
170
  const alignment = parsePhmmerAlignment({
167
- stockholm: await fetchEbiResult({ tool: TOOL, jobId, type: 'sto' }),
171
+ stockholm: await fetchEbiResult({ tool: TOOL, jobId, type: 'sto', signal }),
168
172
  query,
169
173
  })
170
174
  if (alignment.rows.length === 0) {
package/src/utils/poll.ts CHANGED
@@ -5,24 +5,31 @@ import { timeout } from './fetch'
5
5
  * false when still pending, and throws on failure. Between checks it counts down
6
6
  * `intervalSeconds`, calling `onCountdown` each second so the UI can show
7
7
  * progress.
8
+ *
9
+ * `signal` ends the loop with an AbortError: the countdown rejects on the tick
10
+ * it is aborted rather than at the end of the interval, so a cancelled launch
11
+ * stops within a second instead of running for the job's lifetime.
8
12
  */
9
13
  export async function pollLoop({
10
14
  check,
11
15
  intervalSeconds,
12
16
  onCountdown,
17
+ signal,
13
18
  }: {
14
19
  check: () => Promise<boolean>
15
20
  intervalSeconds: number
16
21
  onCountdown: (secondsRemaining: number) => void
22
+ signal?: AbortSignal
17
23
  }) {
18
24
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
19
25
  while (true) {
26
+ signal?.throwIfAborted()
20
27
  if (await check()) {
21
28
  return
22
29
  }
23
30
  for (let i = intervalSeconds; i > 0; i--) {
24
31
  onCountdown(i)
25
- await timeout(1000)
32
+ await timeout(1000, signal)
26
33
  }
27
34
  }
28
35
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '3.4.0'
1
+ export const version = '3.4.1'