jbrowse-plugin-msaview 3.3.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 (149) 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 +60 -18
  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/BlastQuery/searchChoiceStorage.d.ts +7 -0
  15. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
  16. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts +1 -0
  17. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
  18. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -1
  19. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +5 -4
  20. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +1 -1
  21. package/dist/LaunchMsaView/components/useFeatureSequence.js +1 -1
  22. package/dist/LaunchMsaView/detectQueryRow.d.ts +15 -2
  23. package/dist/LaunchMsaView/detectQueryRow.js +20 -21
  24. package/dist/LaunchMsaView/detectQueryRow.test.js +15 -15
  25. package/dist/LaunchMsaView/useQueryRowName.js +5 -8
  26. package/dist/LaunchMsaViewExtensionPoint/index.js +11 -6
  27. package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
  28. package/dist/LaunchMsaViewExtensionPoint/index.test.js +43 -0
  29. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
  30. package/dist/MsaViewPanel/afterCreateAutoruns.js +95 -72
  31. package/dist/MsaViewPanel/components/ErrorBoundary.d.ts +2 -2
  32. package/dist/MsaViewPanel/components/JobLink.js +7 -1
  33. package/dist/MsaViewPanel/components/LaunchProgress.d.ts +17 -0
  34. package/dist/MsaViewPanel/components/LaunchProgress.js +50 -0
  35. package/dist/MsaViewPanel/components/MsaViewPanel.js +6 -3
  36. package/dist/MsaViewPanel/components/MsaViewPanel.test.d.ts +1 -0
  37. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +87 -0
  38. package/dist/MsaViewPanel/doLaunchBlast.d.ts +4 -2
  39. package/dist/MsaViewPanel/doLaunchBlast.js +86 -56
  40. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
  41. package/dist/MsaViewPanel/doLaunchOrthologs.js +6 -5
  42. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +39 -25
  43. package/dist/MsaViewPanel/genomeToMSA.js +4 -2
  44. package/dist/MsaViewPanel/genomeToMSA.test.js +34 -0
  45. package/dist/MsaViewPanel/model.d.ts +190 -19
  46. package/dist/MsaViewPanel/model.js +51 -1
  47. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  48. package/dist/MsaViewPanel/msaDataStore.js +16 -7
  49. package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
  50. package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
  51. package/dist/MsaViewPanel/observeProteinHighlights.test.js +11 -0
  52. package/dist/MsaViewPanel/runLaunch.d.ts +38 -0
  53. package/dist/MsaViewPanel/runLaunch.js +65 -0
  54. package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
  55. package/dist/MsaViewPanel/runLaunch.test.js +129 -0
  56. package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
  57. package/dist/MsaViewPanel/storedData.test.js +160 -0
  58. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +1 -0
  59. package/dist/MsaViewPanel/util.d.ts +18 -0
  60. package/dist/MsaViewPanel/util.js +17 -0
  61. package/dist/jbrowse-plugin-msaview.umd.production.min.js +47 -35
  62. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  63. package/dist/utils/blastCache.d.ts +10 -6
  64. package/dist/utils/blastCache.js +47 -3
  65. package/dist/utils/blastCache.test.d.ts +1 -0
  66. package/dist/utils/blastCache.test.js +72 -0
  67. package/dist/utils/ebiBlast.d.ts +5 -3
  68. package/dist/utils/ebiBlast.js +6 -4
  69. package/dist/utils/ebiJobDispatcher.d.ts +6 -3
  70. package/dist/utils/ebiJobDispatcher.js +13 -6
  71. package/dist/utils/fetch.d.ts +8 -1
  72. package/dist/utils/fetch.js +28 -2
  73. package/dist/utils/msa.d.ts +15 -1
  74. package/dist/utils/msa.js +40 -13
  75. package/dist/utils/msaRows.d.ts +31 -0
  76. package/dist/utils/msaRows.js +67 -0
  77. package/dist/utils/ncbiDomains.d.ts +2 -2
  78. package/dist/utils/ncbiOrthologs.d.ts +11 -1
  79. package/dist/utils/ncbiOrthologs.js +26 -7
  80. package/dist/utils/ncbiOrthologs.test.js +23 -1
  81. package/dist/utils/pantherOrthologs.js +2 -10
  82. package/dist/utils/phmmer.d.ts +54 -0
  83. package/dist/utils/phmmer.js +120 -0
  84. package/dist/utils/poll.d.ts +6 -1
  85. package/dist/utils/poll.js +7 -2
  86. package/dist/utils/taxonomyNames.d.ts +1 -1
  87. package/dist/utils/taxonomyNames.js +6 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +30 -24
  91. package/src/AddHighlightModel/GenomeMouseoverHighlight.tsx +1 -1
  92. package/src/AddHighlightModel/MsaToGenomeHighlight.tsx +1 -1
  93. package/src/AddHighlightModel/index.tsx +1 -1
  94. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +85 -31
  95. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -1
  96. package/src/LaunchMsaView/components/BlastQuery/BlastPanel.tsx +4 -4
  97. package/src/LaunchMsaView/components/BlastQuery/CachedBlastResults.test.ts +50 -0
  98. package/src/LaunchMsaView/components/BlastQuery/CachedBlastResults.tsx +23 -3
  99. package/src/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.tsx +1 -1
  100. package/src/LaunchMsaView/components/BlastQuery/consts.ts +40 -0
  101. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
  102. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
  103. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -1
  104. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +7 -7
  105. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +1 -1
  106. package/src/LaunchMsaView/components/useFeatureSequence.ts +1 -1
  107. package/src/LaunchMsaView/detectQueryRow.test.ts +17 -15
  108. package/src/LaunchMsaView/detectQueryRow.ts +34 -23
  109. package/src/LaunchMsaView/useQueryRowName.ts +6 -9
  110. package/src/LaunchMsaViewExtensionPoint/index.test.ts +51 -0
  111. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -6
  112. package/src/MsaViewPanel/afterCreateAutoruns.ts +102 -68
  113. package/src/MsaViewPanel/components/ErrorBoundary.tsx +2 -1
  114. package/src/MsaViewPanel/components/JobLink.tsx +7 -2
  115. package/src/MsaViewPanel/components/LaunchProgress.tsx +80 -0
  116. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +104 -0
  117. package/src/MsaViewPanel/components/MsaViewPanel.tsx +7 -4
  118. package/src/MsaViewPanel/doLaunchBlast.ts +134 -72
  119. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +43 -28
  120. package/src/MsaViewPanel/doLaunchOrthologs.ts +9 -5
  121. package/src/MsaViewPanel/genomeToMSA.test.ts +37 -0
  122. package/src/MsaViewPanel/genomeToMSA.ts +6 -2
  123. package/src/MsaViewPanel/model.ts +82 -6
  124. package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
  125. package/src/MsaViewPanel/msaDataStore.ts +22 -13
  126. package/src/MsaViewPanel/observeProteinHighlights.test.ts +13 -0
  127. package/src/MsaViewPanel/runLaunch.test.ts +154 -0
  128. package/src/MsaViewPanel/runLaunch.ts +102 -0
  129. package/src/MsaViewPanel/storedData.test.ts +196 -0
  130. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +1 -0
  131. package/src/MsaViewPanel/util.ts +18 -0
  132. package/src/utils/blastCache.test.ts +86 -0
  133. package/src/utils/blastCache.ts +67 -13
  134. package/src/utils/ebiBlast.ts +9 -3
  135. package/src/utils/ebiJobDispatcher.ts +18 -3
  136. package/src/utils/fetch.ts +29 -2
  137. package/src/utils/msa.ts +51 -12
  138. package/src/utils/msaRows.ts +95 -0
  139. package/src/utils/ncbiDomains.ts +4 -2
  140. package/src/utils/ncbiOrthologs.test.ts +25 -0
  141. package/src/utils/ncbiOrthologs.ts +27 -7
  142. package/src/utils/pantherOrthologs.ts +6 -11
  143. package/src/utils/phmmer.ts +178 -0
  144. package/src/utils/poll.ts +8 -1
  145. package/src/utils/taxonomyNames.ts +6 -1
  146. package/src/version.ts +1 -1
  147. package/dist/MsaViewPanel/components/LoadingBLAST.d.ts +0 -6
  148. package/dist/MsaViewPanel/components/LoadingBLAST.js +0 -26
  149. package/src/MsaViewPanel/components/LoadingBLAST.tsx +0 -48
@@ -1,15 +1,18 @@
1
- import type { BlastDatabase, MsaAlgorithm } from '../LaunchMsaView/components/BlastQuery/consts';
1
+ import type { BlastDatabase, MsaAlgorithm, PhmmerDatabase, SearchProgram } from '../LaunchMsaView/components/BlastQuery/consts';
2
2
  export interface CachedBlastResult {
3
3
  id: string;
4
4
  proteinSequence: string;
5
- blastDatabase: BlastDatabase;
5
+ blastDatabase: BlastDatabase | PhmmerDatabase;
6
6
  /**
7
7
  * Only ever set on rows cached by a version that still queried NCBI, where
8
8
  * the choice between blastp and quick-blastp was real. Kept so those rows
9
9
  * still display; never written now.
10
10
  */
11
11
  blastProgram?: string;
12
- msaAlgorithm: MsaAlgorithm;
12
+ /** absent on rows cached before phmmer existed, which were all blastp */
13
+ searchProgram?: SearchProgram;
14
+ /** absent on phmmer rows, which are aligned by the search itself */
15
+ msaAlgorithm?: MsaAlgorithm;
13
16
  msa: string;
14
17
  tree: string;
15
18
  treeMetadata: string;
@@ -20,10 +23,11 @@ export interface CachedBlastResult {
20
23
  transcriptName?: string;
21
24
  geneName?: string;
22
25
  }
23
- export declare function saveBlastResult({ proteinSequence, blastDatabase, msaAlgorithm, msa, tree, treeMetadata, rid, geneId, transcriptId, transcriptName, geneName, }: {
26
+ export declare function saveBlastResult({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, msa, tree, treeMetadata, rid, geneId, transcriptId, transcriptName, geneName, }: {
24
27
  proteinSequence: string;
25
- blastDatabase: BlastDatabase;
26
- msaAlgorithm: MsaAlgorithm;
28
+ blastDatabase: BlastDatabase | PhmmerDatabase;
29
+ msaAlgorithm?: MsaAlgorithm;
30
+ searchProgram?: SearchProgram;
27
31
  msa: string;
28
32
  tree: string;
29
33
  treeMetadata: string;
@@ -2,6 +2,12 @@ import { createDbOpener } from './idb';
2
2
  const DB_NAME = 'jbrowse-msaview-blast-cache';
3
3
  const STORE_NAME = 'blast-results';
4
4
  const DB_VERSION = 2;
5
+ /**
6
+ * How many results the history keeps. Every row holds a whole alignment and its
7
+ * tree — megabytes each — so an unbounded store grows until the browser starts
8
+ * refusing writes to it, and the user never sees why.
9
+ */
10
+ const MAX_CACHED_RESULTS = 50;
5
11
  const getDB = createDbOpener(DB_NAME, DB_VERSION, (db, oldVersion) => {
6
12
  if (oldVersion < 2 && db.objectStoreNames.contains(STORE_NAME)) {
7
13
  db.deleteObjectStore(STORE_NAME);
@@ -10,21 +16,33 @@ const getDB = createDbOpener(DB_NAME, DB_VERSION, (db, oldVersion) => {
10
16
  db.createObjectStore(STORE_NAME, { keyPath: 'id' });
11
17
  }
12
18
  });
13
- function createCacheKey(proteinSequence, blastDatabase, msaAlgorithm, transcriptId) {
19
+ function createCacheKey({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, transcriptId, }) {
14
20
  const idPart = transcriptId ? `:${transcriptId}` : '';
21
+ // phmmer keys are prefixed and blastp keys are left exactly as they were, so
22
+ // results cached before phmmer existed still resolve
23
+ if (searchProgram === 'phmmer') {
24
+ return `phmmer:${blastDatabase}${idPart}:${proteinSequence}`;
25
+ }
15
26
  // msaAlgorithm is part of the key because the stored msa/tree are produced by
16
27
  // it — without it, re-running the same query under a different algorithm
17
28
  // overwrites the earlier result and drops it from the history list
18
29
  return `${blastDatabase}:${msaAlgorithm}${idPart}:${proteinSequence}`;
19
30
  }
20
- export async function saveBlastResult({ proteinSequence, blastDatabase, msaAlgorithm, msa, tree, treeMetadata, rid, geneId, transcriptId, transcriptName, geneName, }) {
31
+ export async function saveBlastResult({ proteinSequence, blastDatabase, msaAlgorithm, searchProgram, msa, tree, treeMetadata, rid, geneId, transcriptId, transcriptName, geneName, }) {
21
32
  const db = await getDB();
22
- const id = createCacheKey(proteinSequence, blastDatabase, msaAlgorithm, transcriptId);
33
+ const id = createCacheKey({
34
+ proteinSequence,
35
+ blastDatabase,
36
+ msaAlgorithm,
37
+ searchProgram,
38
+ transcriptId,
39
+ });
23
40
  const entry = {
24
41
  id,
25
42
  proteinSequence,
26
43
  blastDatabase,
27
44
  msaAlgorithm,
45
+ searchProgram,
28
46
  msa,
29
47
  tree,
30
48
  treeMetadata,
@@ -36,8 +54,34 @@ export async function saveBlastResult({ proteinSequence, blastDatabase, msaAlgor
36
54
  geneName,
37
55
  };
38
56
  await db.put(STORE_NAME, entry);
57
+ await evictOldest(db);
39
58
  return entry;
40
59
  }
60
+ /**
61
+ * Drop the oldest rows until the store is back at MAX_CACHED_RESULTS.
62
+ *
63
+ * `count` first so the common save reads no values at all: without a timestamp
64
+ * index the oldest have to be found by loading every row, and each one is an
65
+ * entire alignment. Failing to evict must not fail the save — the result is
66
+ * already in hand and losing it to a housekeeping error would be the worse
67
+ * outcome.
68
+ */
69
+ async function evictOldest(db) {
70
+ try {
71
+ if ((await db.count(STORE_NAME)) <= MAX_CACHED_RESULTS) {
72
+ return;
73
+ }
74
+ const all = await db.getAll(STORE_NAME);
75
+ const doomed = all
76
+ .toSorted((a, b) => a.timestamp - b.timestamp)
77
+ .slice(0, all.length - MAX_CACHED_RESULTS);
78
+ const tx = db.transaction(STORE_NAME, 'readwrite');
79
+ await Promise.all([...doomed.map(e => tx.store.delete(e.id)), tx.done]);
80
+ }
81
+ catch (e) {
82
+ console.warn('Failed to evict old BLAST cache entries:', e);
83
+ }
84
+ }
41
85
  export async function getAllCachedResults() {
42
86
  const db = await getDB();
43
87
  const results = await db.getAll(STORE_NAME);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
2
+ import { getAllCachedResults, saveBlastResult } from './blastCache';
3
+ // An in-memory stand-in for the one object store this module opens. Only the
4
+ // idb calls blastCache makes are implemented; anything else would be untested
5
+ // scaffolding.
6
+ const { rows } = vi.hoisted(() => ({
7
+ rows: new Map(),
8
+ }));
9
+ vi.mock('./idb', () => ({
10
+ createDbOpener: () => () => Promise.resolve({
11
+ put: (_store, value) => {
12
+ rows.set(value.id, value);
13
+ return Promise.resolve(value.id);
14
+ },
15
+ count: () => Promise.resolve(rows.size),
16
+ getAll: () => Promise.resolve([...rows.values()]),
17
+ transaction: () => ({
18
+ store: {
19
+ delete: (id) => {
20
+ rows.delete(id);
21
+ return Promise.resolve();
22
+ },
23
+ },
24
+ done: Promise.resolve(),
25
+ }),
26
+ }),
27
+ }));
28
+ function save(n) {
29
+ return saveBlastResult({
30
+ proteinSequence: `SEQ${n}`,
31
+ blastDatabase: 'uniprotkb_swissprot',
32
+ msaAlgorithm: 'clustalo',
33
+ msa: '>a\nMK',
34
+ tree: '(a);',
35
+ treeMetadata: '{}',
36
+ rid: `job-${n}`,
37
+ });
38
+ }
39
+ beforeEach(() => {
40
+ rows.clear();
41
+ // strictly increasing, so "oldest" is unambiguous -- 55 saves in one
42
+ // millisecond would otherwise all carry the same timestamp
43
+ let clock = 1;
44
+ vi.spyOn(Date, 'now').mockImplementation(() => clock++);
45
+ });
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+ test('a store at the cap loses nothing', async () => {
50
+ for (let i = 0; i < 50; i++) {
51
+ await save(i);
52
+ }
53
+ expect(rows.size).toBe(50);
54
+ });
55
+ // Every row holds a whole alignment and its tree, so an unbounded store grows
56
+ // until the browser refuses writes to it and the user never learns why.
57
+ test('going over the cap drops the oldest results and keeps the newest 50', async () => {
58
+ for (let i = 0; i < 55; i++) {
59
+ await save(i);
60
+ }
61
+ expect(rows.size).toBe(50);
62
+ const rids = (await getAllCachedResults()).map((r) => r.rid);
63
+ expect(rids.at(0)).toBe('job-54');
64
+ expect(rids.at(-1)).toBe('job-5');
65
+ });
66
+ test('re-saving the same query overwrites its row rather than growing the store', async () => {
67
+ for (let i = 0; i < 50; i++) {
68
+ await save(i);
69
+ }
70
+ await save(0);
71
+ expect(rows.size).toBe(50);
72
+ });
@@ -1,5 +1,5 @@
1
- import type { BlastHit } from './types';
2
1
  import type { BlastDatabase } from '../LaunchMsaView/components/BlastQuery/consts';
2
+ import type { BlastHit } from './types';
3
3
  /**
4
4
  * The subset of EBI's ncbiblast JSON result this plugin reads. The service
5
5
  * returns a great deal more per hit (urls, bit scores, e-values, the match
@@ -33,18 +33,20 @@ export declare function normalizeEbiBlastHits(result: EbiBlastJson): BlastHit[];
33
33
  * yet at the moment the link is on screen.
34
34
  */
35
35
  export declare function ebiBlastResultUrl(jobId: string): string;
36
- export declare function queryEbiBlastFromJobId({ jobId, onProgress, }: {
36
+ export declare function queryEbiBlastFromJobId({ jobId, onProgress, signal, }: {
37
37
  jobId: string;
38
38
  onProgress: (arg: string) => void;
39
+ signal?: AbortSignal;
39
40
  }): Promise<{
40
41
  rid: string;
41
42
  hits: BlastHit[];
42
43
  }>;
43
- export declare function queryEbiBlast({ query, blastDatabase, onProgress, onRid, }: {
44
+ export declare function queryEbiBlast({ query, blastDatabase, onProgress, onRid, signal, }: {
44
45
  query: string;
45
46
  blastDatabase: BlastDatabase;
46
47
  onProgress: (arg: string) => void;
47
48
  onRid: (arg: string) => void;
49
+ signal?: AbortSignal;
48
50
  }): Promise<{
49
51
  rid: string;
50
52
  hits: BlastHit[];
@@ -32,22 +32,23 @@ export function normalizeEbiBlastHits(result) {
32
32
  export function ebiBlastResultUrl(jobId) {
33
33
  return `https://www.ebi.ac.uk/jdispatcher/sss/${TOOL}/summary?jobId=${jobId}`;
34
34
  }
35
- export async function queryEbiBlastFromJobId({ jobId, onProgress, }) {
35
+ export async function queryEbiBlastFromJobId({ jobId, onProgress, signal, }) {
36
36
  onProgress(`Checking BLAST status for job: ${jobId}...`);
37
37
  await waitForEbiJob({
38
38
  tool: TOOL,
39
39
  jobId,
40
+ signal,
40
41
  onCountdown: s => {
41
42
  onProgress(`Re-checking BLAST status in... ${s}`);
42
43
  },
43
44
  });
44
- const hits = normalizeEbiBlastHits(JSON.parse(await fetchEbiResult({ tool: TOOL, jobId, type: 'json' })));
45
+ const hits = normalizeEbiBlastHits(JSON.parse(await fetchEbiResult({ tool: TOOL, jobId, type: 'json', signal })));
45
46
  if (hits.length === 0) {
46
47
  throw new Error('No hits found');
47
48
  }
48
49
  return { rid: jobId, hits };
49
50
  }
50
- export async function queryEbiBlast({ query, blastDatabase, onProgress, onRid, }) {
51
+ export async function queryEbiBlast({ query, blastDatabase, onProgress, onRid, signal, }) {
51
52
  onProgress('Submitting to EBI BLAST...');
52
53
  const jobId = await submitEbiJob({
53
54
  tool: TOOL,
@@ -57,7 +58,8 @@ export async function queryEbiBlast({ query, blastDatabase, onProgress, onRid, }
57
58
  database: blastDatabase,
58
59
  sequence: query,
59
60
  },
61
+ signal,
60
62
  });
61
63
  onRid(jobId);
62
- return queryEbiBlastFromJobId({ jobId, onProgress });
64
+ return queryEbiBlastFromJobId({ jobId, onProgress, signal });
63
65
  }
@@ -16,18 +16,21 @@ export declare const EBI_BASE = "https://www.ebi.ac.uk/Tools/services/rest";
16
16
  export declare const EBI_EMAIL_STORAGE_KEY = "msa-ebiContactEmail";
17
17
  export declare const DEFAULT_EBI_EMAIL = "colin.diesh@gmail.com";
18
18
  export declare function getEbiEmail(): string;
19
- export declare function submitEbiJob({ tool, params, }: {
19
+ export declare function submitEbiJob({ tool, params, signal, }: {
20
20
  tool: string;
21
21
  params: Record<string, string>;
22
+ signal?: AbortSignal;
22
23
  }): Promise<string>;
23
- export declare function waitForEbiJob({ tool, jobId, intervalSeconds, onCountdown, }: {
24
+ export declare function waitForEbiJob({ tool, jobId, intervalSeconds, onCountdown, signal, }: {
24
25
  tool: string;
25
26
  jobId: string;
26
27
  intervalSeconds?: number;
27
28
  onCountdown: (secondsRemaining: number) => void;
29
+ signal?: AbortSignal;
28
30
  }): Promise<void>;
29
- export declare function fetchEbiResult({ tool, jobId, type, }: {
31
+ export declare function fetchEbiResult({ tool, jobId, type, signal, }: {
30
32
  tool: string;
31
33
  jobId: string;
32
34
  type: string;
35
+ signal?: AbortSignal;
33
36
  }): Promise<string>;
@@ -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
  /**
@@ -24,10 +24,11 @@ export function getEbiEmail() {
24
24
  }
25
25
  /** Statuses that mean the job is over and produced no result. */
26
26
  const FAILED_STATUSES = new Set(['ERROR', 'FAILURE', 'NOT_FOUND']);
27
- export async function submitEbiJob({ tool, params, }) {
27
+ export async function submitEbiJob({ tool, params, signal, }) {
28
28
  const jobId = await textfetch(`${EBI_BASE}/${tool}/run`, {
29
29
  method: 'POST',
30
30
  body: new URLSearchParams({ email: getEbiEmail(), ...params }),
31
+ signal,
31
32
  });
32
33
  return jobId.trim();
33
34
  }
@@ -43,17 +44,23 @@ export async function submitEbiJob({ tool, params, }) {
43
44
  * away must not be polled forever.
44
45
  */
45
46
  const MAX_CONSECUTIVE_STATUS_FAILURES = 5;
46
- export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCountdown, }) {
47
+ export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCountdown, signal, }) {
47
48
  let consecutiveFailures = 0;
48
49
  await pollLoop({
49
50
  intervalSeconds,
50
51
  onCountdown,
52
+ signal,
51
53
  check: async () => {
52
54
  let status;
53
55
  try {
54
- status = (await textfetch(`${EBI_BASE}/${tool}/status/${jobId}`)).trim();
56
+ status = (await textfetch(`${EBI_BASE}/${tool}/status/${jobId}`, { signal })).trim();
55
57
  }
56
58
  catch (e) {
59
+ // a cancelled request is the caller giving up, not EBI being
60
+ // unreachable, so it must end the poll rather than be retried
61
+ if (isAbortError(e)) {
62
+ throw e;
63
+ }
57
64
  consecutiveFailures += 1;
58
65
  if (consecutiveFailures >= MAX_CONSECUTIVE_STATUS_FAILURES) {
59
66
  throw new Error(`Could not reach EBI to check ${tool} job ${jobId} after ${consecutiveFailures} tries`, { cause: e });
@@ -75,6 +82,6 @@ export async function waitForEbiJob({ tool, jobId, intervalSeconds = 10, onCount
75
82
  },
76
83
  });
77
84
  }
78
- export async function fetchEbiResult({ tool, jobId, type, }) {
79
- return textfetch(`${EBI_BASE}/${tool}/result/${jobId}/${type}`);
85
+ export async function fetchEbiResult({ tool, jobId, type, signal, }) {
86
+ return textfetch(`${EBI_BASE}/${tool}/result/${jobId}/${type}`, { signal });
80
87
  }
@@ -1,4 +1,11 @@
1
1
  export declare function handleFetch(url: string, args?: RequestInit): Promise<Response>;
2
2
  export declare function textfetch(url: string, args?: RequestInit): Promise<string>;
3
3
  export declare function jsonfetch<T>(url: string, args?: RequestInit): Promise<T>;
4
- export declare function timeout(time: number): Promise<unknown>;
4
+ export declare function timeout(time: number, signal?: AbortSignal): Promise<void>;
5
+ /**
6
+ * A cancelled launch has to be told apart from a failed one: it must not render
7
+ * the "failed" panel, and it must not touch a model that may already be gone.
8
+ * `fetch` and the sleep above both reject with a DOMException named AbortError,
9
+ * which subclasses Error.
10
+ */
11
+ export declare function isAbortError(e: unknown): boolean;
@@ -36,6 +36,32 @@ export async function jsonfetch(url, args) {
36
36
  const response = await handleFetch(url, args);
37
37
  return response.json();
38
38
  }
39
- export function timeout(time) {
40
- return new Promise(res => setTimeout(res, time));
39
+ export function timeout(time, signal) {
40
+ return new Promise((resolve, reject) => {
41
+ if (signal?.aborted) {
42
+ reject(new DOMException('Aborted', 'AbortError'));
43
+ return;
44
+ }
45
+ // the listener is removed on the natural path too: one signal serves every
46
+ // tick of a poll, and a 10-minute job is ~600 sleeps -- leaving each
47
+ // listener behind accumulates them all on that one signal
48
+ const onAbort = () => {
49
+ clearTimeout(id);
50
+ reject(new DOMException('Aborted', 'AbortError'));
51
+ };
52
+ const id = setTimeout(() => {
53
+ signal?.removeEventListener('abort', onAbort);
54
+ resolve();
55
+ }, time);
56
+ signal?.addEventListener('abort', onAbort, { once: true });
57
+ });
58
+ }
59
+ /**
60
+ * A cancelled launch has to be told apart from a failed one: it must not render
61
+ * the "failed" panel, and it must not touch a model that may already be gone.
62
+ * `fetch` and the sleep above both reject with a DOMException named AbortError,
63
+ * which subclasses Error.
64
+ */
65
+ export function isAbortError(e) {
66
+ return e instanceof Error && e.name === 'AbortError';
41
67
  }
@@ -1,8 +1,22 @@
1
1
  import type { MsaAlgorithm } from '../LaunchMsaView/components/BlastQuery/consts';
2
- export declare function launchMSA({ algorithm, sequence, onProgress, }: {
2
+ /**
3
+ * Build a tree from an alignment that already exists, which is what the phmmer
4
+ * path needs: phmmer produces the alignment itself, so there is no aligner run
5
+ * to take a guide tree from — and a guide tree is a byproduct of deciding
6
+ * progressive alignment order, not a phylogeny, so it is not what we would want
7
+ * even if there were one. simple_phylogeny is clustalw2's neighbour-joining on
8
+ * a real distance matrix, Kimura-corrected for protein distances.
9
+ */
10
+ export declare function launchTree({ alignment, onProgress, signal, }: {
11
+ alignment: string;
12
+ onProgress: (arg: string) => void;
13
+ signal?: AbortSignal;
14
+ }): Promise<string>;
15
+ export declare function launchMSA({ algorithm, sequence, onProgress, signal, }: {
3
16
  algorithm: MsaAlgorithm;
4
17
  sequence: string;
5
18
  onProgress: (arg: string) => void;
19
+ signal?: AbortSignal;
6
20
  }): Promise<{
7
21
  msa: string;
8
22
  tree: string;
package/dist/utils/msa.js CHANGED
@@ -21,30 +21,57 @@ const algorithms = {
21
21
  treeResult: 'phylotree',
22
22
  },
23
23
  };
24
- export async function launchMSA({ algorithm, sequence, onProgress, }) {
24
+ /**
25
+ * Build a tree from an alignment that already exists, which is what the phmmer
26
+ * path needs: phmmer produces the alignment itself, so there is no aligner run
27
+ * to take a guide tree from — and a guide tree is a byproduct of deciding
28
+ * progressive alignment order, not a phylogeny, so it is not what we would want
29
+ * even if there were one. simple_phylogeny is clustalw2's neighbour-joining on
30
+ * a real distance matrix, Kimura-corrected for protein distances.
31
+ */
32
+ export async function launchTree({ alignment, onProgress, signal, }) {
33
+ const tool = 'simple_phylogeny';
34
+ onProgress('Building tree...');
35
+ const jobId = await submitEbiJob({
36
+ tool,
37
+ params: {
38
+ sequence: alignment,
39
+ tree: 'phylip',
40
+ clustering: 'Neighbour-joining',
41
+ kimura: 'true',
42
+ },
43
+ signal,
44
+ });
45
+ await waitForEbiJob({
46
+ tool,
47
+ jobId,
48
+ signal,
49
+ onCountdown: s => {
50
+ onProgress(`Re-checking tree status in... ${s}`);
51
+ },
52
+ });
53
+ return fetchEbiResult({ tool, jobId, type: 'tree', signal });
54
+ }
55
+ export async function launchMSA({ algorithm, sequence, onProgress, signal, }) {
25
56
  const config = algorithms[algorithm];
26
57
  onProgress(`Launching ${algorithm} MSA...`);
27
58
  const jobId = await submitEbiJob({
28
59
  tool: algorithm,
29
60
  params: { ...config.params, sequence },
61
+ signal,
30
62
  });
31
63
  await waitForEbiJob({
32
64
  tool: algorithm,
33
65
  jobId,
66
+ signal,
34
67
  onCountdown: s => {
35
68
  onProgress(`Re-checking MSA status in... ${s}`);
36
69
  },
37
70
  });
38
- return {
39
- msa: await fetchEbiResult({
40
- tool: algorithm,
41
- jobId,
42
- type: config.msaResult,
43
- }),
44
- tree: await fetchEbiResult({
45
- tool: algorithm,
46
- jobId,
47
- type: config.treeResult,
48
- }),
49
- };
71
+ // one finished job, two result files, neither derived from the other
72
+ const [msa, tree] = await Promise.all([
73
+ fetchEbiResult({ tool: algorithm, jobId, type: config.msaResult, signal }),
74
+ fetchEbiResult({ tool: algorithm, jobId, type: config.treeResult, signal }),
75
+ ]);
76
+ return { msa, tree };
50
77
  }
@@ -0,0 +1,31 @@
1
+ import type { PhmmerRow } from './phmmer';
2
+ import type { TaxonomyInfo } from './taxonomyNames';
3
+ import type { BlastHitDescription } from './types';
4
+ /**
5
+ * Turning search results into the rows the view is given, kept free of any
6
+ * jbrowse or network import so the whole assembly can be run and checked
7
+ * outside a browser — see test/phmmerLive.test.ts.
8
+ */
9
+ export declare function buildRowMetadata(desc: BlastHitDescription, taxonomyInfo: Map<number, TaxonomyInfo>): Record<string, string>;
10
+ /**
11
+ * One target can match the query in several places and phmmer emits a row per
12
+ * matched envelope — four for lamprey albumin against human albumin, which has
13
+ * three domains. Those rows share an accession and so would share a name, and
14
+ * duplicate names silently collapse rows in both the MSA and the tree, so the
15
+ * envelope disambiguates them.
16
+ */
17
+ export declare function makeRowNames(rows: PhmmerRow[], taxonomyInfo: Map<number, TaxonomyInfo>): string[];
18
+ /**
19
+ * The phmmer alignment as the view receives it: aligned FASTA whose first row
20
+ * is the query, plus the per-row metadata keyed by the same names, which are
21
+ * also what the tree's leaves are labelled with.
22
+ */
23
+ export declare function buildPhmmerMsa({ rows, queryRow, taxonomyInfo, querySeqName, }: {
24
+ rows: PhmmerRow[];
25
+ queryRow: string;
26
+ taxonomyInfo: Map<number, TaxonomyInfo>;
27
+ querySeqName?: string;
28
+ }): {
29
+ msa: string;
30
+ treeMetadata: Record<string, Record<string, string>>;
31
+ };
@@ -0,0 +1,67 @@
1
+ import { makeId } from '../LaunchMsaView/components/util';
2
+ /**
3
+ * Turning search results into the rows the view is given, kept free of any
4
+ * jbrowse or network import so the whole assembly can be run and checked
5
+ * outside a browser — see test/phmmerLive.test.ts.
6
+ */
7
+ export function buildRowMetadata(desc, taxonomyInfo) {
8
+ const metadata = {};
9
+ const taxInfo = desc.taxid ? taxonomyInfo.get(desc.taxid) : undefined;
10
+ if (taxInfo?.sciname) {
11
+ metadata['Scientific name'] = taxInfo.sciname;
12
+ }
13
+ if (taxInfo?.commonName) {
14
+ metadata['Common name'] = taxInfo.commonName;
15
+ }
16
+ if (desc.accession) {
17
+ metadata.Accession = desc.accession;
18
+ }
19
+ if (desc.id) {
20
+ metadata.ID = desc.id;
21
+ }
22
+ if (desc.title) {
23
+ metadata.Description = desc.title;
24
+ }
25
+ return metadata;
26
+ }
27
+ /**
28
+ * One target can match the query in several places and phmmer emits a row per
29
+ * matched envelope — four for lamprey albumin against human albumin, which has
30
+ * three domains. Those rows share an accession and so would share a name, and
31
+ * duplicate names silently collapse rows in both the MSA and the tree, so the
32
+ * envelope disambiguates them.
33
+ */
34
+ export function makeRowNames(rows, taxonomyInfo) {
35
+ const baseNames = rows.map(row => makeId(row, taxonomyInfo));
36
+ const counts = new Map();
37
+ for (const name of baseNames) {
38
+ counts.set(name, (counts.get(name) ?? 0) + 1);
39
+ }
40
+ const used = new Set();
41
+ return baseNames.map((base, i) => {
42
+ let name = counts.get(base) > 1 ? `${base}_${rows[i].range ?? i + 1}` : base;
43
+ while (used.has(name)) {
44
+ name = `${name}_${i + 1}`;
45
+ }
46
+ used.add(name);
47
+ return name;
48
+ });
49
+ }
50
+ /**
51
+ * The phmmer alignment as the view receives it: aligned FASTA whose first row
52
+ * is the query, plus the per-row metadata keyed by the same names, which are
53
+ * also what the tree's leaves are labelled with.
54
+ */
55
+ export function buildPhmmerMsa({ rows, queryRow, taxonomyInfo, querySeqName = 'QUERY', }) {
56
+ const treeMetadata = {};
57
+ const rowNames = makeRowNames(rows, taxonomyInfo);
58
+ const sequences = rows.map((row, i) => {
59
+ const rowName = rowNames[i];
60
+ treeMetadata[rowName] = buildRowMetadata(row, taxonomyInfo);
61
+ return `>${rowName}\n${row.aligned}`;
62
+ });
63
+ return {
64
+ msa: [`>${querySeqName}\n${queryRow}`, ...sequences].join('\n'),
65
+ treeMetadata,
66
+ };
67
+ }
@@ -5,11 +5,11 @@ export type DomainMatch = InterProScanResults['matches'][number];
5
5
  * domain and site annotations, keyed by both the versioned and primary
6
6
  * accession so callers can look up by whichever NCBI returned.
7
7
  */
8
- export declare function parseCddDomains(xml: string): Map<string, import("msa-parsers").InterProScanMatch[]>;
8
+ export declare function parseCddDomains(xml: string): Map<string, DomainMatch[]>;
9
9
  /**
10
10
  * Fetch pre-computed CDD domain and site annotations for NCBI protein
11
11
  * accessions. These come baked into the GenPept records, so a single batched
12
12
  * efetch returns them with no job submission or polling. Results are cached in
13
13
  * IndexedDB so reopening a view doesn't refetch.
14
14
  */
15
- export declare function fetchProteinDomains(accessions: string[]): Promise<Map<string, import("msa-parsers").InterProScanMatch[]>>;
15
+ export declare function fetchProteinDomains(accessions: string[]): Promise<Map<string, DomainMatch[]>>;
@@ -10,6 +10,13 @@ export interface OrthologRow {
10
10
  protein: string;
11
11
  sequence: string;
12
12
  }
13
+ /**
14
+ * A candidate gene reference as the ortholog services know it: a GFF ID prefix
15
+ * (gene:TP53) and a version suffix (NM_000546.6) stripped off. Shared with
16
+ * pantherOrthologs, which asks PANTHER the same question with the same
17
+ * candidates.
18
+ */
19
+ export declare function cleanGeneCandidate(raw: string): string;
13
20
  /**
14
21
  * A free-text gene reference -> NCBI gene id. A bare number is taken as the id
15
22
  * itself; anything else is searched as a gene name within the query taxon.
@@ -50,7 +57,10 @@ export declare function parseFasta(text: string): Map<string, string>;
50
57
  * Sanitized, unique single-token labels used identically in the FASTA headers,
51
58
  * the tree leaf names and the domain GFF seq_ids — that identity is how the
52
59
  * viewer pairs a tree leaf to its alignment row to its domain track. Collisions
53
- * get a numeric suffix rather than silently overwriting a row.
60
+ * get a numeric suffix rather than silently overwriting a row, and the suffix
61
+ * climbs past labels already emitted: a species genuinely named `human_2` would
62
+ * otherwise take the label the second `human` is about to get, merging two rows
63
+ * into one.
54
64
  */
55
65
  export declare function dedupeLabels(names: string[]): string[];
56
66
  /**